Bundle SnappyMail list-unsubscribe-header plugin; enable on install/upgrade

- Add install/snappymail/plugins/list-unsubscribe-header (upstream GitHub plugin)
- Add plogical/snappymail_plugin_utilities.py to copy into snappymail + legacy rainloop data roots and merge enabled_list
- Run after SnappyMail CyberPanel installer in install.py and upgrade.py
- InstallMailBoxFoldersPlugin now merges plugins instead of replacing enabled_list; also installs list-unsubscribe

Roundcube is not shipped by CyberPanel core; SnappyMail is the bundled webmail.
This commit is contained in:
master3395
2026-04-11 01:51:09 +02:00
parent 46c9725715
commit 7306fcb87d
5 changed files with 238 additions and 18 deletions

View File

@@ -4839,6 +4839,13 @@ user_query = SELECT email as user, password, 'vmail' as uid, 'vmail' as gid, '/h
command = f'/usr/local/lsws/lsphp80/bin/php /usr/local/CyberCP/snappymail_cyberpanel.php'
preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR)
try:
from plogical.snappymail_plugin_utilities import install_and_enable_list_unsubscribe_header_plugin
if install_and_enable_list_unsubscribe_header_plugin():
logging.InstallLog.writeToFile("SnappyMail list-unsubscribe-header plugin installed and enabled", 0)
except BaseException as plug_msg:
logging.InstallLog.writeToFile("Warning: list-unsubscribe SnappyMail plugin: " + str(plug_msg), 0)
except BaseException as msg:
logging.InstallLog.writeToFile('[ERROR] ' + str(msg) + " [downoad_and_install_snappymail]")

View File

@@ -0,0 +1,83 @@
<?php
/**
* Bundled with CyberPanel (see plogical/snappymail_plugin_utilities.py).
* Upstream: https://github.com/master3395/snappymail-list-unsubscribe-header
*
* Adds List-Unsubscribe and List-Unsubscribe-Post on outbound mail (RFC 2369 / RFC 8058).
*/
class ListUnsubscribeHeaderPlugin extends \RainLoop\Plugins\AbstractPlugin
{
const
NAME = 'List-Unsubscribe headers',
AUTHOR = 'Master3395',
URL = 'https://newstargeted.com/',
VERSION = '1.1.0',
RELEASE = '2026-04-11',
REQUIRED = '2.0.0',
DESCRIPTION = 'Adds List-Unsubscribe and List-Unsubscribe-Post for bulk/mailing-list best practices.';
public function Init() : void
{
$this->addHook('filter.send-message', 'FilterSendMessage');
}
/**
* @return array
*/
protected function configMapping() : array
{
return array(
\RainLoop\Plugins\Property::NewInstance('unsubscribe_https_url')
->SetLabel('HTTPS unsubscribe URL')
->SetType(\RainLoop\Enumerations\PluginPropertyType::URL)
->SetDescription('Must be https:// — used in List-Unsubscribe (RFC 8058 one-click target).')
->SetDefaultValue('https://newstargeted.com/list-unsubscribe-endpoint.php'),
\RainLoop\Plugins\Property::NewInstance('mailto_unsubscribe')
->SetLabel('Unsubscribe mailto address')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
->SetDescription('Email only (e.g. postmaster@example.com) or full mailto:user@example.com')
->SetDefaultValue('postmaster@newstargeted.com'),
\RainLoop\Plugins\Property::NewInstance('one_click_post')
->SetLabel('Send List-Unsubscribe-Post (one-click)')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDescription('RFC 8058 — disable if your HTTPS URL does not accept POST.')
->SetDefaultValue(true)
);
}
/**
* @param \MailSo\Mime\Message $oMessage
*/
public function FilterSendMessage(&$oMessage) : void
{
if ($oMessage instanceof \MailSo\Mime\Message) {
$sHttps = \trim((string) $this->Config()->Get('plugin', 'unsubscribe_https_url', 'https://newstargeted.com/list-unsubscribe-endpoint.php'));
$sMailRaw = \trim((string) $this->Config()->Get('plugin', 'mailto_unsubscribe', 'postmaster@newstargeted.com'));
$bOneClick = (bool) $this->Config()->Get('plugin', 'one_click_post', true);
if ($sHttps === '' || !\preg_match('#^https://#i', $sHttps)) {
$sHttps = 'https://newstargeted.com/list-unsubscribe-endpoint.php';
}
if ($sMailRaw === '') {
$sMailto = 'mailto:postmaster@newstargeted.com';
} elseif (\stripos($sMailRaw, 'mailto:') === 0) {
$sMailto = $sMailRaw;
} else {
$sMailto = 'mailto:' . $sMailRaw;
}
if (!\preg_match('#^mailto:[^<>\s]+$#i', $sMailto)) {
$sMailto = 'mailto:postmaster@newstargeted.com';
}
$sListUnsub = '<' . $sHttps . '>, <' . $sMailto . '>';
$oMessage->SetCustomHeader(
\MailSo\Mime\Enumerations\Header::LIST_UNSUBSCRIBE,
$sListUnsub
);
if ($bOneClick) {
$oMessage->SetCustomHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
}
}
}
}

View File

@@ -211,26 +211,13 @@ class mailUtilities:
command = f'chown lscpd:lscpd /usr/local/lscp/cyberpanel/snappymail/data/_data_/_default_/plugins/mailbox-detect/index.php'
ProcessUtilities.executioner(command)
### Enable plugins and enable mailbox creation plugin
### Enable plugins: merge mailbox-detect + bundled list-unsubscribe (preserve other plugins)
labsDataLines = open(labsPath, 'r').readlines()
PluginsActivator = 0
WriteToFile = open(labsPath, 'w')
from plogical.snappymail_plugin_utilities import merge_plugin_into_application_ini
from plogical.snappymail_plugin_utilities import install_and_enable_list_unsubscribe_header_plugin
for lines in labsDataLines:
if lines.find('[plugins]') > -1:
PluginsActivator = 1
WriteToFile.write(lines)
elif PluginsActivator and lines.find('enable = ') > -1:
WriteToFile.write(f'enable = On\n')
elif PluginsActivator and lines.find('enabled_list = ') > -1:
WriteToFile.write(f'enabled_list = "mailbox-detect"\n')
elif PluginsActivator == 1 and lines.find('[defaults]') > -1:
PluginsActivator = 0
WriteToFile.write(lines)
else:
WriteToFile.write(lines)
WriteToFile.close()
merge_plugin_into_application_ini(labsPath, 'mailbox-detect')
install_and_enable_list_unsubscribe_header_plugin()
## enable auto create in the enabled plugin
PluginsFilePath = '/usr/local/lscp/cyberpanel/snappymail/data/_data_/_default_/configs/plugin-mailbox-detect.json'

View File

@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
"""
Bundle and enable the SnappyMail list-unsubscribe-header plugin (CyberPanel).
Data may live under snappymail/ or legacy rainloop/ until migration completes.
Upstream plugin: https://github.com/master3395/snappymail-list-unsubscribe-header
"""
import os
import re
import shutil
import subprocess
PLUGIN_ID_LIST_UNSUBSCRIBE = 'list-unsubscribe-header'
BUNDLE_REL = 'install/snappymail/plugins/list-unsubscribe-header/index.php'
CYBERCP_ROOT = '/usr/local/CyberCP'
DATA_ROOT_CANDIDATES = (
'/usr/local/lscp/cyberpanel/snappymail/data',
'/usr/local/lscp/cyberpanel/rainloop/data',
)
def bundled_list_unsubscribe_src():
return os.path.join(CYBERCP_ROOT, BUNDLE_REL)
def _merge_enabled_list_line(line, plugin_id):
"""Merge plugin_id into SnappyMail enabled_list = \"...\" line."""
m = re.match(r'^(\s*enabled_list\s*=\s*")([^"]*)("\s*)\r?$', line)
if not m:
return line
inner = m.group(2)
parts = [p.strip() for p in inner.split(',') if p.strip()]
if plugin_id not in parts:
parts.append(plugin_id)
inner_new = ','.join(parts)
return m.group(1) + inner_new + m.group(3) + ('\n' if line.endswith('\n') else '')
def _force_plugins_enable_on_line(line):
"""Under [plugins], ensure enable = On."""
if not re.match(r'^\s*enable\s*=', line):
return line
if re.search(r'(?i)=\s*On\b', line):
return line
return re.sub(r'(?i)=\s*\S+', '= On', line, count=1)
def merge_plugin_into_application_ini(application_ini_path, plugin_id):
"""
Merge plugin_id into [plugins] enabled_list; set enable = On when that key is present.
"""
if not os.path.isfile(application_ini_path):
return False
with open(application_ini_path, 'r') as f:
lines = f.readlines()
out = []
in_plugins = False
for line in lines:
stripped = line.lstrip()
if stripped.startswith('[plugins]'):
in_plugins = True
out.append(line)
continue
if in_plugins and stripped.startswith('[') and not stripped.startswith('[plugins]'):
in_plugins = False
if in_plugins and re.match(r'^\s*enabled_list\s*=', line):
out.append(_merge_enabled_list_line(line, plugin_id))
continue
if in_plugins and re.match(r'^\s*enable\s*=', line):
out.append(_force_plugins_enable_on_line(line))
continue
out.append(line)
with open(application_ini_path, 'w') as f:
f.writelines(out)
return True
def _chown_lscpd(path):
try:
subprocess.check_call(['chown', 'lscpd:lscpd', path], stderr=subprocess.DEVNULL)
except (OSError, subprocess.CalledProcessError):
pass
def _copy_bundled_plugin_to_data_root(data_root):
"""Copy bundled index.php into .../plugins/list-unsubscribe-header/."""
src = bundled_list_unsubscribe_src()
if not os.path.isfile(src):
return False
dest_dir = os.path.join(
data_root, '_data_', '_default_', 'plugins', PLUGIN_ID_LIST_UNSUBSCRIBE
)
try:
os.makedirs(dest_dir, mode=0o700, exist_ok=True)
except OSError:
return False
dest_file = os.path.join(dest_dir, 'index.php')
try:
shutil.copy2(src, dest_file)
except (OSError, IOError):
return False
try:
os.chmod(dest_file, 0o644)
except OSError:
pass
_chown_lscpd(dest_file)
_chown_lscpd(dest_dir)
return True
def install_and_enable_list_unsubscribe_header_plugin():
"""
Copy bundled plugin into each existing SnappyMail data root and merge into enabled_list.
Idempotent. Returns 1 if at least one data root was updated, else 0.
"""
if not os.path.isfile(bundled_list_unsubscribe_src()):
return 0
ok = 0
for root in DATA_ROOT_CANDIDATES:
default_path = os.path.join(root, '_data_', '_default_')
if not os.path.isdir(default_path):
continue
if not _copy_bundled_plugin_to_data_root(root):
continue
app_ini = os.path.join(default_path, 'configs', 'application.ini')
if os.path.isfile(app_ini):
merge_plugin_into_application_ini(app_ini, PLUGIN_ID_LIST_UNSUBSCRIBE)
try:
os.chmod(app_ini, 0o600)
except OSError:
pass
_chown_lscpd(app_ini)
ok = 1
return ok

View File

@@ -1587,6 +1587,13 @@ $cfg['Servers'][$i]['port'] = '3306';
command = f'/usr/local/lsws/lsphp83/bin/php /usr/local/CyberCP/snappymail_cyberpanel.php'
Upgrade.executioner_silent(command, 'verify certificate', 0)
try:
from plogical.snappymail_plugin_utilities import install_and_enable_list_unsubscribe_header_plugin
if install_and_enable_list_unsubscribe_header_plugin():
Upgrade.stdOut("SnappyMail list-unsubscribe-header plugin installed and enabled", 0)
except BaseException as plug_msg:
Upgrade.stdOut("Warning: list-unsubscribe SnappyMail plugin: " + str(plug_msg), 0)
# labsPath = '/usr/local/lscp/cyberpanel/rainloop/data/_data_/_default_/configs/application.ini'
# labsData = """[labs]