diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 89cc317c..484f5670 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**185 classes** across **32 capabilities** · **19 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**194 classes** across **32 capabilities** · **19 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -149,6 +149,15 @@ ### Mail - **Tiger_Mail** `@api` — a thin, fluent wrapper over Zend_Mail. · `library/Tiger/Mail.php` +- **Tiger_Mail_Provider** `@api` — the catalog of mail providers the admin can choose from. · `library/Tiger/Mail/Provider.php` +- **Tiger_Mail_Transport_Api** `@api` — base for the HTTPS mail-API drivers (SendGrid, Mailgun, Postmark, Resend, Brevo, Mailjet, SES). · `library/Tiger/Mail/Transport/Api.php` +- **Tiger_Mail_Transport_Brevo** `@api` — send over the Brevo (formerly Sendinblue) transactional API. · `library/Tiger/Mail/Transport/Brevo.php` +- **Tiger_Mail_Transport_Mailgun** `@api` — send over the Mailgun Messages API. · `library/Tiger/Mail/Transport/Mailgun.php` +- **Tiger_Mail_Transport_Mailjet** `@api` — send over the Mailjet Send API v3.1. · `library/Tiger/Mail/Transport/Mailjet.php` +- **Tiger_Mail_Transport_Postmark** `@api` — send over the Postmark Email API. · `library/Tiger/Mail/Transport/Postmark.php` +- **Tiger_Mail_Transport_Resend** `@api` — send over the Resend API. · `library/Tiger/Mail/Transport/Resend.php` +- **Tiger_Mail_Transport_SendGrid** `@api` — send over the SendGrid v3 Mail Send API. · `library/Tiger/Mail/Transport/SendGrid.php` +- **Tiger_Mail_Transport_Ses** `@api` — send over the Amazon SES v2 API using the vendored AWS SDK. · `library/Tiger/Mail/Transport/Ses.php` ### Location diff --git a/core/languages/de/core.php b/core/languages/de/core.php index d6b8252c..63fe76b4 100644 --- a/core/languages/de/core.php +++ b/core/languages/de/core.php @@ -193,4 +193,17 @@ 'core.media.field.clear' => 'Entfernen', 'core.media.field.preview_alt' => 'Vorschau des ausgewählten Mediums', 'core.media.field.file' => 'Ausgewählte Datei', + + // --- Mail providers (Tiger_Mail_Provider) --- + 'core.mail.provider.help.ses_smtp' => 'SES-SMTP-Zugangsdaten werden in der SES-Konsole erzeugt — sie sind NICHT Ihre AWS-Zugriffsschlüssel. Tragen Sie sie unten als Benutzername und Passwort ein.', + 'core.mail.provider.help.ses_api' => 'Versendet über die SES-v2-API mit dem mitgelieferten AWS-SDK.', + 'core.mail.provider.help.ses_api_iam' => 'Lassen Sie Schlüssel und Secret leer, um die IAM-Rolle der Instanz zu verwenden — dann wird gar kein Zugangsdatum gespeichert.', + 'core.mail.provider.help.sendgrid_smtp' => 'Verwenden Sie den wörtlichen Benutzernamen „apikey“ und Ihren API-Schlüssel als Passwort.', + 'core.mail.provider.help.postmark_smtp' => 'Verwenden Sie Ihr Server-API-Token SOWOHL als Benutzernamen ALS AUCH als Passwort.', + 'core.mail.provider.help.resend_smtp' => 'Verwenden Sie den wörtlichen Benutzernamen „resend“ und Ihren API-Schlüssel als Passwort.', + 'core.mail.provider.help.mailgun_region' => 'Mailgun betreibt getrennte US- und EU-Regionen; ein Schlüssel funktioniert nur in der Region, in der er erstellt wurde.', + 'core.mail.provider.help.google_smtp' => 'Erfordert ein App-Passwort bei aktivierter Bestätigung in zwei Schritten — das normale Kontopasswort funktioniert nicht.', + 'core.mail.provider.help.microsoft_smtp' => 'Microsoft deaktiviert SMTP AUTH standardmäßig und stellt die Basisauthentifizierung ein; möglicherweise müssen Sie sie für dieses Postfach aktivieren.', + 'core.mail.provider.requires.aws_sdk' => 'Dieser Treiber benötigt das AWS-SDK-Modul (tiger-sdk-aws). Installieren und aktivieren Sie es, oder verwenden Sie Amazon SES (SMTP).', + 'core.mail.provider.requires.generic' => 'Der Treiber dieses Anbieters ist auf dieser Installation nicht verfügbar.', ]; diff --git a/core/languages/en/core.php b/core/languages/en/core.php index 488339de..e5e532cf 100644 --- a/core/languages/en/core.php +++ b/core/languages/en/core.php @@ -197,4 +197,17 @@ 'core.media.field.clear' => 'Clear', 'core.media.field.preview_alt' => 'Selected media preview', 'core.media.field.file' => 'Selected file', + + // --- Mail providers (Tiger_Mail_Provider) --- + 'core.mail.provider.help.ses_smtp' => 'SES SMTP credentials are generated in the SES console — they are NOT your AWS access keys. Enter them as the username and password below.', + 'core.mail.provider.help.ses_api' => 'Sends through the SES v2 API using the vendored AWS SDK.', + 'core.mail.provider.help.ses_api_iam' => 'Leave the key and secret blank to use the instance IAM role — then no credentials are stored at all.', + 'core.mail.provider.help.sendgrid_smtp' => 'Use the literal username "apikey" and your API key as the password.', + 'core.mail.provider.help.postmark_smtp' => 'Use your Server API token as BOTH the username and the password.', + 'core.mail.provider.help.resend_smtp' => 'Use the literal username "resend" and your API key as the password.', + 'core.mail.provider.help.mailgun_region' => 'Mailgun runs separate US and EU regions, and a key only works in the region it was created in.', + 'core.mail.provider.help.google_smtp' => 'Requires an App Password with 2-Step Verification enabled — a normal account password will not work.', + 'core.mail.provider.help.microsoft_smtp' => 'Microsoft disables SMTP AUTH by default and is retiring basic authentication; you may need to enable it for this mailbox.', + 'core.mail.provider.requires.aws_sdk' => 'This driver needs the AWS SDK module (tiger-sdk-aws). Install and activate it, or use Amazon SES (SMTP) instead.', + 'core.mail.provider.requires.generic' => 'This provider\'s driver is not available on this install.', ]; diff --git a/core/languages/es/core.php b/core/languages/es/core.php index ec1def2e..c5c25ac6 100644 --- a/core/languages/es/core.php +++ b/core/languages/es/core.php @@ -193,4 +193,17 @@ 'core.media.field.clear' => 'Quitar', 'core.media.field.preview_alt' => 'Vista previa del medio seleccionado', 'core.media.field.file' => 'Archivo seleccionado', + + // --- Mail providers (Tiger_Mail_Provider) --- + 'core.mail.provider.help.ses_smtp' => 'Las credenciales SMTP de SES se generan en la consola de SES; NO son tus claves de acceso de AWS. Introdúcelas como usuario y contraseña más abajo.', + 'core.mail.provider.help.ses_api' => 'Envía mediante la API SES v2 usando el SDK de AWS incluido.', + 'core.mail.provider.help.ses_api_iam' => 'Deja la clave y el secreto en blanco para usar el rol IAM de la instancia: así no se almacena ninguna credencial.', + 'core.mail.provider.help.sendgrid_smtp' => 'Usa el usuario literal «apikey» y tu clave de API como contraseña.', + 'core.mail.provider.help.postmark_smtp' => 'Usa tu Server API token TANTO como usuario como contraseña.', + 'core.mail.provider.help.resend_smtp' => 'Usa el usuario literal «resend» y tu clave de API como contraseña.', + 'core.mail.provider.help.mailgun_region' => 'Mailgun opera regiones separadas de EE. UU. y UE, y una clave solo funciona en la región donde se creó.', + 'core.mail.provider.help.google_smtp' => 'Requiere una contraseña de aplicación con la verificación en dos pasos activada; la contraseña normal de la cuenta no funciona.', + 'core.mail.provider.help.microsoft_smtp' => 'Microsoft desactiva SMTP AUTH de forma predeterminada y está retirando la autenticación básica; puede que tengas que habilitarla para este buzón.', + 'core.mail.provider.requires.aws_sdk' => 'Este controlador necesita el módulo del SDK de AWS (tiger-sdk-aws). Instálalo y actívalo, o usa Amazon SES (SMTP).', + 'core.mail.provider.requires.generic' => 'El controlador de este proveedor no está disponible en esta instalación.', ]; diff --git a/core/languages/fr/core.php b/core/languages/fr/core.php index a6fd8227..a4e66f27 100644 --- a/core/languages/fr/core.php +++ b/core/languages/fr/core.php @@ -193,4 +193,17 @@ 'core.media.field.clear' => 'Retirer', 'core.media.field.preview_alt' => 'Aperçu du média sélectionné', 'core.media.field.file' => 'Fichier sélectionné', + + // --- Mail providers (Tiger_Mail_Provider) --- + 'core.mail.provider.help.ses_smtp' => 'Les identifiants SMTP SES se génèrent dans la console SES — ce ne sont PAS vos clés d\'accès AWS. Saisissez-les ci-dessous comme nom d\'utilisateur et mot de passe.', + 'core.mail.provider.help.ses_api' => 'Envoie via l\'API SES v2 à l\'aide du SDK AWS fourni.', + 'core.mail.provider.help.ses_api_iam' => 'Laissez la clé et le secret vides pour utiliser le rôle IAM de l\'instance — aucun identifiant n\'est alors stocké.', + 'core.mail.provider.help.sendgrid_smtp' => 'Utilisez le nom d\'utilisateur littéral « apikey » et votre clé d\'API comme mot de passe.', + 'core.mail.provider.help.postmark_smtp' => 'Utilisez votre jeton Server API À LA FOIS comme nom d\'utilisateur et comme mot de passe.', + 'core.mail.provider.help.resend_smtp' => 'Utilisez le nom d\'utilisateur littéral « resend » et votre clé d\'API comme mot de passe.', + 'core.mail.provider.help.mailgun_region' => 'Mailgun exploite des régions US et UE distinctes ; une clé ne fonctionne que dans la région où elle a été créée.', + 'core.mail.provider.help.google_smtp' => 'Nécessite un mot de passe d\'application avec la validation en deux étapes activée — le mot de passe habituel du compte ne fonctionnera pas.', + 'core.mail.provider.help.microsoft_smtp' => 'Microsoft désactive SMTP AUTH par défaut et retire l\'authentification de base ; vous devrez peut-être l\'activer pour cette boîte aux lettres.', + 'core.mail.provider.requires.aws_sdk' => 'Ce pilote nécessite le module SDK AWS (tiger-sdk-aws). Installez-le et activez-le, ou utilisez Amazon SES (SMTP).', + 'core.mail.provider.requires.generic' => 'Le pilote de ce fournisseur n\'est pas disponible sur cette installation.', ]; diff --git a/core/languages/hi/core.php b/core/languages/hi/core.php index b1d79376..39ca5d06 100644 --- a/core/languages/hi/core.php +++ b/core/languages/hi/core.php @@ -193,4 +193,17 @@ 'core.media.field.clear' => 'हटाएँ', 'core.media.field.preview_alt' => 'चयनित मीडिया का प्रीव्यू', 'core.media.field.file' => 'चयनित फ़ाइल', + + // --- Mail providers (Tiger_Mail_Provider) --- + 'core.mail.provider.help.ses_smtp' => 'SES SMTP क्रेडेंशियल SES कंसोल में बनाए जाते हैं — ये आपकी AWS एक्सेस कुंजियाँ नहीं हैं। इन्हें नीचे उपयोगकर्ता नाम और पासवर्ड के रूप में डालें।', + 'core.mail.provider.help.ses_api' => 'साथ में शामिल AWS SDK का उपयोग करके SES v2 API से भेजता है।', + 'core.mail.provider.help.ses_api_iam' => 'इंस्टेंस की IAM भूमिका उपयोग करने के लिए कुंजी और सीक्रेट खाली छोड़ें — तब कोई क्रेडेंशियल संग्रहीत नहीं होता।', + 'core.mail.provider.help.sendgrid_smtp' => 'उपयोगकर्ता नाम के रूप में शब्दशः "apikey" और पासवर्ड के रूप में अपनी API कुंजी उपयोग करें।', + 'core.mail.provider.help.postmark_smtp' => 'अपने Server API token को उपयोगकर्ता नाम और पासवर्ड दोनों के रूप में उपयोग करें।', + 'core.mail.provider.help.resend_smtp' => 'उपयोगकर्ता नाम के रूप में शब्दशः "resend" और पासवर्ड के रूप में अपनी API कुंजी उपयोग करें।', + 'core.mail.provider.help.mailgun_region' => 'Mailgun अलग-अलग US और EU क्षेत्र चलाता है, और कुंजी केवल उसी क्षेत्र में काम करती है जहाँ बनाई गई थी।', + 'core.mail.provider.help.google_smtp' => 'इसके लिए 2-स्टेप वेरिफिकेशन चालू होने के साथ ऐप पासवर्ड आवश्यक है — सामान्य खाता पासवर्ड काम नहीं करेगा।', + 'core.mail.provider.help.microsoft_smtp' => 'Microsoft डिफ़ॉल्ट रूप से SMTP AUTH बंद रखता है और बेसिक प्रमाणीकरण हटा रहा है; आपको इस मेलबॉक्स के लिए इसे सक्षम करना पड़ सकता है।', + 'core.mail.provider.requires.aws_sdk' => 'इस ड्राइवर के लिए AWS SDK मॉड्यूल (tiger-sdk-aws) चाहिए। इसे इंस्टॉल और सक्रिय करें, या Amazon SES (SMTP) उपयोग करें।', + 'core.mail.provider.requires.generic' => 'इस प्रदाता का ड्राइवर इस इंस्टॉल पर उपलब्ध नहीं है।', ]; diff --git a/core/languages/pt/core.php b/core/languages/pt/core.php index b8afb57f..6e0a621c 100644 --- a/core/languages/pt/core.php +++ b/core/languages/pt/core.php @@ -193,4 +193,17 @@ 'core.media.field.clear' => 'Remover', 'core.media.field.preview_alt' => 'Pré-visualização da mídia selecionada', 'core.media.field.file' => 'Arquivo selecionado', + + // --- Mail providers (Tiger_Mail_Provider) --- + 'core.mail.provider.help.ses_smtp' => 'As credenciais SMTP do SES são geradas no console do SES — elas NÃO são suas chaves de acesso da AWS. Informe-as como usuário e senha abaixo.', + 'core.mail.provider.help.ses_api' => 'Envia pela API SES v2 usando o SDK da AWS incluído.', + 'core.mail.provider.help.ses_api_iam' => 'Deixe a chave e o segredo em branco para usar a role IAM da instância — assim nenhuma credencial é armazenada.', + 'core.mail.provider.help.sendgrid_smtp' => 'Use o usuário literal "apikey" e sua chave de API como senha.', + 'core.mail.provider.help.postmark_smtp' => 'Use seu Server API token TANTO como usuário quanto como senha.', + 'core.mail.provider.help.resend_smtp' => 'Use o usuário literal "resend" e sua chave de API como senha.', + 'core.mail.provider.help.mailgun_region' => 'O Mailgun opera regiões separadas nos EUA e na UE, e uma chave só funciona na região em que foi criada.', + 'core.mail.provider.help.google_smtp' => 'Requer uma Senha de app com a verificação em duas etapas ativada — a senha normal da conta não funciona.', + 'core.mail.provider.help.microsoft_smtp' => 'A Microsoft desativa o SMTP AUTH por padrão e está descontinuando a autenticação básica; pode ser necessário habilitá-la para esta caixa de correio.', + 'core.mail.provider.requires.aws_sdk' => 'Este driver precisa do módulo do SDK da AWS (tiger-sdk-aws). Instale e ative-o, ou use o Amazon SES (SMTP).', + 'core.mail.provider.requires.generic' => 'O driver deste provedor não está disponível nesta instalação.', ]; diff --git a/library/Tiger/Mail.php b/library/Tiger/Mail.php index cb4a35e1..6d02b124 100644 --- a/library/Tiger/Mail.php +++ b/library/Tiger/Mail.php @@ -164,13 +164,23 @@ public function transport() $m = $this->_config ? $this->_config->get('mail') : null; $type = ($m && $m->get('transport')) ? strtolower((string) $m->transport) : 'mail'; + // A provider API driver (SendGrid, SES, …) is just another transport — see + // Tiger_Mail_Transport_Api. Resolved from the stored provider + its credentials. + if ($type === 'api') { + $provider = $m ? (string) $m->get('provider') : ''; + $api = self::apiTransport($provider, self::apiCredentials($provider)); + if ($api) { return $api; } + // An unusable API provider (driver missing, e.g. the AWS SDK was deactivated) must not + // fatal the request — fall through to sendmail, the same way an unset provider does. + } + if ($type === 'smtp' && $m && $m->get('smtp') && (string) $m->smtp->get('host') !== '') { $s = $m->smtp; $opts = []; if ((string) $s->get('auth') !== '') { $opts['auth'] = (string) $s->auth; $opts['username'] = (string) $s->get('username'); - $opts['password'] = (string) $s->get('password'); + $opts['password'] = self::_smtpPassword($s); } if ((string) $s->get('port') !== '') { $opts['port'] = (int) $s->port; } if ((string) $s->get('ssl') !== '') { $opts['ssl'] = (string) $s->ssl; } @@ -180,6 +190,290 @@ public function transport() return new Zend_Mail_Transport_Sendmail(); // boring, reliable PHP mail() } + /** + * The SMTP password from the `mail.smtp` node — `password_enc` (encrypted at rest, written by + * the admin screen) wins, else the legacy plaintext `password`. + * + * Both are supported on purpose: an install configured before the admin screen existed has a + * plaintext `mail.smtp.password` in its `local.ini` or `config` tier, and must keep working + * untouched. Saving from the admin screen upgrades it to the encrypted key. + * + * @param Zend_Config $smtp the resolved `mail.smtp` config node + * @return string the plaintext password ('' when none is set) + */ + protected static function _smtpPassword($smtp) + { + $enc = (string) $smtp->get('password_enc'); + if ($enc !== '') { + try { + if (class_exists('Tiger_Crypto') && Tiger_Crypto::isConfigured()) { + return (string) Tiger_Crypto::decrypt($enc); + } + } catch (Throwable $e) { + // Fall through to the plaintext key — a mail send must never fatal on a bad secret. + } + } + return (string) $smtp->get('password'); + } + + /** + * Build a transport from EXPLICIT values rather than the resolved config. + * + * This is what lets the admin screen "Send test" against the values currently in the form, + * before they're saved — so a wrong host or port is caught without first writing bad config + * over a working setup. + * + * @param array $v transport, host, port, ssl, auth, username, password + * @return Zend_Mail_Transport_Abstract + */ + public static function transportFor(array $v) + { + $type = strtolower((string) ($v['transport'] ?? 'mail')); + + // API provider — build the driver from the values given (the "Send test" path passes the + // form's current provider + credentials so a setup is testable before it's saved). + if ($type === 'api') { + $api = self::apiTransport((string) ($v['provider'] ?? ''), (array) ($v['fields'] ?? [])); + if ($api) { return $api; } + return new Zend_Mail_Transport_Sendmail(); + } + + $host = trim((string) ($v['host'] ?? '')); + if ($type !== 'smtp' || $host === '') { + return new Zend_Mail_Transport_Sendmail(); + } + + $opts = []; + $auth = strtolower(trim((string) ($v['auth'] ?? ''))); + if ($auth !== '') { + $opts['auth'] = $auth; + $opts['username'] = (string) ($v['username'] ?? ''); + $opts['password'] = (string) ($v['password'] ?? ''); + } + $port = (int) ($v['port'] ?? 0); + if ($port > 0) { $opts['port'] = $port; } + $ssl = strtolower(trim((string) ($v['ssl'] ?? ''))); + if ($ssl !== '') { $opts['ssl'] = $ssl; } + + return new Zend_Mail_Transport_Smtp($host, $opts); + } + + /** + * Instantiate a provider's API driver. + * + * @param string $provider the provider slug (must be an API-kind entry in Tiger_Mail_Provider) + * @param array $fields its credential values + * @return Zend_Mail_Transport_Abstract|null the driver, or null when the provider is unknown, + * isn't an API provider, or its driver isn't available + */ + public static function apiTransport($provider, array $fields) + { + $def = Tiger_Mail_Provider::get($provider); + if (!$def || ($def['kind'] ?? '') !== Tiger_Mail_Provider::KIND_API) { return null; } + if (!Tiger_Mail_Provider::isAvailable($provider)) { return null; } + + $class = (string) ($def['transport'] ?? ''); + if ($class === '' || !class_exists($class)) { return null; } + + return new $class($fields); + } + + /** + * A provider's stored credentials, decrypted. Read from `mail.api..`, with a + * secret living at `_enc`. + * + * @param string $provider the provider slug + * @return array field name => plaintext value + */ + public static function apiCredentials($provider) + { + $out = []; + if ((string) $provider === '') { return $out; } + + $cfg = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + $node = ($cfg && $cfg->get('mail') && $cfg->mail->get('api')) ? $cfg->mail->api->get($provider) : null; + if (!$node) { return $out; } + + foreach (array_keys(Tiger_Mail_Provider::fields($provider)) as $field) { + if (Tiger_Mail_Provider::isSecret($provider, $field)) { + $enc = (string) $node->get($field . '_enc'); + if ($enc !== '') { + try { + if (class_exists('Tiger_Crypto') && Tiger_Crypto::isConfigured()) { + $out[$field] = (string) Tiger_Crypto::decrypt($enc); + continue; + } + } catch (Throwable $e) { + // fall through to the plaintext key — a send must never fatal on a bad secret + } + } + } + $out[$field] = (string) $node->get($field); + } + return $out; + } + + /** + * Persist a provider's credentials to the `config` tier. Secrets are encrypted at rest and a + * BLANK secret keeps the stored one (same rule as the SMTP password). + * + * @param string $provider the provider slug + * @param array $fields submitted field values + * @return void + */ + public static function saveApiCredentials($provider, array $fields) + { + $defs = Tiger_Mail_Provider::fields($provider); + if (!$defs) { return; } + + $cfg = new Tiger_Model_Config(); + $g = Tiger_Model_Config::SCOPE_GLOBAL; + $base = 'mail.api.' . $provider . '.'; + + foreach ($defs as $field => $def) { + $value = trim((string) ($fields[$field] ?? '')); + + if (!empty($def['secret'])) { + if ($value === '') { continue; } // blank = keep the stored secret + if (class_exists('Tiger_Crypto') && Tiger_Crypto::isConfigured()) { + $cfg->set($g, '', $base . $field . '_enc', Tiger_Crypto::encrypt($value)); + $cfg->set($g, '', $base . $field, ''); + } else { + $cfg->set($g, '', $base . $field, $value); + } + continue; + } + $cfg->set($g, '', $base . $field, $value); + } + } + + /** + * The stored SMTP password in plaintext — for the admin "Send test" path ONLY, so a test can + * run against the saved secret when the admin left the password field blank (blank = keep). + * Never surfaced through `settings()`. + * + * @return string the stored password, or '' when none is set + */ + public static function storedSmtpPassword() + { + $cfg = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + $mail = ($cfg && $cfg->get('mail')) ? $cfg->mail : null; + $smtp = ($mail && $mail->get('smtp')) ? $mail->smtp : null; + return $smtp ? self::_smtpPassword($smtp) : ''; + } + + /** + * The current mail settings, for an admin screen. The password is NEVER returned — only + * `has_password`, so the secret can't be read back out through the UI. + * + * @return array{transport:string,host:string,port:string,ssl:string,auth:string,username:string,has_password:bool,from_email:string,from_name:string} + */ + public static function settings() + { + $cfg = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + $mail = ($cfg && $cfg->get('mail')) ? $cfg->mail : null; + $smtp = ($mail && $mail->get('smtp')) ? $mail->smtp : null; + $from = ($mail && $mail->get('from')) ? $mail->from : null; + + $has = $smtp && ((string) $smtp->get('password_enc') !== '' || (string) $smtp->get('password') !== ''); + + // Provider credentials, with every secret reduced to a has_* flag — the screen learns that + // a key EXISTS without the value ever crossing back out of the server. + $provider = $mail ? (string) $mail->get('provider') : ''; + $creds = []; + $hasCred = []; + foreach (Tiger_Mail_Provider::fields($provider) as $field => $def) { + $stored = self::apiCredentials($provider); + if (!empty($def['secret'])) { + $hasCred[$field] = isset($stored[$field]) && $stored[$field] !== ''; + continue; + } + $creds[$field] = (string) ($stored[$field] ?? ''); + } + + return [ + 'provider' => $provider, + 'fields' => $creds, + 'has_field' => $hasCred, + 'transport' => $mail ? strtolower((string) $mail->get('transport')) : 'mail', + 'host' => $smtp ? (string) $smtp->get('host') : '', + 'port' => $smtp ? (string) $smtp->get('port') : '', + 'ssl' => $smtp ? (string) $smtp->get('ssl') : '', + 'auth' => $smtp ? (string) $smtp->get('auth') : '', + 'username' => $smtp ? (string) $smtp->get('username') : '', + 'has_password' => (bool) $has, + 'from_email' => $from ? (string) $from->get('email') : '', + 'from_name' => $from ? (string) $from->get('name') : '', + ]; + } + + /** + * Persist mail settings to the `config` DB tier (the live-override pattern — effective next + * request, no deploy). Mirrors `Tiger_Recaptcha::saveSettings`. + * + * A BLANK password keeps the stored one, so an admin can edit the host or the From identity + * without re-typing the secret (and without the UI ever having to hold it). + * + * @param array $values transport, host, port, ssl, auth, username, password, from_email, from_name + * @return void + */ + public static function saveSettings(array $values) + { + $cfg = new Tiger_Model_Config(); + $g = Tiger_Model_Config::SCOPE_GLOBAL; + + // The provider drives the transport KIND: an API provider switches mail.transport to 'api' + // and stores its credentials; every other provider keeps the smtp/mail path below. + if (array_key_exists('provider', $values)) { + $provider = (string) $values['provider']; + if (Tiger_Mail_Provider::get($provider)) { + $cfg->set($g, '', 'mail.provider', $provider); + self::saveApiCredentials($provider, (array) ($values['fields'] ?? [])); + + if (Tiger_Mail_Provider::get($provider)['kind'] === Tiger_Mail_Provider::KIND_API) { + $cfg->set($g, '', 'mail.transport', 'api'); + // The From identity still applies to an API send. + if (array_key_exists('from_email', $values)) { $cfg->set($g, '', 'mail.from.email', trim((string) $values['from_email'])); } + if (array_key_exists('from_name', $values)) { $cfg->set($g, '', 'mail.from.name', trim((string) $values['from_name'])); } + return; + } + } + } + + if (array_key_exists('transport', $values)) { + $cfg->set($g, '', 'mail.transport', ((string) $values['transport'] === 'smtp') ? 'smtp' : 'mail'); + } + foreach (['host' => 'mail.smtp.host', 'username' => 'mail.smtp.username'] as $k => $key) { + if (array_key_exists($k, $values)) { $cfg->set($g, '', $key, trim((string) $values[$k])); } + } + if (array_key_exists('port', $values)) { + $port = (int) $values['port']; + $cfg->set($g, '', 'mail.smtp.port', (string) (($port > 0 && $port <= 65535) ? $port : 587)); + } + if (array_key_exists('ssl', $values)) { + $ssl = strtolower(trim((string) $values['ssl'])); + $cfg->set($g, '', 'mail.smtp.ssl', in_array($ssl, ['tls', 'ssl'], true) ? $ssl : ''); + } + if (array_key_exists('auth', $values)) { + $auth = strtolower(trim((string) $values['auth'])); + $cfg->set($g, '', 'mail.smtp.auth', in_array($auth, ['login', 'plain', 'crammd5'], true) ? $auth : ''); + } + if (array_key_exists('from_email', $values)) { $cfg->set($g, '', 'mail.from.email', trim((string) $values['from_email'])); } + if (array_key_exists('from_name', $values)) { $cfg->set($g, '', 'mail.from.name', trim((string) $values['from_name'])); } + + // Secret — only when a new value is given (blank keeps the current). Encrypt at rest, and + // clear any legacy plaintext row so the secret doesn't linger in two places. + if (!empty($values['password'])) { + $secret = (string) $values['password']; + if (class_exists('Tiger_Crypto') && Tiger_Crypto::isConfigured()) { + $cfg->set($g, '', 'mail.smtp.password_enc', Tiger_Crypto::encrypt($secret)); + $cfg->set($g, '', 'mail.smtp.password', ''); + } else { + $cfg->set($g, '', 'mail.smtp.password', $secret); // no crypto available → plaintext fallback + } + } + } + /** The configured default sender ([email, name]). */ protected function _configFrom() { diff --git a/library/Tiger/Mail/Provider.php b/library/Tiger/Mail/Provider.php new file mode 100644 index 00000000..b0db42c8 --- /dev/null +++ b/library/Tiger/Mail/Provider.php @@ -0,0 +1,321 @@ + [label, placeholder, secret, required, help] + * + * @var array + */ + protected static $_providers = [ + + 'custom' => [ + 'label' => 'Custom SMTP server', + 'kind' => self::KIND_SMTP, + 'smtp' => ['host' => '', 'port' => 587, 'ssl' => 'tls', 'auth' => 'login'], + 'fields' => [], + ], + + 'sendmail' => [ + 'label' => 'PHP mail() / sendmail', + 'kind' => self::KIND_SMTP, + 'smtp' => ['host' => '', 'port' => '', 'ssl' => '', 'auth' => ''], + 'fields' => [], + ], + + // --- Amazon SES ------------------------------------------------------------------- + 'ses-smtp' => [ + 'label' => 'Amazon SES (SMTP)', + 'kind' => self::KIND_SMTP, + 'smtp' => ['host' => 'email-smtp.{region}.amazonaws.com', 'port' => 587, 'ssl' => 'tls', 'auth' => 'login'], + 'fields' => [ + 'region' => ['label' => 'Region', 'placeholder' => 'us-east-1', 'required' => true], + ], + // NOTE: SES SMTP credentials are NOT your AWS access keys — they're generated in the + // SES console. Username/password go in the standard SMTP fields. + 'help' => 'core.mail.provider.help.ses_smtp', + ], + 'ses-api' => [ + 'label' => 'Amazon SES (API)', + 'kind' => self::KIND_API, + 'transport' => 'Tiger_Mail_Transport_Ses', + 'requires_class' => 'Aws\\SesV2\\SesV2Client', + 'requires_hint' => 'core.mail.provider.requires.aws_sdk', + 'fields' => [ + 'region' => ['label' => 'Region', 'placeholder' => 'us-east-1', 'required' => true], + 'key' => ['label' => 'Access key ID', 'placeholder' => 'AKIA…', 'required' => false, + 'help' => 'core.mail.provider.help.ses_api_iam'], + 'secret' => ['label' => 'Secret access key', 'secret' => true, 'required' => false], + ], + 'help' => 'core.mail.provider.help.ses_api', + ], + + // --- SendGrid --------------------------------------------------------------------- + 'sendgrid-smtp' => [ + 'label' => 'SendGrid (SMTP)', + 'kind' => self::KIND_SMTP, + 'smtp' => ['host' => 'smtp.sendgrid.net', 'port' => 587, 'ssl' => 'tls', 'auth' => 'login'], + 'fields' => [], + 'help' => 'core.mail.provider.help.sendgrid_smtp', + ], + 'sendgrid-api' => [ + 'label' => 'SendGrid (API)', + 'kind' => self::KIND_API, + 'transport' => 'Tiger_Mail_Transport_SendGrid', + 'fields' => [ + 'key' => ['label' => 'API key', 'placeholder' => 'SG.…', 'secret' => true, 'required' => true], + ], + ], + + // --- Mailgun ---------------------------------------------------------------------- + 'mailgun-smtp' => [ + 'label' => 'Mailgun (SMTP)', + 'kind' => self::KIND_SMTP, + 'smtp' => ['host' => '{host}', 'port' => 587, 'ssl' => 'tls', 'auth' => 'login'], + 'fields' => [ + 'host' => ['label' => 'SMTP host', 'placeholder' => 'smtp.mailgun.org', 'required' => true, + 'help' => 'core.mail.provider.help.mailgun_region'], + ], + ], + 'mailgun-api' => [ + 'label' => 'Mailgun (API)', + 'kind' => self::KIND_API, + 'transport' => 'Tiger_Mail_Transport_Mailgun', + 'fields' => [ + 'domain' => ['label' => 'Sending domain', 'placeholder' => 'mg.example.com', 'required' => true], + 'key' => ['label' => 'API key', 'placeholder' => 'key-…', 'secret' => true, 'required' => true], + 'endpoint' => ['label' => 'API base', 'placeholder' => 'https://api.mailgun.net', 'required' => false, + 'help' => 'core.mail.provider.help.mailgun_region'], + ], + ], + + // --- Postmark --------------------------------------------------------------------- + 'postmark-smtp' => [ + 'label' => 'Postmark (SMTP)', + 'kind' => self::KIND_SMTP, + 'smtp' => ['host' => 'smtp.postmarkapp.com', 'port' => 587, 'ssl' => 'tls', 'auth' => 'login'], + 'fields' => [], + 'help' => 'core.mail.provider.help.postmark_smtp', + ], + 'postmark-api' => [ + 'label' => 'Postmark (API)', + 'kind' => self::KIND_API, + 'transport' => 'Tiger_Mail_Transport_Postmark', + 'fields' => [ + 'key' => ['label' => 'Server token', 'secret' => true, 'required' => true], + 'stream' => ['label' => 'Message stream', 'placeholder' => 'outbound', 'required' => false], + ], + ], + + // --- Resend ----------------------------------------------------------------------- + 'resend-smtp' => [ + 'label' => 'Resend (SMTP)', + 'kind' => self::KIND_SMTP, + 'smtp' => ['host' => 'smtp.resend.com', 'port' => 587, 'ssl' => 'tls', 'auth' => 'login'], + 'fields' => [], + 'help' => 'core.mail.provider.help.resend_smtp', + ], + 'resend-api' => [ + 'label' => 'Resend (API)', + 'kind' => self::KIND_API, + 'transport' => 'Tiger_Mail_Transport_Resend', + 'fields' => [ + 'key' => ['label' => 'API key', 'placeholder' => 're_…', 'secret' => true, 'required' => true], + ], + ], + + // --- Brevo (formerly Sendinblue) -------------------------------------------------- + 'brevo-smtp' => [ + 'label' => 'Brevo (SMTP)', + 'kind' => self::KIND_SMTP, + 'smtp' => ['host' => 'smtp-relay.brevo.com', 'port' => 587, 'ssl' => 'tls', 'auth' => 'login'], + 'fields' => [], + ], + 'brevo-api' => [ + 'label' => 'Brevo (API)', + 'kind' => self::KIND_API, + 'transport' => 'Tiger_Mail_Transport_Brevo', + 'fields' => [ + 'key' => ['label' => 'API key', 'placeholder' => 'xkeysib-…', 'secret' => true, 'required' => true], + ], + ], + + // --- Mailjet ---------------------------------------------------------------------- + 'mailjet-smtp' => [ + 'label' => 'Mailjet (SMTP)', + 'kind' => self::KIND_SMTP, + 'smtp' => ['host' => 'in-v3.mailjet.com', 'port' => 587, 'ssl' => 'tls', 'auth' => 'login'], + 'fields' => [], + ], + 'mailjet-api' => [ + 'label' => 'Mailjet (API)', + 'kind' => self::KIND_API, + 'transport' => 'Tiger_Mail_Transport_Mailjet', + 'fields' => [ + 'key' => ['label' => 'API key', 'required' => true], + 'secret' => ['label' => 'Secret key', 'secret' => true, 'required' => true], + ], + ], + + // --- Google Workspace / Microsoft 365 --------------------------------------------- + // SMTP only, deliberately. Both providers' send APIs (Gmail API, Microsoft Graph) require + // an OAuth2 flow — a service account with domain-wide delegation, or app credentials with + // Mail.Send — NOT a pasteable API key, so they can't be configured on a settings screen the + // way the others can. That belongs behind the TigerConnect OAuth broker; until then SMTP is + // the honest, working option rather than a field that can't be filled in. + 'google-smtp' => [ + 'label' => 'Google Workspace / Gmail (SMTP)', + 'kind' => self::KIND_SMTP, + 'smtp' => ['host' => 'smtp.gmail.com', 'port' => 587, 'ssl' => 'tls', 'auth' => 'login'], + 'fields' => [], + 'help' => 'core.mail.provider.help.google_smtp', + ], + 'microsoft-smtp' => [ + 'label' => 'Microsoft 365 / Outlook (SMTP)', + 'kind' => self::KIND_SMTP, + 'smtp' => ['host' => 'smtp.office365.com', 'port' => 587, 'ssl' => 'tls', 'auth' => 'login'], + 'fields' => [], + 'help' => 'core.mail.provider.help.microsoft_smtp', + ], + ]; + + /** + * Every provider, keyed by slug. + * + * @return array the provider table + */ + public static function all() + { + return self::$_providers; + } + + /** + * One provider definition. + * + * @param string $key the provider slug + * @return array|null the definition, or null when unknown + */ + public static function get($key) + { + $key = (string) $key; + return isset(self::$_providers[$key]) ? self::$_providers[$key] : null; + } + + /** + * Dropdown options (slug => label), in table order. + * + * @return array + */ + public static function options() + { + $out = []; + foreach (self::$_providers as $key => $def) { $out[$key] = $def['label']; } + return $out; + } + + /** + * Whether a provider's driver can actually run here — an API provider may need an optional + * module (SES needs the AWS SDK). Capability-detected, never assumed. + * + * @param string $key the provider slug + * @return bool true when the provider is usable on this install + */ + public static function isAvailable($key) + { + $def = self::get($key); + if (!$def) { return false; } + if (($def['kind'] ?? '') !== self::KIND_API) { return true; } + $needs = $def['requires_class'] ?? ''; + return $needs === '' || class_exists($needs); + } + + /** + * Resolve a provider's SMTP settings, interpolating `{field}` placeholders from its own + * credential values (SES's region, Mailgun's host). + * + * @param string $key the provider slug + * @param array $fields that provider's stored credential values + * @return array{host:string,port:string,ssl:string,auth:string} + */ + public static function smtpFor($key, array $fields = []) + { + $def = self::get($key); + $smtp = ($def && isset($def['smtp'])) ? $def['smtp'] : ['host' => '', 'port' => 587, 'ssl' => 'tls', 'auth' => 'login']; + + $host = (string) $smtp['host']; + if (strpos($host, '{') !== false) { + foreach ($fields as $name => $value) { + $host = str_replace('{' . $name . '}', (string) $value, $host); + } + // An un-substituted placeholder means the operator hasn't filled that field in yet. + if (strpos($host, '{') !== false) { $host = ''; } + } + + return [ + 'host' => $host, + 'port' => (string) $smtp['port'], + 'ssl' => (string) $smtp['ssl'], + 'auth' => (string) $smtp['auth'], + ]; + } + + /** + * The credential field names a provider declares (for the form + the config writer). + * + * @param string $key the provider slug + * @return array field name => definition + */ + public static function fields($key) + { + $def = self::get($key); + return ($def && isset($def['fields'])) ? $def['fields'] : []; + } + + /** + * Whether a provider's field holds a secret (encrypted at rest, never echoed back). + * + * @param string $provider the provider slug + * @param string $field the field name + * @return bool + */ + public static function isSecret($provider, $field) + { + $fields = self::fields($provider); + return !empty($fields[$field]['secret']); + } +} diff --git a/library/Tiger/Mail/Transport/Api.php b/library/Tiger/Mail/Transport/Api.php new file mode 100644 index 00000000..77015460 --- /dev/null +++ b/library/Tiger/Mail/Transport/Api.php @@ -0,0 +1,184 @@ +_mail` before calling `_sendMail()`, so a subclass reads the + * STRUCTURED message (to / subject / html / text) rather than trying to parse rendered MIME — + * which matters because most of these APIs take JSON fields, not a raw message. + * + * A subclass implements `_endpoint()`, `_headers()` and `_payload()`. This base owns the HTTP call, + * the User-Agent (an outbound request with no UA is refused by some WAFs), timeouts, and turning a + * non-2xx response into a `Zend_Mail_Transport_Exception` carrying the provider's own error text — + * because on a failed send that text is the entire diagnostic. + * + * @api + * @see Tiger_Mail_Provider + */ +abstract class Tiger_Mail_Transport_Api extends Zend_Mail_Transport_Abstract +{ + /** Connect + total timeouts (seconds). A hung provider must never hang a request. */ + const TIMEOUT_CONNECT = 10; + const TIMEOUT_TOTAL = 30; + + /** @var array the provider's stored credential values */ + protected $_config = []; + + /** + * @param array $config the provider's credential values (api key, region, domain, …) + */ + public function __construct(array $config = []) + { + $this->_config = $config; + } + + /** @return string the absolute HTTPS endpoint to POST to */ + abstract protected function _endpoint(); + + /** @return array request headers, as "Name: value" strings */ + abstract protected function _headers(); + + /** + * Build the provider's request body from the normalized message. + * + * @param array $msg from{email,name}, to[{email,name}], reply_to, subject, html, text + * @return array|string an array (JSON-encoded by default) or a pre-encoded body string + */ + abstract protected function _payload(array $msg); + + /** @return string a config value, or '' */ + protected function _cfg($key) + { + return isset($this->_config[$key]) ? (string) $this->_config[$key] : ''; + } + + /** + * Normalize the Zend_Mail into the flat shape every driver builds its payload from. + * + * @return array{from:array,to:array,reply_to:array|null,subject:string,html:string,text:string} + */ + protected function _message() + { + $mail = $this->_mail; + + $to = []; + foreach ((array) $mail->getRecipients() as $address) { + $to[] = ['email' => (string) $address, 'name' => '']; + } + + $replyTo = null; + $rt = method_exists($mail, 'getReplyTo') ? $mail->getReplyTo() : null; + if ($rt) { $replyTo = ['email' => (string) $rt, 'name' => '']; } + + return [ + 'from' => ['email' => (string) $mail->getFrom(), 'name' => $this->_fromName()], + 'to' => $to, + 'reply_to' => $replyTo, + 'subject' => (string) $mail->getSubject(), + 'html' => $this->_part($mail->getBodyHtml()), + 'text' => $this->_part($mail->getBodyText()), + ]; + } + + /** + * A MIME part's ORIGINAL content. + * + * Deliberately `getRawContent()`, not `getContent()`: the latter runs `Zend_Mime::encode()` and + * hands back quoted-printable/base64 for wire transmission, which is exactly wrong for a JSON + * API field — the recipient would see the encoded source. `getBodyHtml(true)` has the same + * problem, since it returns `getContent()` internally. + * + * @param Zend_Mime_Part|false|null $part the value from getBodyHtml()/getBodyText() + * @return string the unencoded body, or '' + */ + protected function _part($part) + { + if (!$part instanceof Zend_Mime_Part) { return ''; } + return (string) $part->getRawContent(); + } + + /** + * The sender's display name. + * + * Zend_Mail has no `getFromName()` — `setFrom($email, $name)` folds the name into the `From` + * header — so it's read back out of the formatted header (`"Name" `). + * + * @return string the display name, or '' when the sender is a bare address + */ + protected function _fromName() + { + $headers = (array) $this->_mail->getHeaders(); + $from = isset($headers['From'][0]) ? (string) $headers['From'][0] : ''; + if ($from === '' || strpos($from, '<') === false) { return ''; } + + $name = trim(substr($from, 0, strpos($from, '<'))); + return trim($name, '" '); + } + + /** + * Send the message over the provider's API. Called by Zend_Mail_Transport_Abstract::send(). + * + * @return void + * @throws Zend_Mail_Transport_Exception on a transport or provider error + */ + protected function _sendMail() + { + $payload = $this->_payload($this->_message()); + $body = is_string($payload) ? $payload : json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + $this->_post($this->_endpoint(), $body, $this->_headers()); + } + + /** + * POST to the provider and assert a 2xx. + * + * @param string $url the endpoint + * @param string $body the request body + * @param array $headers request headers + * @return string the response body (for drivers that want the provider's message id) + * @throws Zend_Mail_Transport_Exception when the request fails or the provider refuses it + */ + protected function _post($url, $body, array $headers) + { + if (!function_exists('curl_init')) { + throw new Zend_Mail_Transport_Exception('The PHP cURL extension is required to send mail over a provider API.'); + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => self::TIMEOUT_CONNECT, + CURLOPT_TIMEOUT => self::TIMEOUT_TOTAL, + // Some WAFs (including ours) refuse a request with no User-Agent. + CURLOPT_USERAGENT => 'Tiger/' . (class_exists('Tiger_Version') ? Tiger_Version::VERSION : '1.0') . ' (+https://webtigers.com)', + ]); + + $response = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + // No curl_close(): it has been a no-op since PHP 8.0 (the handle is freed when it goes out + // of scope) and is deprecated as of 8.5 — calling it would emit a deprecation on every send. + + if ($response === false || $error !== '') { + throw new Zend_Mail_Transport_Exception('Mail API request failed: ' . $error); + } + if ($status < 200 || $status >= 300) { + // The provider's own message is the diagnostic — surface it, trimmed. + $detail = trim((string) $response); + if (strlen($detail) > 500) { $detail = substr($detail, 0, 500) . '…'; } + throw new Zend_Mail_Transport_Exception( + 'Mail API rejected the message (HTTP ' . $status . ')' . ($detail !== '' ? ': ' . $detail : '') + ); + } + + return (string) $response; + } +} diff --git a/library/Tiger/Mail/Transport/Brevo.php b/library/Tiger/Mail/Transport/Brevo.php new file mode 100644 index 00000000..34588704 --- /dev/null +++ b/library/Tiger/Mail/Transport/Brevo.php @@ -0,0 +1,48 @@ + the api-key header + JSON */ + protected function _headers() + { + return ['api-key: ' . $this->_cfg('key'), 'Content-Type: application/json', 'Accept: application/json']; + } + + /** + * @param array $msg the normalized message + * @return array Brevo's sender/to shape + */ + protected function _payload(array $msg) + { + $payload = [ + 'sender' => array_filter(['email' => $msg['from']['email'], 'name' => $msg['from']['name']], 'strlen'), + 'to' => array_map(static function ($t) { + return array_filter(['email' => $t['email'], 'name' => $t['name']], 'strlen'); + }, $msg['to']), + 'subject' => $msg['subject'], + ]; + if ($msg['html'] !== '') { $payload['htmlContent'] = $msg['html']; } + if ($msg['text'] !== '') { $payload['textContent'] = $msg['text']; } + if ($msg['reply_to']) { $payload['replyTo'] = ['email' => $msg['reply_to']['email']]; } + + return $payload; + } +} diff --git a/library/Tiger/Mail/Transport/Mailgun.php b/library/Tiger/Mail/Transport/Mailgun.php new file mode 100644 index 00000000..be3980de --- /dev/null +++ b/library/Tiger/Mail/Transport/Mailgun.php @@ -0,0 +1,57 @@ +_cfg('endpoint') ?: 'https://api.mailgun.net', '/'); + return $base . '/v3/' . rawurlencode($this->_cfg('domain')) . '/messages'; + } + + /** @return array basic auth (api:KEY) + form encoding */ + protected function _headers() + { + return [ + 'Authorization: Basic ' . base64_encode('api:' . $this->_cfg('key')), + 'Content-Type: application/x-www-form-urlencoded', + ]; + } + + /** + * @param array $msg the normalized message + * @return string form-encoded body (Mailgun does not take JSON here) + */ + protected function _payload(array $msg) + { + $from = $msg['from']['name'] !== '' + ? sprintf('%s <%s>', $msg['from']['name'], $msg['from']['email']) + : $msg['from']['email']; + + $fields = [ + 'from' => $from, + 'to' => implode(',', array_column($msg['to'], 'email')), + 'subject' => $msg['subject'], + ]; + if ($msg['text'] !== '') { $fields['text'] = $msg['text']; } + if ($msg['html'] !== '') { $fields['html'] = $msg['html']; } + if ($msg['reply_to']) { $fields['h:Reply-To'] = $msg['reply_to']['email']; } + + return http_build_query($fields); + } +} diff --git a/library/Tiger/Mail/Transport/Mailjet.php b/library/Tiger/Mail/Transport/Mailjet.php new file mode 100644 index 00000000..3a10ed1e --- /dev/null +++ b/library/Tiger/Mail/Transport/Mailjet.php @@ -0,0 +1,50 @@ + basic auth (key:secret) + JSON */ + protected function _headers() + { + return [ + 'Authorization: Basic ' . base64_encode($this->_cfg('key') . ':' . $this->_cfg('secret')), + 'Content-Type: application/json', + ]; + } + + /** + * @param array $msg the normalized message + * @return array Mailjet's Messages[] envelope + */ + protected function _payload(array $msg) + { + $message = [ + 'From' => array_filter(['Email' => $msg['from']['email'], 'Name' => $msg['from']['name']], 'strlen'), + 'To' => array_map(static function ($t) { + return array_filter(['Email' => $t['email'], 'Name' => $t['name']], 'strlen'); + }, $msg['to']), + 'Subject' => $msg['subject'], + ]; + if ($msg['html'] !== '') { $message['HTMLPart'] = $msg['html']; } + if ($msg['text'] !== '') { $message['TextPart'] = $msg['text']; } + if ($msg['reply_to']) { $message['ReplyTo'] = ['Email' => $msg['reply_to']['email']]; } + + return ['Messages' => [$message]]; + } +} diff --git a/library/Tiger/Mail/Transport/Postmark.php b/library/Tiger/Mail/Transport/Postmark.php new file mode 100644 index 00000000..9f268def --- /dev/null +++ b/library/Tiger/Mail/Transport/Postmark.php @@ -0,0 +1,54 @@ + the server-token header + JSON */ + protected function _headers() + { + return [ + 'X-Postmark-Server-Token: ' . $this->_cfg('key'), + 'Content-Type: application/json', + 'Accept: application/json', + ]; + } + + /** + * @param array $msg the normalized message + * @return array Postmark's PascalCase field shape + */ + protected function _payload(array $msg) + { + $from = $msg['from']['name'] !== '' + ? sprintf('"%s" <%s>', $msg['from']['name'], $msg['from']['email']) + : $msg['from']['email']; + + $payload = [ + 'From' => $from, + 'To' => implode(',', array_column($msg['to'], 'email')), + 'Subject' => $msg['subject'], + 'MessageStream' => $this->_cfg('stream') ?: 'outbound', + ]; + if ($msg['html'] !== '') { $payload['HtmlBody'] = $msg['html']; } + if ($msg['text'] !== '') { $payload['TextBody'] = $msg['text']; } + if ($msg['reply_to']) { $payload['ReplyTo'] = $msg['reply_to']['email']; } + + return $payload; + } +} diff --git a/library/Tiger/Mail/Transport/Resend.php b/library/Tiger/Mail/Transport/Resend.php new file mode 100644 index 00000000..393a1ee8 --- /dev/null +++ b/library/Tiger/Mail/Transport/Resend.php @@ -0,0 +1,48 @@ + bearer auth + JSON */ + protected function _headers() + { + return ['Authorization: Bearer ' . $this->_cfg('key'), 'Content-Type: application/json']; + } + + /** + * @param array $msg the normalized message + * @return array Resend's flat shape (to is an array of addresses) + */ + protected function _payload(array $msg) + { + $from = $msg['from']['name'] !== '' + ? sprintf('%s <%s>', $msg['from']['name'], $msg['from']['email']) + : $msg['from']['email']; + + $payload = [ + 'from' => $from, + 'to' => array_column($msg['to'], 'email'), + 'subject' => $msg['subject'], + ]; + if ($msg['html'] !== '') { $payload['html'] = $msg['html']; } + if ($msg['text'] !== '') { $payload['text'] = $msg['text']; } + if ($msg['reply_to']) { $payload['reply_to'] = $msg['reply_to']['email']; } + + return $payload; + } +} diff --git a/library/Tiger/Mail/Transport/SendGrid.php b/library/Tiger/Mail/Transport/SendGrid.php new file mode 100644 index 00000000..30bbe56d --- /dev/null +++ b/library/Tiger/Mail/Transport/SendGrid.php @@ -0,0 +1,50 @@ + bearer auth + JSON */ + protected function _headers() + { + return ['Authorization: Bearer ' . $this->_cfg('key'), 'Content-Type: application/json']; + } + + /** + * @param array $msg the normalized message + * @return array SendGrid's personalizations/content shape + */ + protected function _payload(array $msg) + { + $content = []; + if ($msg['text'] !== '') { $content[] = ['type' => 'text/plain', 'value' => $msg['text']]; } + if ($msg['html'] !== '') { $content[] = ['type' => 'text/html', 'value' => $msg['html']]; } + + $payload = [ + 'personalizations' => [['to' => array_map(static function ($t) { + return array_filter(['email' => $t['email'], 'name' => $t['name']], 'strlen'); + }, $msg['to'])]], + 'from' => array_filter(['email' => $msg['from']['email'], 'name' => $msg['from']['name']], 'strlen'), + 'subject' => $msg['subject'], + 'content' => $content, + ]; + if ($msg['reply_to']) { $payload['reply_to'] = ['email' => $msg['reply_to']['email']]; } + + return $payload; + } +} diff --git a/library/Tiger/Mail/Transport/Ses.php b/library/Tiger/Mail/Transport/Ses.php new file mode 100644 index 00000000..ff7a43b9 --- /dev/null +++ b/library/Tiger/Mail/Transport/Ses.php @@ -0,0 +1,95 @@ + '2019-09-27', 'region' => $this->_cfg('region') ?: 'us-east-1']; + + // Explicit credentials only when BOTH are given; otherwise fall through to the SDK's + // default provider chain (instance role, env, shared config) — the no-stored-secret path. + $key = $this->_cfg('key'); + $secret = $this->_cfg('secret'); + if ($key !== '' && $secret !== '') { + $args['credentials'] = ['key' => $key, 'secret' => $secret]; + } + + try { + $client = new Aws\SesV2\SesV2Client($args); + $client->sendEmail([ + 'Content' => ['Raw' => ['Data' => $this->_rawMessage()]], + 'Destination' => ['ToAddresses' => (array) $this->_mail->getRecipients()], + 'FromEmailAddress' => (string) $this->_mail->getFrom(), + ]); + } catch (Throwable $e) { + // Surface SES's own message — on a send failure it's the entire diagnostic (an + // unverified identity, a sandbox restriction, a bad region). + throw new Zend_Mail_Transport_Exception('Amazon SES rejected the message: ' . $e->getMessage()); + } + } + + /** + * The complete RFC 5322 message — the headers Zend rendered for this transport plus the MIME + * body — which is exactly what SES `Content.Raw` expects. + * + * @return string the raw message + */ + protected function _rawMessage() + { + return $this->header . Zend_Mime::LINEEND . $this->body; + } +} diff --git a/modules/system/controllers/SettingsController.php b/modules/system/controllers/SettingsController.php index 4807325d..58c4f0a7 100644 --- a/modules/system/controllers/SettingsController.php +++ b/modules/system/controllers/SettingsController.php @@ -47,10 +47,20 @@ public function indexAction() $al = (new Tiger_Service_Authentication())->autologoutConfig(); - $rc = Tiger_Recaptcha::settings(); + $rc = Tiger_Recaptcha::settings(); + $mail = Tiger_Mail::settings(); $form = new System_Form_Settings(); $form->populate([ + 'mail_provider' => $mail['provider'] !== '' ? $mail['provider'] : ($mail['transport'] === 'smtp' ? 'custom' : 'sendmail'), + 'mail_smtp_host' => $mail['host'], + 'mail_smtp_port' => $mail['port'] !== '' ? $mail['port'] : '587', + 'mail_smtp_ssl' => $mail['ssl'], + 'mail_smtp_auth' => $mail['auth'], + 'mail_smtp_username' => $mail['username'], + 'mail_from_email' => $mail['from_email'], + 'mail_from_name' => $mail['from_name'], + // mail password is never prefilled — blank on save keeps the stored value 'session_ttl_privileged' => $ttlPriv, 'session_ttl' => $ttlAuthed, 'session_ttl_guest' => $ttlGuest, @@ -69,6 +79,14 @@ public function indexAction() $this->view->title = 'System Settings — Tiger Admin'; $this->view->form = $form; $this->view->hasSecret = $rc['has_secret']; + // Email SMTP: drives the initial fieldset visibility + the "password already set" hint. + $this->view->mailTransport = $mail['transport'] === 'smtp' ? 'smtp' : 'mail'; + $this->view->mailHasPassword = $mail['has_password']; + // Provider catalog + the selected provider's stored credentials (secrets as has_* flags only). + $this->view->mailProviders = Tiger_Mail_Provider::all(); + $this->view->mailProvider = $mail['provider'] !== '' ? $mail['provider'] : ($mail['transport'] === 'smtp' ? 'custom' : 'sendmail'); + $this->view->mailFields = $mail['fields']; + $this->view->mailHasField = $mail['has_field']; $this->view->location = (class_exists('Tiger_Location') && method_exists('Tiger_Location', 'settings')) ? Tiger_Location::settings() : null; $this->view->consent = class_exists('Tiger_Consent') ? Tiger_Consent::settings() : null; diff --git a/modules/system/forms/Settings.php b/modules/system/forms/Settings.php index 454480d6..9f29fcad 100644 --- a/modules/system/forms/Settings.php +++ b/modules/system/forms/Settings.php @@ -53,6 +53,81 @@ protected function elements(): array 'attribs' => ['class' => 'form-check-input'], ]], + // Email SMTP tab — transport + the SMTP connection + the From identity. Everything is + // optional: `mail` (PHP sendmail) needs none of it. The password is a password field — + // blank = keep the stored one (Tiger_Mail::saveSettings), which is also why the current + // secret is never rendered back into the form. + // The provider drives everything: it picks the transport kind, supplies the SMTP + // defaults, and declares which credential fields the screen renders. + ['select', 'mail_provider', [ + 'multiOptions' => Tiger_Mail_Provider::options(), + 'value' => 'sendmail', + 'attribs' => ['id' => 'set-mail-provider', 'class' => 'form-select'], + ]], + ['text', 'mail_smtp_host', [ + 'required' => false, + 'filters' => ['StringTrim'], + 'attribs' => array_merge($control, ['id' => 'set-mail-host', 'autocomplete' => 'off', + 'placeholder' => 'email-smtp.us-east-1.amazonaws.com']), + ]], + ['text', 'mail_smtp_port', [ + 'required' => false, + 'filters' => ['StringTrim'], + 'validators' => [['Digits'], ['Between', false, ['min' => 1, 'max' => 65535, 'inclusive' => true]]], + 'attribs' => array_merge($control, ['id' => 'set-mail-port', 'inputmode' => 'numeric', 'placeholder' => '587']), + ]], + ['select', 'mail_smtp_ssl', [ + 'multiOptions' => [ + 'tls' => $this->_t('system.settings.smtp.ssl_tls'), + 'ssl' => $this->_t('system.settings.smtp.ssl_ssl'), + '' => $this->_t('system.settings.smtp.ssl_none'), + ], + 'value' => 'tls', + 'attribs' => ['id' => 'set-mail-ssl', 'class' => 'form-select'], + ]], + ['select', 'mail_smtp_auth', [ + 'multiOptions' => [ + 'login' => $this->_t('system.settings.smtp.auth_login'), + 'plain' => $this->_t('system.settings.smtp.auth_plain'), + 'crammd5' => $this->_t('system.settings.smtp.auth_crammd5'), + '' => $this->_t('system.settings.smtp.auth_none'), + ], + 'value' => 'login', + 'attribs' => ['id' => 'set-mail-auth', 'class' => 'form-select'], + ]], + ['text', 'mail_smtp_username', [ + 'required' => false, + 'filters' => ['StringTrim'], + 'attribs' => array_merge($control, ['id' => 'set-mail-username', 'autocomplete' => 'off']), + ]], + ['password', 'mail_smtp_password', [ + 'required' => false, + 'filters' => ['StringTrim'], + 'attribs' => array_merge($control, ['id' => 'set-mail-password', 'autocomplete' => 'new-password']), + ]], + // ALLOW_LOCAL alongside ALLOW_DNS on purpose: the shipped default From address is + // `no-reply@localhost`, and an intranet install legitimately sends from a local + // hostname. DNS-only validation would refuse core's own default value. + ['text', 'mail_from_email', [ + 'required' => false, + 'filters' => ['StringTrim'], + 'validators' => [['EmailAddress', false, [ + 'allow' => Zend_Validate_Hostname::ALLOW_DNS | Zend_Validate_Hostname::ALLOW_LOCAL, + ]]], + 'attribs' => array_merge($control, ['id' => 'set-mail-from-email', 'placeholder' => 'no-reply@example.com']), + ]], + ['text', 'mail_from_name', [ + 'required' => false, + 'filters' => ['StringTrim'], + 'attribs' => array_merge($control, ['id' => 'set-mail-from-name', 'placeholder' => 'Tiger']), + ]], + ['text', 'mail_test_to', [ + 'required' => false, + 'filters' => ['StringTrim'], + 'validators' => [['EmailAddress']], + 'attribs' => array_merge($control, ['id' => 'set-mail-test-to', 'placeholder' => 'you@example.com']), + ]], + // reCAPTCHA tab — keys are optional (a keyless install just leaves the widget off). The // secret is a password field: blank = keep the current one (Tiger_Recaptcha::saveSettings). ['checkbox', 'recaptcha_enabled', [ diff --git a/modules/system/languages/de/system.php b/modules/system/languages/de/system.php index a2a6e70d..da72e987 100644 --- a/modules/system/languages/de/system.php +++ b/modules/system/languages/de/system.php @@ -200,8 +200,43 @@ // ---- Settings: Email SMTP -------------------------------------------------------------- 'system.settings.smtp.header' => 'E-Mail & SMTP', - 'system.settings.smtp.under_construction' => 'In Bearbeitung', - 'system.settings.smtp.body' => 'Mail-Transport, SMTP-Host/-Port/-TLS, Authentifizierung und die Absenderidentität werden heute per Konfiguration gesteuert (mail.*) und live von Tiger_Mail gelesen — Konfiguration, nicht Code, sodass ein Admin-Bildschirm sich hier sauber einfügt. Demnächst verfügbar.', + 'system.settings.smtp.intro' => 'Wie diese Installation E-Mails versendet — Passwort-Zurücksetzungen, Bestätigungslinks und Benachrichtigungen. Senden Sie nach dem Speichern eine Testnachricht, um die Zustellung zu bestätigen.', + 'system.settings.smtp.provider' => 'Anbieter', + 'system.settings.smtp.provider_help' => 'Wählen Sie Ihren Maildienst. SMTP funktioniert fast überall; die API-Treiber funktionieren zusätzlich dort, wo ausgehende SMTP-Ports blockiert sind.', + 'system.settings.smtp.optional' => '(optional)', + 'system.settings.smtp.transport' => 'Transport', + 'system.settings.smtp.transport_help' => 'PHP mail() benötigt einen funktionierenden lokalen MTA. SMTP ist auf den meisten Hostings die zuverlässige Wahl.', + 'system.settings.smtp.transport_mail' => 'PHP mail() (sendmail)', + 'system.settings.smtp.transport_smtp' => 'SMTP-Server', + 'system.settings.smtp.connection' => 'SMTP-Verbindung', + 'system.settings.smtp.host' => 'Host', + 'system.settings.smtp.port' => 'Port', + 'system.settings.smtp.encryption' => 'Verschlüsselung', + 'system.settings.smtp.encryption_help' => 'STARTTLS auf Port 587 oder implizites SSL auf Port 465.', + 'system.settings.smtp.ssl_tls' => 'STARTTLS', + 'system.settings.smtp.ssl_ssl' => 'SSL/TLS', + 'system.settings.smtp.ssl_none' => 'Keine', + 'system.settings.smtp.auth' => 'Authentifizierung', + 'system.settings.smtp.auth_login' => 'LOGIN', + 'system.settings.smtp.auth_plain' => 'PLAIN', + 'system.settings.smtp.auth_crammd5' => 'CRAM-MD5', + 'system.settings.smtp.auth_none' => 'Keine', + 'system.settings.smtp.username' => 'Benutzername', + 'system.settings.smtp.password' => 'Passwort', + 'system.settings.smtp.password_placeholder_set' => 'Leer lassen, um das aktuelle Passwort beizubehalten', + 'system.settings.smtp.password_placeholder_new' => 'SMTP-Passwort', + 'system.settings.smtp.password_help_set' => 'Ein Passwort ist gespeichert. Lassen Sie das Feld leer, um es beizubehalten.', + 'system.settings.smtp.password_help_new' => 'Wird verschlüsselt gespeichert und nie wieder angezeigt.', + 'system.settings.smtp.from_email' => 'Absenderadresse', + 'system.settings.smtp.from_name' => 'Absendername', + 'system.settings.smtp.test_header' => 'Test senden', + 'system.settings.smtp.test_intro' => 'Verwendet die aktuell auf dieser Seite eingetragenen Einstellungen — Sie können also vor dem Speichern testen. Ein leeres Passwort verwendet das gespeicherte.', + 'system.settings.smtp.test_to' => 'Senden an', + 'system.settings.smtp.test_send' => 'Test-E-Mail senden', + 'system.settings.smtp.test_subject' => 'Tiger-Test-E-Mail', + 'system.settings.smtp.test_body' => 'Dies ist eine Testnachricht Ihrer Tiger-Installation. Wenn Sie sie erhalten haben, funktioniert der E-Mail-Versand.', + 'system.settings.smtp.test_sent' => 'Test-E-Mail gesendet.', + 'system.settings.smtp.test_bad_address' => 'Geben Sie eine gültige E-Mail-Adresse für den Test an.', // ---- Settings: reCAPTCHA --------------------------------------------------------------- 'system.settings.recaptcha.header' => 'Google reCAPTCHA', @@ -329,6 +364,10 @@ 'system.js.network_error' => 'Netzwerkfehler — bitte versuchen Sie es erneut.', 'system.js.network_error_short' => 'Netzwerkfehler.', 'system.js.lookup_failed' => 'Die Abfrage ist fehlgeschlagen.', + 'system.js.mail_test_sending' => 'Wird gesendet…', + 'system.js.mail_test_sent' => 'Gesendet an', + 'system.js.mail_test_failed' => 'Senden fehlgeschlagen.', + 'system.js.mail_test_needs_address' => 'Geben Sie zuerst eine Adresse ein.', 'system.js.select_update' => 'Wählen Sie mindestens ein Update aus.', 'system.js.updates_attention' => 'Einige Elemente benötigen Aufmerksamkeit — siehe das Protokoll unten.', 'system.js.updates_applied' => 'Updates angewendet.', diff --git a/modules/system/languages/en/system.php b/modules/system/languages/en/system.php index e682b9e8..f37ea67d 100644 --- a/modules/system/languages/en/system.php +++ b/modules/system/languages/en/system.php @@ -202,8 +202,43 @@ // ---- Settings: Email SMTP -------------------------------------------------------------- 'system.settings.smtp.header' => 'Email & SMTP', - 'system.settings.smtp.under_construction' => 'Under construction', - 'system.settings.smtp.body' => 'Mail transport, SMTP host/port/TLS, auth, and the From identity are driven by config today (mail.*) and read live by Tiger_Mail — config, not code, so an admin screen slots in cleanly here. Coming soon.', + 'system.settings.smtp.intro' => 'How this install sends mail — password resets, verification links, and notifications. Send a test after saving to confirm it actually delivers.', + 'system.settings.smtp.provider' => 'Provider', + 'system.settings.smtp.provider_help' => 'Pick your mail service. SMTP works almost everywhere; the API drivers also work where outbound SMTP ports are blocked.', + 'system.settings.smtp.optional' => '(optional)', + 'system.settings.smtp.transport' => 'Transport', + 'system.settings.smtp.transport_help' => 'PHP mail() needs a working local MTA. SMTP is the reliable choice on most hosts.', + 'system.settings.smtp.transport_mail' => 'PHP mail() (sendmail)', + 'system.settings.smtp.transport_smtp' => 'SMTP server', + 'system.settings.smtp.connection' => 'SMTP connection', + 'system.settings.smtp.host' => 'Host', + 'system.settings.smtp.port' => 'Port', + 'system.settings.smtp.encryption' => 'Encryption', + 'system.settings.smtp.encryption_help' => 'STARTTLS on 587, or implicit SSL on 465.', + 'system.settings.smtp.ssl_tls' => 'STARTTLS', + 'system.settings.smtp.ssl_ssl' => 'SSL/TLS', + 'system.settings.smtp.ssl_none' => 'None', + 'system.settings.smtp.auth' => 'Authentication', + 'system.settings.smtp.auth_login' => 'LOGIN', + 'system.settings.smtp.auth_plain' => 'PLAIN', + 'system.settings.smtp.auth_crammd5' => 'CRAM-MD5', + 'system.settings.smtp.auth_none' => 'None', + 'system.settings.smtp.username' => 'Username', + 'system.settings.smtp.password' => 'Password', + 'system.settings.smtp.password_placeholder_set' => 'Leave blank to keep the current password', + 'system.settings.smtp.password_placeholder_new' => 'SMTP password', + 'system.settings.smtp.password_help_set' => 'A password is stored. Leave this blank to keep it.', + 'system.settings.smtp.password_help_new' => 'Stored encrypted; it is never shown again.', + 'system.settings.smtp.from_email' => 'From address', + 'system.settings.smtp.from_name' => 'From name', + 'system.settings.smtp.test_header' => 'Send a test', + 'system.settings.smtp.test_intro' => 'Uses the settings currently on this screen, so you can test before saving. A blank password uses the stored one.', + 'system.settings.smtp.test_to' => 'Send to', + 'system.settings.smtp.test_send' => 'Send test email', + 'system.settings.smtp.test_subject' => 'Tiger test email', + 'system.settings.smtp.test_body' => 'This is a test message from your Tiger install. If you received it, outgoing mail is working.', + 'system.settings.smtp.test_sent' => 'Test email sent.', + 'system.settings.smtp.test_bad_address' => 'Enter a valid email address to send the test to.', // ---- Settings: reCAPTCHA --------------------------------------------------------------- 'system.settings.recaptcha.header' => 'Google reCAPTCHA', @@ -331,6 +366,10 @@ 'system.js.network_error' => 'Network error — please try again.', 'system.js.network_error_short' => 'Network error.', 'system.js.lookup_failed' => 'Lookup failed.', + 'system.js.mail_test_sending' => 'Sending…', + 'system.js.mail_test_sent' => 'Sent to', + 'system.js.mail_test_failed' => 'Send failed.', + 'system.js.mail_test_needs_address' => 'Enter an address first.', 'system.js.select_update' => 'Select at least one update.', 'system.js.updates_attention' => 'Some items need attention — see the log below.', 'system.js.updates_applied' => 'Updates applied.', diff --git a/modules/system/languages/es/system.php b/modules/system/languages/es/system.php index d9955ca1..1269e7f8 100644 --- a/modules/system/languages/es/system.php +++ b/modules/system/languages/es/system.php @@ -200,8 +200,43 @@ // ---- Settings: Email SMTP -------------------------------------------------------------- 'system.settings.smtp.header' => 'Correo & SMTP', - 'system.settings.smtp.under_construction' => 'En construcción', - 'system.settings.smtp.body' => 'El transporte de correo, el host/puerto/TLS SMTP, la autenticación y la identidad del remitente se controlan hoy mediante configuración (mail.*) y los lee en vivo Tiger_Mail: configuración, no código, así que una pantalla de administración encaja limpiamente aquí. Próximamente.', + 'system.settings.smtp.intro' => 'Cómo envía correo esta instalación: restablecimientos de contraseña, enlaces de verificación y notificaciones. Envía una prueba después de guardar para confirmar que realmente se entrega.', + 'system.settings.smtp.provider' => 'Proveedor', + 'system.settings.smtp.provider_help' => 'Elige tu servicio de correo. SMTP funciona casi en todas partes; los controladores de API también funcionan donde los puertos SMTP salientes están bloqueados.', + 'system.settings.smtp.optional' => '(opcional)', + 'system.settings.smtp.transport' => 'Transporte', + 'system.settings.smtp.transport_help' => 'PHP mail() necesita un MTA local funcionando. SMTP es la opción fiable en la mayoría de los alojamientos.', + 'system.settings.smtp.transport_mail' => 'PHP mail() (sendmail)', + 'system.settings.smtp.transport_smtp' => 'Servidor SMTP', + 'system.settings.smtp.connection' => 'Conexión SMTP', + 'system.settings.smtp.host' => 'Servidor', + 'system.settings.smtp.port' => 'Puerto', + 'system.settings.smtp.encryption' => 'Cifrado', + 'system.settings.smtp.encryption_help' => 'STARTTLS en el 587, o SSL implícito en el 465.', + 'system.settings.smtp.ssl_tls' => 'STARTTLS', + 'system.settings.smtp.ssl_ssl' => 'SSL/TLS', + 'system.settings.smtp.ssl_none' => 'Ninguno', + 'system.settings.smtp.auth' => 'Autenticación', + 'system.settings.smtp.auth_login' => 'LOGIN', + 'system.settings.smtp.auth_plain' => 'PLAIN', + 'system.settings.smtp.auth_crammd5' => 'CRAM-MD5', + 'system.settings.smtp.auth_none' => 'Ninguna', + 'system.settings.smtp.username' => 'Usuario', + 'system.settings.smtp.password' => 'Contraseña', + 'system.settings.smtp.password_placeholder_set' => 'Déjalo en blanco para mantener la contraseña actual', + 'system.settings.smtp.password_placeholder_new' => 'Contraseña SMTP', + 'system.settings.smtp.password_help_set' => 'Hay una contraseña guardada. Déjalo en blanco para mantenerla.', + 'system.settings.smtp.password_help_new' => 'Se guarda cifrada; no se vuelve a mostrar.', + 'system.settings.smtp.from_email' => 'Dirección del remitente', + 'system.settings.smtp.from_name' => 'Nombre del remitente', + 'system.settings.smtp.test_header' => 'Enviar una prueba', + 'system.settings.smtp.test_intro' => 'Usa los ajustes actuales de esta pantalla, así puedes probar antes de guardar. Si dejas la contraseña en blanco, se usa la guardada.', + 'system.settings.smtp.test_to' => 'Enviar a', + 'system.settings.smtp.test_send' => 'Enviar correo de prueba', + 'system.settings.smtp.test_subject' => 'Correo de prueba de Tiger', + 'system.settings.smtp.test_body' => 'Este es un mensaje de prueba de tu instalación de Tiger. Si lo has recibido, el correo saliente funciona.', + 'system.settings.smtp.test_sent' => 'Correo de prueba enviado.', + 'system.settings.smtp.test_bad_address' => 'Introduce una dirección de correo válida para enviar la prueba.', // ---- Settings: reCAPTCHA --------------------------------------------------------------- 'system.settings.recaptcha.header' => 'Google reCAPTCHA', @@ -329,6 +364,10 @@ 'system.js.network_error' => 'Error de red — inténtalo de nuevo.', 'system.js.network_error_short' => 'Error de red.', 'system.js.lookup_failed' => 'La búsqueda falló.', + 'system.js.mail_test_sending' => 'Enviando…', + 'system.js.mail_test_sent' => 'Enviado a', + 'system.js.mail_test_failed' => 'Error al enviar.', + 'system.js.mail_test_needs_address' => 'Introduce primero una dirección.', 'system.js.select_update' => 'Selecciona al menos una actualización.', 'system.js.updates_attention' => 'Algunos elementos requieren atención — consulta el registro más abajo.', 'system.js.updates_applied' => 'Actualizaciones aplicadas.', diff --git a/modules/system/languages/fr/system.php b/modules/system/languages/fr/system.php index 4a881e74..e66f9cdb 100644 --- a/modules/system/languages/fr/system.php +++ b/modules/system/languages/fr/system.php @@ -200,8 +200,43 @@ // ---- Settings: Email SMTP -------------------------------------------------------------- 'system.settings.smtp.header' => 'E-mail & SMTP', - 'system.settings.smtp.under_construction' => 'En construction', - 'system.settings.smtp.body' => 'Le transport du courrier, l\'hôte/port/TLS SMTP, l\'authentification et l\'identité de l\'expéditeur sont aujourd\'hui pilotés par la configuration (mail.*) et lus en direct par Tiger_Mail — de la configuration, pas du code, donc un écran d\'administration s\'insère proprement ici. Bientôt disponible.', + 'system.settings.smtp.intro' => 'Comment cette installation envoie ses e-mails — réinitialisations de mot de passe, liens de vérification et notifications. Envoyez un test après l\'enregistrement pour confirmer que la distribution fonctionne.', + 'system.settings.smtp.provider' => 'Fournisseur', + 'system.settings.smtp.provider_help' => 'Choisissez votre service de messagerie. SMTP fonctionne presque partout ; les pilotes d\'API fonctionnent aussi là où les ports SMTP sortants sont bloqués.', + 'system.settings.smtp.optional' => '(facultatif)', + 'system.settings.smtp.transport' => 'Transport', + 'system.settings.smtp.transport_help' => 'PHP mail() nécessite un MTA local fonctionnel. SMTP est le choix fiable sur la plupart des hébergements.', + 'system.settings.smtp.transport_mail' => 'PHP mail() (sendmail)', + 'system.settings.smtp.transport_smtp' => 'Serveur SMTP', + 'system.settings.smtp.connection' => 'Connexion SMTP', + 'system.settings.smtp.host' => 'Hôte', + 'system.settings.smtp.port' => 'Port', + 'system.settings.smtp.encryption' => 'Chiffrement', + 'system.settings.smtp.encryption_help' => 'STARTTLS sur le port 587, ou SSL implicite sur le 465.', + 'system.settings.smtp.ssl_tls' => 'STARTTLS', + 'system.settings.smtp.ssl_ssl' => 'SSL/TLS', + 'system.settings.smtp.ssl_none' => 'Aucun', + 'system.settings.smtp.auth' => 'Authentification', + 'system.settings.smtp.auth_login' => 'LOGIN', + 'system.settings.smtp.auth_plain' => 'PLAIN', + 'system.settings.smtp.auth_crammd5' => 'CRAM-MD5', + 'system.settings.smtp.auth_none' => 'Aucune', + 'system.settings.smtp.username' => 'Nom d\'utilisateur', + 'system.settings.smtp.password' => 'Mot de passe', + 'system.settings.smtp.password_placeholder_set' => 'Laissez vide pour conserver le mot de passe actuel', + 'system.settings.smtp.password_placeholder_new' => 'Mot de passe SMTP', + 'system.settings.smtp.password_help_set' => 'Un mot de passe est enregistré. Laissez vide pour le conserver.', + 'system.settings.smtp.password_help_new' => 'Stocké chiffré ; il n\'est plus jamais affiché.', + 'system.settings.smtp.from_email' => 'Adresse d\'expéditeur', + 'system.settings.smtp.from_name' => 'Nom d\'expéditeur', + 'system.settings.smtp.test_header' => 'Envoyer un test', + 'system.settings.smtp.test_intro' => 'Utilise les réglages actuellement saisis sur cet écran, vous pouvez donc tester avant d\'enregistrer. Un mot de passe vide utilise celui qui est enregistré.', + 'system.settings.smtp.test_to' => 'Envoyer à', + 'system.settings.smtp.test_send' => 'Envoyer un e-mail de test', + 'system.settings.smtp.test_subject' => 'E-mail de test Tiger', + 'system.settings.smtp.test_body' => 'Ceci est un message de test de votre installation Tiger. Si vous l\'avez reçu, l\'envoi d\'e-mails fonctionne.', + 'system.settings.smtp.test_sent' => 'E-mail de test envoyé.', + 'system.settings.smtp.test_bad_address' => 'Saisissez une adresse e-mail valide pour l\'envoi du test.', // ---- Settings: reCAPTCHA --------------------------------------------------------------- 'system.settings.recaptcha.header' => 'Google reCAPTCHA', @@ -329,6 +364,10 @@ 'system.js.network_error' => 'Erreur réseau — veuillez réessayer.', 'system.js.network_error_short' => 'Erreur réseau.', 'system.js.lookup_failed' => 'La recherche a échoué.', + 'system.js.mail_test_sending' => 'Envoi…', + 'system.js.mail_test_sent' => 'Envoyé à', + 'system.js.mail_test_failed' => 'Échec de l\'envoi.', + 'system.js.mail_test_needs_address' => 'Saisissez d\'abord une adresse.', 'system.js.select_update' => 'Sélectionnez au moins une mise à jour.', 'system.js.updates_attention' => 'Certains éléments nécessitent votre attention — consultez le journal ci-dessous.', 'system.js.updates_applied' => 'Mises à jour appliquées.', diff --git a/modules/system/languages/hi/system.php b/modules/system/languages/hi/system.php index e5a43cc8..900bf3d1 100644 --- a/modules/system/languages/hi/system.php +++ b/modules/system/languages/hi/system.php @@ -200,8 +200,43 @@ // ---- Settings: Email SMTP -------------------------------------------------------------- 'system.settings.smtp.header' => 'ईमेल & SMTP', - 'system.settings.smtp.under_construction' => 'निर्माणाधीन', - 'system.settings.smtp.body' => 'मेल ट्रांसपोर्ट, SMTP होस्ट/पोर्ट/TLS, प्रमाणीकरण और From पहचान आज कॉन्फ़िगरेशन (mail.*) द्वारा संचालित होते हैं और Tiger_Mail द्वारा लाइव पढ़े जाते हैं — कॉन्फ़िगरेशन, कोड नहीं, इसलिए एक एडमिन स्क्रीन यहाँ सफाई से फिट हो जाती है। जल्द ही आ रहा है।', + 'system.settings.smtp.intro' => 'यह इंस्टॉल मेल कैसे भेजता है — पासवर्ड रीसेट, सत्यापन लिंक और सूचनाएँ। सहेजने के बाद एक परीक्षण भेजें ताकि पुष्टि हो सके कि डिलीवरी वाकई हो रही है।', + 'system.settings.smtp.provider' => 'प्रदाता', + 'system.settings.smtp.provider_help' => 'अपनी मेल सेवा चुनें। SMTP लगभग हर जगह काम करता है; API ड्राइवर वहाँ भी काम करते हैं जहाँ आउटगोइंग SMTP पोर्ट अवरुद्ध हैं।', + 'system.settings.smtp.optional' => '(वैकल्पिक)', + 'system.settings.smtp.transport' => 'ट्रांसपोर्ट', + 'system.settings.smtp.transport_help' => 'PHP mail() के लिए चालू स्थानीय MTA चाहिए। अधिकांश होस्ट पर SMTP भरोसेमंद विकल्प है।', + 'system.settings.smtp.transport_mail' => 'PHP mail() (sendmail)', + 'system.settings.smtp.transport_smtp' => 'SMTP सर्वर', + 'system.settings.smtp.connection' => 'SMTP कनेक्शन', + 'system.settings.smtp.host' => 'होस्ट', + 'system.settings.smtp.port' => 'पोर्ट', + 'system.settings.smtp.encryption' => 'एन्क्रिप्शन', + 'system.settings.smtp.encryption_help' => '587 पर STARTTLS, या 465 पर इम्प्लिसिट SSL।', + 'system.settings.smtp.ssl_tls' => 'STARTTLS', + 'system.settings.smtp.ssl_ssl' => 'SSL/TLS', + 'system.settings.smtp.ssl_none' => 'कोई नहीं', + 'system.settings.smtp.auth' => 'प्रमाणीकरण', + 'system.settings.smtp.auth_login' => 'LOGIN', + 'system.settings.smtp.auth_plain' => 'PLAIN', + 'system.settings.smtp.auth_crammd5' => 'CRAM-MD5', + 'system.settings.smtp.auth_none' => 'कोई नहीं', + 'system.settings.smtp.username' => 'उपयोगकर्ता नाम', + 'system.settings.smtp.password' => 'पासवर्ड', + 'system.settings.smtp.password_placeholder_set' => 'मौजूदा पासवर्ड रखने के लिए खाली छोड़ें', + 'system.settings.smtp.password_placeholder_new' => 'SMTP पासवर्ड', + 'system.settings.smtp.password_help_set' => 'एक पासवर्ड सहेजा हुआ है। उसे बनाए रखने के लिए खाली छोड़ें।', + 'system.settings.smtp.password_help_new' => 'एन्क्रिप्टेड रूप में सहेजा जाता है; इसे दोबारा नहीं दिखाया जाता।', + 'system.settings.smtp.from_email' => 'प्रेषक पता', + 'system.settings.smtp.from_name' => 'प्रेषक नाम', + 'system.settings.smtp.test_header' => 'परीक्षण भेजें', + 'system.settings.smtp.test_intro' => 'यह इसी स्क्रीन की मौजूदा सेटिंग्स का उपयोग करता है, इसलिए आप सहेजने से पहले परीक्षण कर सकते हैं। पासवर्ड खाली छोड़ने पर सहेजा गया पासवर्ड उपयोग होगा।', + 'system.settings.smtp.test_to' => 'इन्हें भेजें', + 'system.settings.smtp.test_send' => 'परीक्षण ईमेल भेजें', + 'system.settings.smtp.test_subject' => 'Tiger परीक्षण ईमेल', + 'system.settings.smtp.test_body' => 'यह आपके Tiger इंस्टॉल से भेजा गया परीक्षण संदेश है। यदि यह आपको मिला है, तो आउटगोइंग मेल काम कर रहा है।', + 'system.settings.smtp.test_sent' => 'परीक्षण ईमेल भेजा गया।', + 'system.settings.smtp.test_bad_address' => 'परीक्षण भेजने के लिए एक मान्य ईमेल पता दर्ज करें।', // ---- Settings: reCAPTCHA --------------------------------------------------------------- 'system.settings.recaptcha.header' => 'Google reCAPTCHA', @@ -329,6 +364,10 @@ 'system.js.network_error' => 'नेटवर्क त्रुटि — कृपया पुनः प्रयास करें।', 'system.js.network_error_short' => 'नेटवर्क त्रुटि।', 'system.js.lookup_failed' => 'लुकअप विफल रहा।', + 'system.js.mail_test_sending' => 'भेजा जा रहा है…', + 'system.js.mail_test_sent' => 'इन्हें भेजा गया', + 'system.js.mail_test_failed' => 'भेजना विफल रहा।', + 'system.js.mail_test_needs_address' => 'पहले एक पता दर्ज करें।', 'system.js.select_update' => 'कम से कम एक अपडेट चुनें।', 'system.js.updates_attention' => 'कुछ आइटमों पर ध्यान देने की आवश्यकता है — नीचे लॉग देखें।', 'system.js.updates_applied' => 'अपडेट लागू किए गए।', diff --git a/modules/system/languages/pt/system.php b/modules/system/languages/pt/system.php index dfca0b1a..9c2a35cf 100644 --- a/modules/system/languages/pt/system.php +++ b/modules/system/languages/pt/system.php @@ -200,8 +200,43 @@ // ---- Settings: Email SMTP -------------------------------------------------------------- 'system.settings.smtp.header' => 'E-mail & SMTP', - 'system.settings.smtp.under_construction' => 'Em construção', - 'system.settings.smtp.body' => 'O transporte de e-mail, o host/porta/TLS do SMTP, a autenticação e a identidade do remetente são controlados hoje por configuração (mail.*) e lidos ao vivo pelo Tiger_Mail — configuração, não código, então uma tela de administração se encaixa aqui de forma limpa. Em breve.', + 'system.settings.smtp.intro' => 'Como esta instalação envia e-mail: redefinições de senha, links de verificação e notificações. Envie um teste após salvar para confirmar que realmente entrega.', + 'system.settings.smtp.provider' => 'Provedor', + 'system.settings.smtp.provider_help' => 'Escolha seu serviço de e-mail. O SMTP funciona quase em todo lugar; os drivers de API também funcionam onde as portas SMTP de saída estão bloqueadas.', + 'system.settings.smtp.optional' => '(opcional)', + 'system.settings.smtp.transport' => 'Transporte', + 'system.settings.smtp.transport_help' => 'O PHP mail() precisa de um MTA local funcionando. SMTP é a opção confiável na maioria das hospedagens.', + 'system.settings.smtp.transport_mail' => 'PHP mail() (sendmail)', + 'system.settings.smtp.transport_smtp' => 'Servidor SMTP', + 'system.settings.smtp.connection' => 'Conexão SMTP', + 'system.settings.smtp.host' => 'Servidor', + 'system.settings.smtp.port' => 'Porta', + 'system.settings.smtp.encryption' => 'Criptografia', + 'system.settings.smtp.encryption_help' => 'STARTTLS na 587, ou SSL implícito na 465.', + 'system.settings.smtp.ssl_tls' => 'STARTTLS', + 'system.settings.smtp.ssl_ssl' => 'SSL/TLS', + 'system.settings.smtp.ssl_none' => 'Nenhuma', + 'system.settings.smtp.auth' => 'Autenticação', + 'system.settings.smtp.auth_login' => 'LOGIN', + 'system.settings.smtp.auth_plain' => 'PLAIN', + 'system.settings.smtp.auth_crammd5' => 'CRAM-MD5', + 'system.settings.smtp.auth_none' => 'Nenhuma', + 'system.settings.smtp.username' => 'Usuário', + 'system.settings.smtp.password' => 'Senha', + 'system.settings.smtp.password_placeholder_set' => 'Deixe em branco para manter a senha atual', + 'system.settings.smtp.password_placeholder_new' => 'Senha SMTP', + 'system.settings.smtp.password_help_set' => 'Há uma senha salva. Deixe em branco para mantê-la.', + 'system.settings.smtp.password_help_new' => 'Armazenada criptografada; nunca é exibida novamente.', + 'system.settings.smtp.from_email' => 'Endereço do remetente', + 'system.settings.smtp.from_name' => 'Nome do remetente', + 'system.settings.smtp.test_header' => 'Enviar um teste', + 'system.settings.smtp.test_intro' => 'Usa as configurações atuais desta tela, então você pode testar antes de salvar. Uma senha em branco usa a que está salva.', + 'system.settings.smtp.test_to' => 'Enviar para', + 'system.settings.smtp.test_send' => 'Enviar e-mail de teste', + 'system.settings.smtp.test_subject' => 'E-mail de teste do Tiger', + 'system.settings.smtp.test_body' => 'Esta é uma mensagem de teste da sua instalação do Tiger. Se você a recebeu, o envio de e-mails está funcionando.', + 'system.settings.smtp.test_sent' => 'E-mail de teste enviado.', + 'system.settings.smtp.test_bad_address' => 'Informe um endereço de e-mail válido para enviar o teste.', // ---- Settings: reCAPTCHA --------------------------------------------------------------- 'system.settings.recaptcha.header' => 'Google reCAPTCHA', @@ -329,6 +364,10 @@ 'system.js.network_error' => 'Erro de rede — tente novamente.', 'system.js.network_error_short' => 'Erro de rede.', 'system.js.lookup_failed' => 'A consulta falhou.', + 'system.js.mail_test_sending' => 'Enviando…', + 'system.js.mail_test_sent' => 'Enviado para', + 'system.js.mail_test_failed' => 'Falha no envio.', + 'system.js.mail_test_needs_address' => 'Informe um endereço primeiro.', 'system.js.select_update' => 'Selecione ao menos uma atualização.', 'system.js.updates_attention' => 'Alguns itens precisam de atenção — consulte o registro abaixo.', 'system.js.updates_applied' => 'Atualizações aplicadas.', diff --git a/modules/system/languages/tlh/system.php b/modules/system/languages/tlh/system.php index bdab96eb..94ac6b3e 100644 --- a/modules/system/languages/tlh/system.php +++ b/modules/system/languages/tlh/system.php @@ -203,8 +203,6 @@ // ---- Settings: Email SMTP -------------------------------------------------------------- 'system.settings.smtp.header' => 'Email & SMTP', - 'system.settings.smtp.under_construction' => 'Under construction', - 'system.settings.smtp.body' => 'Mail transport, SMTP host/port/TLS, auth, and the From identity are driven by config today (mail.*) and read live by Tiger_Mail — config, not code, so an admin screen slots in cleanly here. Coming soon.', // ---- Settings: reCAPTCHA --------------------------------------------------------------- 'system.settings.recaptcha.header' => 'Google reCAPTCHA', diff --git a/modules/system/services/Settings.php b/modules/system/services/Settings.php index 0bd118c9..fa8568c2 100644 --- a/modules/system/services/Settings.php +++ b/modules/system/services/Settings.php @@ -40,6 +40,28 @@ public function save(array $params): void $cfg->set($g, '', 'tiger.session.autologout.seconds', (string) max(30, (int) $v['autologout_seconds'])); $cfg->set($g, '', 'tiger.session.autologout.action', $v['autologout_action'] === 'lock' ? 'lock' : 'logout'); + // Email SMTP tab — shared writer (encrypts the password; a blank one keeps the current). + // The provider decides the transport kind: an API provider stores its credentials and + // returns early; sendmail means PHP mail(); everything else is SMTP. + $provider = (string) $v['mail_provider']; + $pDef = Tiger_Mail_Provider::get($provider); + $fields = (isset($params['mail_field'][$provider]) && is_array($params['mail_field'][$provider])) + ? $params['mail_field'][$provider] : []; + + Tiger_Mail::saveSettings([ + 'provider' => $provider, + 'fields' => $fields, + 'transport' => ($pDef && $pDef['kind'] === Tiger_Mail_Provider::KIND_SMTP && $provider !== 'sendmail') ? 'smtp' : 'mail', + 'host' => $v['mail_smtp_host'], + 'port' => $v['mail_smtp_port'], + 'ssl' => $v['mail_smtp_ssl'], + 'auth' => $v['mail_smtp_auth'], + 'username' => $v['mail_smtp_username'], + 'password' => $v['mail_smtp_password'], + 'from_email' => $v['mail_from_email'], + 'from_name' => $v['mail_from_name'], + ]); + // reCAPTCHA tab — shared writer (encrypts the secret; blank secret keeps the current one). Tiger_Recaptcha::saveSettings([ 'enabled' => !empty($v['recaptcha_enabled']) ? 1 : 0, @@ -112,4 +134,110 @@ public function locationTest(array $params): void $this->_success(Tiger_Location::test($ip, $provider, $config)); } + + /** + * Live send test for the Email SMTP tab's "Send test" button — the answer to "is my mail + * actually configured?", which is otherwise unanswerable: the password-reset flow deliberately + * reveals nothing (no account enumeration), so a broken MTA looks identical to a working one. + * + * Like `locationTest`, it uses the form's CURRENT values, so a just-typed host/port/password is + * testable WITHOUT saving — a wrong setting never has to overwrite a working one to be tried. A + * blank password falls back to the stored secret (blank = keep, same rule as save). + * + * The transport's error text is returned verbatim: on a connection test the message ("Could not + * open socket", an auth rejection, a TLS failure) IS the diagnostic, and this action is admin-only. + * + * @param array $params to, mail_transport, mail_smtp_{host,port,ssl,auth,username,password}, mail_from_{email,name} + * @return void + */ + public function mailTest(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + + $to = trim((string) ($params['to'] ?? '')); + if ($to === '' || !Zend_Validate::is($to, 'EmailAddress')) { + $this->_error('system.settings.smtp.test_bad_address'); return; + } + + // Blank password → the stored one, so testing an existing setup needs no re-typing. + $password = (string) ($params['mail_smtp_password'] ?? ''); + if ($password === '') { $password = Tiger_Mail::storedSmtpPassword(); } + + $provider = (string) ($params['mail_provider'] ?? ''); + $pDef = Tiger_Mail_Provider::get($provider); + $isApi = $pDef && $pDef['kind'] === Tiger_Mail_Provider::KIND_API; + + if ($isApi && !Tiger_Mail_Provider::isAvailable($provider)) { + $this->_success(['ok' => false, 'to' => $to, + 'error' => $this->_translate($pDef['requires_hint'] ?? 'core.mail.provider.requires.generic')]); + return; + } + + // API credentials from the form, with each blank secret falling back to the stored one. + $fields = []; + if ($isApi) { + $submitted = (isset($params['mail_field'][$provider]) && is_array($params['mail_field'][$provider])) + ? $params['mail_field'][$provider] : []; + $stored = Tiger_Mail::apiCredentials($provider); + foreach (array_keys(Tiger_Mail_Provider::fields($provider)) as $f) { + $val = trim((string) ($submitted[$f] ?? '')); + $fields[$f] = $val !== '' ? $val : (string) ($stored[$f] ?? ''); + } + } + + $values = [ + 'transport' => $isApi ? 'api' : (($pDef && $provider !== 'sendmail') ? 'smtp' : 'mail'), + 'provider' => $provider, + 'fields' => $fields, + 'host' => (string) ($params['mail_smtp_host'] ?? ''), + 'port' => (string) ($params['mail_smtp_port'] ?? ''), + 'ssl' => (string) ($params['mail_smtp_ssl'] ?? ''), + 'auth' => (string) ($params['mail_smtp_auth'] ?? ''), + 'username' => (string) ($params['mail_smtp_username'] ?? ''), + 'password' => $password, + ]; + + $started = microtime(true); + try { + $mail = new Tiger_Mail(); + $from = trim((string) ($params['mail_from_email'] ?? '')); + if ($from !== '') { $mail->from($from, trim((string) ($params['mail_from_name'] ?? ''))); } + + $mail->to($to) + ->subject($this->_translate('system.settings.smtp.test_subject')) + ->html('

' . htmlspecialchars($this->_translate('system.settings.smtp.test_body'), ENT_QUOTES) . '

') + ->send(Tiger_Mail::transportFor($values)); + + $this->_success([ + 'ok' => true, + 'to' => $to, + 'ms' => (int) round((microtime(true) - $started) * 1000), + 'via' => $isApi + ? (string) $pDef['label'] + : (($values['transport'] === 'smtp' && $values['host'] !== '') + ? $values['host'] . ':' . ($values['port'] !== '' ? $values['port'] : '25') + : 'sendmail'), + ], 'system.settings.smtp.test_sent'); + } catch (Throwable $e) { + $this->_success([ + 'ok' => false, + 'to' => $to, + 'ms' => (int) round((microtime(true) - $started) * 1000), + 'error' => $e->getMessage(), + ]); + } + } + + /** Translate a key through the registered translator, falling back to the key itself. */ + protected function _translate(string $key): string + { + try { + if (Zend_Registry::isRegistered('Zend_Translate')) { + return (string) Zend_Registry::get('Zend_Translate')->translate($key); + } + } catch (Throwable $e) { + // fall through — a test send must never fail on a missing translator + } + return $key; + } } diff --git a/modules/system/views/scripts/settings/index.phtml b/modules/system/views/scripts/settings/index.phtml index 32e31cb8..83269c7b 100644 --- a/modules/system/views/scripts/settings/index.phtml +++ b/modules/system/views/scripts/settings/index.phtml @@ -14,6 +14,16 @@ $alAction = (string) $el('autologout_action')->getValue() ?: 'logout'; $rcVersion = (string) $el('recaptcha_version')->getValue() ?: 'v2'; $rcEnabled = !empty($el('recaptcha_enabled')->getValue()); $hasSecret = !empty($this->hasSecret); +$mailTransport = (string) $this->mailTransport === 'smtp' ? 'smtp' : 'mail'; +$mailHasPassword = !empty($this->mailHasPassword); +$mailProviders = (array) $this->mailProviders; +$mailProvider = (string) $this->mailProvider; +$mailFields = (array) $this->mailFields; +$mailHasField = (array) $this->mailHasField; +// The SMTP connection block applies to every provider EXCEPT the API drivers and bare sendmail. +$mailIsSmtp = isset($mailProviders[$mailProvider]) + && $mailProviders[$mailProvider]['kind'] === Tiger_Mail_Provider::KIND_SMTP + && $mailProvider !== 'sendmail'; ?>
@@ -135,21 +145,122 @@ $hasSecret = !empty($this->hasSecret);
- +
t('system.settings.smtp.header') ?>
-
- -

t('system.settings.smtp.under_construction') ?>

-

t('system.settings.smtp.body') ?>

+

t('system.settings.smtp.intro') ?>

+ +
+ + +
t('system.settings.smtp.provider_help') ?>
+
+ + + $pDef): if (empty($pDef['fields'])) { continue; } ?> +
> + +

t($pDef['help']) ?>

+ + +
t($pDef['requires_hint'] ?? 'core.mail.provider.requires.generic') ?>
+ +
+ $fDef): + $id = 'mail-f-' . $pKey . '-' . $fName; + $isSec = !empty($fDef['secret']); + $hasVal = !empty($mailHasField[$fName]); + $val = $isSec ? '' : (string) ($mailFields[$fName] ?? ''); + $ph = $isSec && $hasVal + ? $this->t('system.settings.smtp.password_placeholder_set') + : (string) ($fDef['placeholder'] ?? ''); + ?> +
+ + +
t($fDef['help']) ?>
+
t('system.settings.smtp.password_help_set') ?>
+
+ +
+
+ + +
+ t('system.settings.smtp.connection') ?> + +
+
+ + +
+
+ + +
+
+ + +
t('system.settings.smtp.encryption_help') ?>
+
+
+ + +
+
+ + +
+
+ + setAttrib('placeholder', $mailHasPassword ? $this->t('system.settings.smtp.password_placeholder_set') : $this->t('system.settings.smtp.password_placeholder_new')); ?> + +
t('system.settings.smtp.password_help_set') : $this->t('system.settings.smtp.password_help_new') ?>
+
+
+
+ +
+
+ + +
+
+ + +
+ +
+
+
t('system.settings.smtp.test_header') ?>
+
+

t('system.settings.smtp.test_intro') ?>

+
+ + +
+ +
+
+
+
@@ -377,6 +488,10 @@ $hasSecret = !empty($this->hasSecret); $this->i18n([ 'fixFields' => 'system.js.fix_fields', 'lookupFailed' => 'system.js.lookup_failed', + 'mailTestFailed' => 'system.js.mail_test_failed', + 'mailTestNeedsAddress' => 'system.js.mail_test_needs_address', + 'mailTestSending' => 'system.js.mail_test_sending', + 'mailTestSent' => 'system.js.mail_test_sent', 'networkError' => 'system.js.network_error', 'networkErrorShort' => 'system.js.network_error_short', 'settingsSaved' => 'system.js.settings_saved', @@ -514,5 +629,85 @@ document.addEventListener('DOMContentLoaded', function () { }).catch(function () { out.className = 'small mt-2 text-danger'; out.textContent = Tiger.t('networkErrorShort'); }); }); } + + // --- Email SMTP: the provider drives which blocks are relevant --- + var mailProvider = document.getElementById('set-mail-provider'); + var mailSmtpBox = document.getElementById('set-mail-smtp'); + if (mailProvider) { + // Which providers use the SMTP connection block, and each one's host/port defaults — + // emitted from the PHP catalog so the two can't drift apart. + var mailKinds = $d['kind'], 'smtp' => $d['smtp'] ?? null]; + }, $mailProviders), JSON_UNESCAPED_SLASHES) ?>; + + var syncProvider = function (prefill) { + var key = mailProvider.value; + var def = mailKinds[key] || {}; + var smtp = def.kind === 'smtp' && key !== 'sendmail'; + + if (mailSmtpBox) { mailSmtpBox.classList.toggle('d-none', !smtp); } + document.querySelectorAll('.mail-provider-fields').forEach(function (box) { + box.style.display = (box.getAttribute('data-provider') === key) ? '' : 'none'; + }); + + // Prefill the connection defaults on a real change, so picking "SendGrid (SMTP)" fills + // in host/port/encryption instead of leaving the operator to look them up. Never on + // first paint — that would clobber a saved custom host. + if (prefill && smtp && def.smtp) { + var set = function (id, val) { + var elx = document.getElementById(id); + if (elx && val !== null && val !== undefined && String(val) !== '') { elx.value = String(val); } + }; + if (String(def.smtp.host || '').indexOf('{') === -1) { set('set-mail-host', def.smtp.host); } + set('set-mail-port', def.smtp.port); + set('set-mail-ssl', def.smtp.ssl); + set('set-mail-auth', def.smtp.auth); + } + }; + mailProvider.addEventListener('change', function () { syncProvider(true); }); + syncProvider(false); + } + + // --- Email SMTP: send a test with the values CURRENTLY in the form (no save required) --- + var mailTestBtn = document.getElementById('mail-test-btn'); + if (mailTestBtn) { + mailTestBtn.addEventListener('click', function () { + var out = document.getElementById('mail-test-result'); + var to = document.getElementById('set-mail-test-to').value.trim(); + if (to === '') { + out.className = 'small text-danger'; out.textContent = '✗ ' + Tiger.t('mailTestNeedsAddress'); + return; + } + var fd = new URLSearchParams(); + fd.set('module', 'system'); fd.set('service', 'settings'); fd.set('method', 'mailTest'); + fd.set('to', to); + ['mail_provider', 'mail_smtp_host', 'mail_smtp_port', 'mail_smtp_ssl', 'mail_smtp_auth', + 'mail_smtp_username', 'mail_smtp_password', 'mail_from_email', 'mail_from_name'].forEach(function (k) { + var input = form.querySelector('[name="' + k + '"]'); + fd.set(k, input ? input.value : ''); + }); + // The selected provider's credential fields, so an API key can be tested before saving. + var credBox = document.querySelector('.mail-provider-fields[data-provider="' + (document.getElementById('set-mail-provider') || {}).value + '"]'); + if (credBox) { + credBox.querySelectorAll('input[name^="mail_field"]').forEach(function (inp) { + if (inp.value !== '') { fd.set(inp.name, inp.value); } + }); + } + out.className = 'small text-body-secondary'; out.textContent = Tiger.t('mailTestSending'); + TigerButton.run(this, function () { + return fetch('/api', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd }).then(function (r) { return r.json().catch(function () { return {}; }); }); + }).then(function (res) { + var d = (res && res.data) || {}; + if (d.ok) { + out.className = 'small text-success'; + out.textContent = '✓ ' + Tiger.t('mailTestSent') + ' ' + d.to + ' — ' + (d.via || '') + ' (' + (d.ms || 0) + 'ms)'; + } else { + // The transport's own message is the diagnostic — show it verbatim, wrapped. + out.className = 'small text-danger text-break'; + out.textContent = '✗ ' + (d.error || ((res && res.messages && res.messages[0]) ? res.messages[0].message : Tiger.t('mailTestFailed'))); + } + }).catch(function () { out.className = 'small text-danger'; out.textContent = Tiger.t('networkErrorShort'); }); + }); + } }); diff --git a/tests/Integration/Mail/MailApiSendTest.php b/tests/Integration/Mail/MailApiSendTest.php new file mode 100644 index 00000000..d22ca219 --- /dev/null +++ b/tests/Integration/Mail/MailApiSendTest.php @@ -0,0 +1,143 @@ +setFrom('no-reply@example.test', 'Tiger'); + $mail->addTo('buyer@example.test'); + $mail->setSubject('Hello'); + $mail->setBodyHtml('

Hi there

'); + return $mail; + } + + #[Test] + public function every_driver_posts_to_its_endpoint_with_a_well_formed_body(): void + { + $cases = [ + 'sendgrid' => [new ProbeSendGrid(['key' => 'k']), 'api.sendgrid.com', true], + 'postmark' => [new ProbePostmark(['key' => 'k']), 'api.postmarkapp.com', true], + 'resend' => [new ProbeResend(['key' => 'k']), 'api.resend.com', true], + 'brevo' => [new ProbeBrevo(['key' => 'k']), 'api.brevo.com', true], + 'mailjet' => [new ProbeMailjet(['key' => 'k', 'secret' => 's']), 'api.mailjet.com', true], + 'mailgun' => [new ProbeMailgun(['key' => 'k', 'domain' => 'mg.test']), 'api.mailgun.net', false], + ]; + + foreach ($cases as $name => [$probe, $host, $isJson]) { + $probe->send($this->mail()); + + $this->assertStringContainsString($host, (string) $probe->probeUrl, "$name posts to its own endpoint"); + $this->assertNotSame('', (string) $probe->probeBody, "$name sends a non-empty body"); + $this->assertNotEmpty($probe->probeHeaders, "$name sends headers"); + + if ($isJson) { + $decoded = json_decode((string) $probe->probeBody, true); + $this->assertIsArray($decoded, "$name sends valid JSON"); + $this->assertStringContainsString('Hello', (string) $probe->probeBody, "$name carries the subject"); + } else { + parse_str((string) $probe->probeBody, $form); + $this->assertSame('Hello', $form['subject'] ?? null, "$name form-encodes the subject"); + } + } + } + + #[Test] + public function a_transport_failure_becomes_a_mail_transport_exception(): void + { + // Port 1 on localhost refuses instantly — a real cURL failure with no network dependency. + $t = new ProbeUnreachable(['key' => 'k']); + + $this->expectException(Zend_Mail_Transport_Exception::class); + $this->expectExceptionMessageMatches('/Mail API request failed/'); + $t->send($this->mail()); + } + + #[Test] + public function ses_without_the_sdk_explains_itself_instead_of_a_class_not_found_fatal(): void + { + if (class_exists('Aws\\SesV2\\SesV2Client')) { + $this->markTestSkipped('The AWS SDK is installed here, so the missing-SDK branch cannot be exercised.'); + } + + $t = new Tiger_Mail_Transport_Ses(['region' => 'us-east-1']); + + $this->expectException(Zend_Mail_Transport_Exception::class); + // The operator needs to be told what to install and what to use instead — a raw + // "Class not found" would be a dead end. + $this->expectExceptionMessageMatches('/AWS SDK module \(tiger-sdk-aws\).*SES \(SMTP\)/s'); + $t->send($this->mail()); + } +} + +/** Capture the request instead of performing it. */ +trait ProbesTheRequest +{ + // Deliberately NOT $body/$header: Zend_Mail_Transport_Abstract already declares public + // $body and $header for the rendered message, and a trait redeclaring them is a fatal + // "same property ... definition differs" at class-composition time. + public $probeUrl; + public $probeBody; + public $probeHeaders = []; + + protected function _post($url, $body, array $headers) + { + $this->probeUrl = $url; + $this->probeBody = $body; + $this->probeHeaders = $headers; + return '{"ok":true}'; + } +} + +final class ProbeSendGrid extends Tiger_Mail_Transport_SendGrid { use ProbesTheRequest; } +final class ProbePostmark extends Tiger_Mail_Transport_Postmark { use ProbesTheRequest; } +final class ProbeResend extends Tiger_Mail_Transport_Resend { use ProbesTheRequest; } +final class ProbeBrevo extends Tiger_Mail_Transport_Brevo { use ProbesTheRequest; } +final class ProbeMailjet extends Tiger_Mail_Transport_Mailjet { use ProbesTheRequest; } +final class ProbeMailgun extends Tiger_Mail_Transport_Mailgun { use ProbesTheRequest; } + +/** Points the REAL _post() at a closed local port to exercise the cURL failure branch. */ +final class ProbeUnreachable extends Tiger_Mail_Transport_Resend +{ + protected function _endpoint() + { + return 'http://127.0.0.1:1/emails'; + } +} diff --git a/tests/Integration/Mail/MailApiTransportTest.php b/tests/Integration/Mail/MailApiTransportTest.php new file mode 100644 index 00000000..020812b7 --- /dev/null +++ b/tests/Integration/Mail/MailApiTransportTest.php @@ -0,0 +1,174 @@ +Totals: 40=40 & rising — a deliberately long line so quoted-printable would insert a soft break somewhere in the middle of it.

'; + + /** Build a realistic Zend_Mail, as Tiger_Mail::send() would. */ + private function mail(): Zend_Mail + { + $mail = new Zend_Mail('UTF-8'); + $mail->setFrom('no-reply@example.test', 'Tiger Support'); + $mail->addTo('buyer@example.test'); + $mail->setReplyTo('help@example.test'); + $mail->setSubject('Your receipt'); + $mail->setBodyHtml(self::HTML); + $mail->setBodyText('Totals: 40=40 & rising'); + return $mail; + } + + /** Run a driver's protected _payload() against the mail, returning the decoded body. */ + private function payload(Tiger_Mail_Transport_Api $t): array + { + $mail = $this->mail(); + + $prop = new ReflectionProperty(Tiger_Mail_Transport_Api::class, '_mail'); + $prop->setValue($t, $mail); + + $msg = new ReflectionMethod($t, '_message'); + $message = $msg->invoke($t); + + $pay = new ReflectionMethod($t, '_payload'); + $out = $pay->invoke($t, $message); + + if (is_string($out)) { parse_str($out, $parsed); return $parsed; } // Mailgun posts form-encoded + return $out; + } + + #[Test] + public function body_is_never_quoted_printable_encoded(): void + { + $p = $this->payload(new Tiger_Mail_Transport_Resend(['key' => 'k'])); + + $this->assertSame(self::HTML, $p['html'], + 'the HTML must reach the API verbatim — getContent()/getBodyHtml(true) would hand over =3D-encoded soup'); + $this->assertStringNotContainsString('=3D', $p['html'], 'no quoted-printable escapes'); + $this->assertStringNotContainsString("=\r\n", $p['html'], 'no soft line breaks'); + } + + #[Test] + public function the_sender_display_name_survives(): void + { + // Zend_Mail has no getFromName(); the base parses it back out of the formatted From header. + $p = $this->payload(new Tiger_Mail_Transport_SendGrid(['key' => 'k'])); + $this->assertSame('Tiger Support', $p['from']['name'], 'the display name is recovered from the From header'); + $this->assertSame('no-reply@example.test', $p['from']['email']); + } + + #[Test] + public function sendgrid_uses_personalizations_and_typed_content(): void + { + $p = $this->payload(new Tiger_Mail_Transport_SendGrid(['key' => 'k'])); + + $this->assertSame('buyer@example.test', $p['personalizations'][0]['to'][0]['email']); + $this->assertSame('Your receipt', $p['subject']); + $types = array_column($p['content'], 'type'); + $this->assertSame(['text/plain', 'text/html'], $types, 'plain part precedes html, as SendGrid requires'); + $this->assertSame('help@example.test', $p['reply_to']['email']); + } + + #[Test] + public function postmark_uses_pascal_case_and_a_message_stream(): void + { + $p = $this->payload(new Tiger_Mail_Transport_Postmark(['key' => 't'])); + + $this->assertSame('"Tiger Support" ', $p['From']); + $this->assertSame('buyer@example.test', $p['To']); + $this->assertSame('outbound', $p['MessageStream'], 'defaults to the outbound stream'); + $this->assertSame(self::HTML, $p['HtmlBody']); + $this->assertSame('help@example.test', $p['ReplyTo']); + } + + #[Test] + public function mailgun_posts_form_encoded_fields(): void + { + $p = $this->payload(new Tiger_Mail_Transport_Mailgun(['key' => 'k', 'domain' => 'mg.example.test'])); + + $this->assertSame('Tiger Support ', $p['from']); + $this->assertSame('buyer@example.test', $p['to']); + $this->assertSame(self::HTML, $p['html']); + $this->assertSame('help@example.test', $p['h:Reply-To'], 'reply-to rides as a custom header'); + } + + #[Test] + public function mailgun_endpoint_honors_the_region_base(): void + { + $eu = new Tiger_Mail_Transport_Mailgun(['key' => 'k', 'domain' => 'mg.example.test', 'endpoint' => 'https://api.eu.mailgun.net']); + $m = new ReflectionMethod($eu, '_endpoint'); + $this->assertSame('https://api.eu.mailgun.net/v3/mg.example.test/messages', $m->invoke($eu), + 'an EU key must not be sent to the US endpoint'); + + $us = new Tiger_Mail_Transport_Mailgun(['key' => 'k', 'domain' => 'mg.example.test']); + $this->assertStringStartsWith('https://api.mailgun.net/', $m->invoke($us), 'US is the default base'); + } + + #[Test] + public function brevo_and_mailjet_use_their_own_envelopes(): void + { + $b = $this->payload(new Tiger_Mail_Transport_Brevo(['key' => 'k'])); + $this->assertSame('no-reply@example.test', $b['sender']['email']); + $this->assertSame('buyer@example.test', $b['to'][0]['email']); + $this->assertSame(self::HTML, $b['htmlContent']); + + $j = $this->payload(new Tiger_Mail_Transport_Mailjet(['key' => 'k', 'secret' => 's'])); + $this->assertArrayHasKey('Messages', $j, 'Mailjet wraps in a Messages array'); + $this->assertSame('no-reply@example.test', $j['Messages'][0]['From']['Email']); + $this->assertSame(self::HTML, $j['Messages'][0]['HTMLPart']); + } + + #[Test] + public function each_driver_sends_its_credential_in_the_right_header(): void + { + $headers = static function (Tiger_Mail_Transport_Api $t): string { + $m = new ReflectionMethod($t, '_headers'); + return implode("\n", $m->invoke($t)); + }; + + $this->assertStringContainsString('Authorization: Bearer SG.key', $headers(new Tiger_Mail_Transport_SendGrid(['key' => 'SG.key']))); + $this->assertStringContainsString('X-Postmark-Server-Token: tok', $headers(new Tiger_Mail_Transport_Postmark(['key' => 'tok']))); + $this->assertStringContainsString('api-key: xkeysib', $headers(new Tiger_Mail_Transport_Brevo(['key' => 'xkeysib']))); + $this->assertStringContainsString('Authorization: Basic ' . base64_encode('api:k'), $headers(new Tiger_Mail_Transport_Mailgun(['key' => 'k'])), + 'Mailgun authenticates as the literal user "api"'); + $this->assertStringContainsString('Authorization: Basic ' . base64_encode('k:s'), $headers(new Tiger_Mail_Transport_Mailjet(['key' => 'k', 'secret' => 's'])), + 'Mailjet is the one provider needing a key AND a secret'); + } +} diff --git a/tests/Integration/Mail/MailProviderTest.php b/tests/Integration/Mail/MailProviderTest.php new file mode 100644 index 00000000..115b4b26 --- /dev/null +++ b/tests/Integration/Mail/MailProviderTest.php @@ -0,0 +1,194 @@ + [], 'tiger' => []], true)); + } + + #[Test] + public function every_api_provider_declares_a_driver_that_exists(): void + { + $api = 0; + foreach (Tiger_Mail_Provider::all() as $key => $def) { + if ($def['kind'] !== Tiger_Mail_Provider::KIND_API) { continue; } + $api++; + $this->assertArrayHasKey('transport', $def, "$key declares a transport class"); + $this->assertTrue(class_exists($def['transport']), "$key's driver {$def['transport']} is loadable"); + $this->assertTrue( + is_subclass_of($def['transport'], Tiger_Mail_Transport_Api::class), + "$key's driver extends the API transport base" + ); + } + $this->assertGreaterThanOrEqual(7, $api, 'the catalog ships the API drivers'); + } + + #[Test] + public function every_smtp_provider_declares_usable_connection_defaults(): void + { + foreach (Tiger_Mail_Provider::all() as $key => $def) { + if ($def['kind'] !== Tiger_Mail_Provider::KIND_SMTP) { continue; } + $this->assertArrayHasKey('smtp', $def, "$key declares smtp defaults"); + $ssl = (string) $def['smtp']['ssl']; + $this->assertContains($ssl, ['tls', 'ssl', ''], "$key's encryption is a value the transport accepts"); + } + } + + #[Test] + public function a_placeholder_host_is_interpolated_from_the_providers_own_fields(): void + { + $smtp = Tiger_Mail_Provider::smtpFor('ses-smtp', ['region' => 'eu-west-1']); + $this->assertSame('email-smtp.eu-west-1.amazonaws.com', $smtp['host'], 'the region is substituted into the host'); + $this->assertSame('587', $smtp['port']); + $this->assertSame('tls', $smtp['ssl']); + } + + #[Test] + public function an_unfilled_placeholder_yields_no_host_rather_than_a_broken_one(): void + { + $smtp = Tiger_Mail_Provider::smtpFor('ses-smtp', []); // region not supplied yet + $this->assertSame('', $smtp['host'], + 'a literal "{region}" host would be a baffling DNS failure — an empty host degrades to sendmail instead'); + } + + #[Test] + public function ses_api_is_unavailable_without_the_aws_sdk(): void + { + // The SDK module is optional and is NOT installed in the test environment. + $expected = class_exists('Aws\\SesV2\\SesV2Client'); + $this->assertSame($expected, Tiger_Mail_Provider::isAvailable('ses-api'), + 'availability is capability-detected against the SDK, never assumed'); + + // A keyless provider is always available. + $this->assertTrue(Tiger_Mail_Provider::isAvailable('sendgrid-api'), 'a plain HTTPS driver needs no SDK'); + $this->assertTrue(Tiger_Mail_Provider::isAvailable('custom'), 'SMTP providers need no driver at all'); + $this->assertFalse(Tiger_Mail_Provider::isAvailable('nope'), 'an unknown provider is not available'); + } + + #[Test] + public function choosing_an_api_provider_builds_its_driver(): void + { + $t = Tiger_Mail::apiTransport('postmark-api', ['key' => 'token']); + $this->assertInstanceOf('Tiger_Mail_Transport_Postmark', $t); + + $this->assertNull(Tiger_Mail::apiTransport('custom', []), 'an SMTP provider has no API driver'); + $this->assertNull(Tiger_Mail::apiTransport('nope', []), 'an unknown provider has no driver'); + } + + #[Test] + public function an_api_provider_round_trips_through_the_config_tier(): void + { + Tiger_Mail::saveSettings([ + 'provider' => 'resend-api', + 'fields' => ['key' => 're_test_secret'], + 'from_email' => 'no-reply@example.test', + ]); + + $cfg = new Tiger_Model_Config(); + $g = Tiger_Model_Config::SCOPE_GLOBAL; + $this->assertSame('api', $cfg->get($g, '', 'mail.transport'), 'an API provider switches the transport kind'); + $this->assertSame('resend-api', $cfg->get($g, '', 'mail.provider')); + $this->assertSame('no-reply@example.test', $cfg->get($g, '', 'mail.from.email'), + 'the From identity still applies to an API send'); + } + + #[Test] + public function stored_credentials_are_read_from_the_resolved_cascade(): void + { + // Deliberately seeded into the registry rather than read back after a save: the config tier + // is "effective NEXT request" by design (Tiger_Model_Config::set does not refresh the + // in-memory cascade — Tiger_Recaptcha reads the same way), so this mirrors how a live + // request actually sees stored credentials. + Zend_Registry::set('Zend_Config', new Zend_Config([ + 'mail' => ['api' => ['mailgun-api' => ['domain' => 'mg.example.test', 'key' => 'key-plain', 'endpoint' => '']]], + 'tiger' => [], + ], true)); + + $creds = Tiger_Mail::apiCredentials('mailgun-api'); + $this->assertSame('mg.example.test', $creds['domain'], 'non-secret fields resolve'); + $this->assertSame('key-plain', $creds['key'], 'and the driver gets the key it needs to authenticate'); + $this->assertSame([], Tiger_Mail::apiCredentials(''), 'no provider means no credentials'); + } + + #[Test] + public function an_api_secret_is_encrypted_at_rest_when_crypto_is_configured(): void + { + // With no crypto key the writer documents a plaintext fallback — assert BOTH halves, since + // "it was encrypted" is only meaningful next to "and it isn't when it can't be". + Tiger_Mail::saveApiCredentials('brevo-api', ['key' => 'plain-fallback']); + $cfg = new Tiger_Model_Config(); + $g = Tiger_Model_Config::SCOPE_GLOBAL; + $this->assertSame('plain-fallback', (string) $cfg->get($g, '', 'mail.api.brevo-api.key'), + 'with no crypto configured the key is stored as-is rather than being silently dropped'); + + // Now configure crypto and re-save: the value must land in the _enc key, not the plain one. + Zend_Registry::set('Zend_Config', new Zend_Config([ + 'mail' => [], + 'tiger' => ['crypto' => ['key' => 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAo=']], + ], true)); + Tiger_Mail::saveApiCredentials('brevo-api', ['key' => 'secret-key-value']); + + $this->assertSame('', (string) $cfg->get($g, '', 'mail.api.brevo-api.key'), + 'the plaintext row is cleared so the secret never lingers in two places'); + $enc = (string) $cfg->get($g, '', 'mail.api.brevo-api.key_enc'); + $this->assertNotSame('', $enc, 'the encrypted row is written'); + $this->assertStringNotContainsString('secret-key-value', $enc, 'and it is genuinely encrypted, not encoded'); + } + + #[Test] + public function a_blank_api_secret_keeps_the_stored_one(): void + { + Tiger_Mail::saveApiCredentials('sendgrid-api', ['key' => 'SG.first']); + $cfg = new Tiger_Model_Config(); + $g = Tiger_Model_Config::SCOPE_GLOBAL; + $first = (string) ($cfg->get($g, '', 'mail.api.sendgrid-api.key_enc') ?: $cfg->get($g, '', 'mail.api.sendgrid-api.key')); + $this->assertNotSame('', $first, 'precondition: a key is stored'); + + Tiger_Mail::saveApiCredentials('sendgrid-api', ['key' => '']); // edited another field, left the key blank + + $now = (string) ($cfg->get($g, '', 'mail.api.sendgrid-api.key_enc') ?: $cfg->get($g, '', 'mail.api.sendgrid-api.key')); + $this->assertSame($first, $now, 'a blank secret must never wipe the stored API key'); + } + + #[Test] + public function an_unusable_api_provider_degrades_to_sendmail_instead_of_fataling(): void + { + // transport=api but the provider is unknown → the driver can't be built. + Zend_Registry::set('Zend_Config', new Zend_Config([ + 'mail' => ['transport' => 'api', 'provider' => 'not-a-provider'], + 'tiger' => [], + ], true)); + + $this->assertInstanceOf(Zend_Mail_Transport_Sendmail::class, (new Tiger_Mail())->transport(), + 'a broken provider (e.g. its SDK was deactivated) must not fatal every request that sends mail'); + } +} diff --git a/tests/Integration/Mail/MailSettingsTest.php b/tests/Integration/Mail/MailSettingsTest.php new file mode 100644 index 00000000..cf4203c0 --- /dev/null +++ b/tests/Integration/Mail/MailSettingsTest.php @@ -0,0 +1,150 @@ +seedConfig([]); + } + + /** Put a resolved config into the registry, the way the booted app would. */ + private function seedConfig(array $mail, ?string $cryptoKey = null): void + { + $tiger = []; + if ($cryptoKey !== null) { $tiger['crypto'] = ['key' => $cryptoKey]; } + Zend_Registry::set('Zend_Config', new Zend_Config(['mail' => $mail, 'tiger' => $tiger], true)); + } + + #[Test] + public function transport_falls_back_to_sendmail_without_smtp(): void + { + $this->seedConfig(['transport' => 'mail']); + $this->assertInstanceOf(Zend_Mail_Transport_Sendmail::class, (new Tiger_Mail())->transport(), + 'the default transport is PHP mail()'); + + // smtp selected but no host is not a usable SMTP config — fall back rather than fatal. + $this->seedConfig(['transport' => 'smtp', 'smtp' => ['host' => '']]); + $this->assertInstanceOf(Zend_Mail_Transport_Sendmail::class, (new Tiger_Mail())->transport(), + 'smtp with no host degrades to sendmail instead of erroring'); + } + + #[Test] + public function a_legacy_plaintext_password_still_builds_an_smtp_transport(): void + { + $this->seedConfig(['transport' => 'smtp', 'smtp' => [ + 'host' => 'smtp.example.test', 'port' => 587, 'ssl' => 'tls', 'auth' => 'login', + 'username' => 'user', 'password' => 'legacy-secret', + ]]); + $this->assertInstanceOf(Zend_Mail_Transport_Smtp::class, (new Tiger_Mail())->transport(), + 'an install predating the admin screen keeps working on its plaintext password'); + } + + #[Test] + public function an_encrypted_password_is_decrypted_and_wins_over_the_plaintext_key(): void + { + if (!Tiger_Crypto::isConfigured()) { + $this->seedConfig([], self::KEY); + } + $this->seedConfig(['transport' => 'smtp', 'smtp' => [ + 'host' => 'smtp.example.test', 'auth' => 'login', 'username' => 'u', + 'password' => 'stale-plaintext', + 'password_enc' => Tiger_Crypto::encrypt('the-real-secret'), + ]], self::KEY); + + $this->assertSame('the-real-secret', Tiger_Mail::storedSmtpPassword(), + 'the encrypted key is decrypted and takes precedence over any lingering plaintext'); + } + + #[Test] + public function settings_never_exposes_the_password(): void + { + $this->seedConfig(['transport' => 'smtp', 'smtp' => [ + 'host' => 'smtp.example.test', 'port' => 587, 'username' => 'u', 'password' => 'super-secret', + ]]); + + $s = Tiger_Mail::settings(); + $this->assertTrue($s['has_password'], 'the screen is told a password EXISTS'); + $this->assertArrayNotHasKey('password', $s, 'but the value itself is never returned'); + $this->assertNotContains('super-secret', $s, 'and it appears nowhere in the payload'); + $this->assertSame('smtp.example.test', $s['host'], 'the non-secret fields still round-trip'); + } + + #[Test] + public function saving_persists_to_the_config_tier_and_normalizes_port_and_protocol(): void + { + Tiger_Mail::saveSettings([ + 'transport' => 'smtp', 'host' => 'smtp.example.test', + 'port' => '99999', // out of range -> falls back to 587 + 'ssl' => 'BOGUS', // not tls/ssl -> stored as none + 'auth' => 'login', 'username' => 'u', 'from_email' => 'no-reply@example.test', + ]); + + $cfg = new Tiger_Model_Config(); + $g = Tiger_Model_Config::SCOPE_GLOBAL; + $this->assertSame('smtp', $cfg->get($g, '', 'mail.transport')); + $this->assertSame('smtp.example.test', $cfg->get($g, '', 'mail.smtp.host')); + $this->assertSame('587', $cfg->get($g, '', 'mail.smtp.port'), 'an out-of-range port is normalized, not stored'); + $this->assertSame('', $cfg->get($g, '', 'mail.smtp.ssl'), 'an unknown protocol is refused rather than passed to the transport'); + } + + #[Test] + public function a_blank_password_keeps_the_stored_one(): void + { + Tiger_Mail::saveSettings(['transport' => 'smtp', 'host' => 'a.example.test', 'password' => 'first-secret']); + $cfg = new Tiger_Model_Config(); + $g = Tiger_Model_Config::SCOPE_GLOBAL; + $after = $cfg->get($g, '', 'mail.smtp.password_enc') ?: $cfg->get($g, '', 'mail.smtp.password'); + $this->assertNotSame('', (string) $after, 'precondition: a secret is stored'); + + // Edit the host only — the password field comes back blank from the form. + Tiger_Mail::saveSettings(['transport' => 'smtp', 'host' => 'b.example.test', 'password' => '']); + + $now = $cfg->get($g, '', 'mail.smtp.password_enc') ?: $cfg->get($g, '', 'mail.smtp.password'); + $this->assertSame((string) $after, (string) $now, 'editing the host must not wipe the stored password'); + $this->assertSame('b.example.test', $cfg->get($g, '', 'mail.smtp.host'), 'and the host did change'); + } + + #[Test] + public function transport_for_builds_from_explicit_values_so_a_test_needs_no_save(): void + { + $smtp = Tiger_Mail::transportFor([ + 'transport' => 'smtp', 'host' => 'smtp.example.test', 'port' => 2525, + 'ssl' => 'tls', 'auth' => 'login', 'username' => 'u', 'password' => 'p', + ]); + $this->assertInstanceOf(Zend_Mail_Transport_Smtp::class, $smtp, + 'the admin "Send test" can build a transport from unsaved form values'); + + $this->assertInstanceOf(Zend_Mail_Transport_Sendmail::class, + Tiger_Mail::transportFor(['transport' => 'mail']), + 'and honors the sendmail transport the same way'); + } +} diff --git a/tests/Integration/System/SettingsFormTest.php b/tests/Integration/System/SettingsFormTest.php new file mode 100644 index 00000000..b1b8da00 --- /dev/null +++ b/tests/Integration/System/SettingsFormTest.php @@ -0,0 +1,114 @@ +form = new System_Form_Settings(); + } + + #[Test] + public function the_form_builds_and_declares_every_screen_field(): void + { + foreach ([ + 'session_ttl', 'session_ttl_guest', 'session_ttl_privileged', + 'autologout_enabled', 'autologout_seconds', 'autologout_action', + 'mail_provider', 'mail_smtp_host', 'mail_smtp_port', 'mail_smtp_ssl', 'mail_smtp_auth', + 'mail_smtp_username', 'mail_smtp_password', 'mail_from_email', 'mail_from_name', 'mail_test_to', + 'recaptcha_enabled', 'recaptcha_version', 'recaptcha_site_key', 'recaptcha_secret_key', + ] as $name) { + $this->assertNotNull($this->form->getElement($name), "the form declares $name"); + } + } + + #[Test] + public function the_provider_dropdown_matches_the_live_catalog(): void + { + $options = $this->form->getElement('mail_provider')->getMultiOptions(); + + $this->assertSame(array_keys(Tiger_Mail_Provider::options()), array_keys($options), + 'the dropdown is generated from the catalog, so the two can never drift apart'); + $this->assertArrayHasKey('ses-api', $options, 'the API providers are offered'); + $this->assertArrayHasKey('ses-smtp', $options, 'alongside their SMTP counterparts'); + } + + #[Test] + public function the_smtp_password_field_is_a_password_input(): void + { + // A text input would render the stored secret into the page source. + $this->assertSame('password', strtolower($this->form->getElement('mail_smtp_password')->getType() === 'Zend_Form_Element_Password' ? 'password' : 'other'), + 'the SMTP password is never a plain text field'); + $this->assertSame('Zend_Form_Element_Password', get_class($this->form->getElement('recaptcha_secret_key')), + 'and neither is the reCAPTCHA secret'); + } + + #[Test] + public function an_out_of_range_port_is_rejected(): void + { + $port = $this->form->getElement('mail_smtp_port'); + + $this->assertTrue($port->isValid('587'), 'a normal submission port passes'); + $this->assertTrue($port->isValid('465'), 'so does implicit-SSL'); + $this->assertFalse($port->isValid('99999'), 'a port above the TCP range is refused at the form'); + $this->assertFalse($port->isValid('smtp'), 'and a non-numeric port never reaches the transport'); + } + + #[Test] + public function the_from_and_test_addresses_must_be_real_email_addresses(): void + { + $from = $this->form->getElement('mail_from_email'); + + $this->assertTrue($from->isValid('no-reply@example.com'), 'an ordinary address passes'); + $this->assertFalse($from->isValid('not-an-address'), + 'a malformed From address is caught before it reaches the transport'); + $this->assertFalse($this->form->getElement('mail_test_to')->isValid('nope'), + 'and the test recipient is validated the same way'); + } + + #[Test] + public function a_local_hostname_sender_is_allowed(): void + { + // core.ini ships `mail.from.email = no-reply@localhost`, and an intranet install genuinely + // sends from a local hostname — DNS-only validation would reject the platform's own default. + $this->assertTrue($this->form->getElement('mail_from_email')->isValid('no-reply@localhost'), + 'the shipped default From address must be saveable'); + } + + #[Test] + public function optional_mail_fields_stay_optional(): void + { + // The whole SMTP block is irrelevant to the sendmail transport, so nothing here may be + // required — otherwise saving an unrelated tab (reCAPTCHA, cookies) would fail validation. + foreach (['mail_smtp_host', 'mail_smtp_port', 'mail_smtp_username', 'mail_smtp_password', + 'mail_from_email', 'mail_from_name', 'mail_test_to'] as $name) { + $this->assertFalse($this->form->getElement($name)->isRequired(), "$name is optional"); + $this->assertTrue($this->form->getElement($name)->isValid(''), "$name accepts empty"); + } + } +}