From 7306fcb87d88bda9eecafe0a2f0f60595d8619e5 Mon Sep 17 00:00:00 2001 From: master3395 Date: Sat, 11 Apr 2026 01:51:09 +0200 Subject: [PATCH] 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. --- install/install.py | 7 + .../plugins/list-unsubscribe-header/index.php | 83 +++++++++++ plogical/mailUtilities.py | 23 +-- plogical/snappymail_plugin_utilities.py | 136 ++++++++++++++++++ plogical/upgrade.py | 7 + 5 files changed, 238 insertions(+), 18 deletions(-) create mode 100644 install/snappymail/plugins/list-unsubscribe-header/index.php create mode 100644 plogical/snappymail_plugin_utilities.py diff --git a/install/install.py b/install/install.py index 897af0aea..4e23bcbfa 100644 --- a/install/install.py +++ b/install/install.py @@ -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]") diff --git a/install/snappymail/plugins/list-unsubscribe-header/index.php b/install/snappymail/plugins/list-unsubscribe-header/index.php new file mode 100644 index 000000000..8731b975a --- /dev/null +++ b/install/snappymail/plugins/list-unsubscribe-header/index.php @@ -0,0 +1,83 @@ +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'); + } + } + } +} diff --git a/plogical/mailUtilities.py b/plogical/mailUtilities.py index ec5f821ef..8eab8da85 100644 --- a/plogical/mailUtilities.py +++ b/plogical/mailUtilities.py @@ -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' diff --git a/plogical/snappymail_plugin_utilities.py b/plogical/snappymail_plugin_utilities.py new file mode 100644 index 000000000..2f0231176 --- /dev/null +++ b/plogical/snappymail_plugin_utilities.py @@ -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 diff --git a/plogical/upgrade.py b/plogical/upgrade.py index 30a9fdcb3..fd5dcd6d8 100644 --- a/plogical/upgrade.py +++ b/plogical/upgrade.py @@ -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]