-
Notifications
You must be signed in to change notification settings - Fork 1
Optional Amazon SES mailer + trim AWS SDK + docs #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| # Transactional email via Amazon SES (optional) | ||
|
|
||
| [Français](../fr/email-ses.md) · **English** | ||
|
|
||
| SPIP's native mail uses PHP `mail()`/sendmail, which does **not** work on Lambda/Bref | ||
| (no local MTA). The `ses_mail` plugin routes SPIP's transactional emails (password reset, | ||
| notifications, forms…) through **Amazon SES** instead. | ||
|
|
||
| It is **optional and self-disabling**: with no SES configuration the plugin falls back to | ||
| native `mail()`, so nothing changes until you opt in. | ||
|
|
||
| ## How it works | ||
|
|
||
| - `spip/plugins/ses_mail/inc/envoyer_mail.php` overrides SPIP's `inc_envoyer_mail` (the | ||
| standard SPIP function-surcharge mechanism), so every `envoyer_mail(...)` call goes | ||
| through it transparently. | ||
| - If `SES_FROM` (and `SES_REGION`) are set → it calls `SES:SendEmail` via the AWS SDK | ||
| (already bundled). Otherwise → native `mail()` fallback. | ||
| - `From` is `SES_FROM` (which must be a verified SES identity); a caller-supplied From is | ||
| passed as `Reply-To`. Text + HTML bodies, `cc`/`bcc`, and the site charset are honoured. | ||
| - Outcomes are logged to the `ses` SPIP log channel (→ CloudWatch via `logs_stderr`). | ||
|
|
||
| ## Enable it | ||
|
|
||
| 1. **Verify an SES identity** (a domain or a single email) in the SES region for this | ||
| account. New SES accounts are in the **sandbox**: you can only send to *verified* | ||
| recipients until you request production access. | ||
| 2. Set the From address in `iac/spip/app/var/<env>/values.tfvars`: | ||
| ```hcl | ||
| ses_from_email = "My Site <noreply@example.com>" | ||
| ``` | ||
| This is all it takes — Terraform then: | ||
| - attaches the `ses:SendEmail` / `ses:SendRawEmail` IAM policy to the Lambda role | ||
| (`aws_iam_role_policy.ses_send`, created only when `ses_from_email != ""`), and | ||
| - injects `SES_FROM` + `SES_REGION` (= the stack region) into the Lambda env. | ||
| 3. `make deploy ENV=<env>` (or push to `main` for the CI). SPIP mail now goes via SES. | ||
|
|
||
| ## Region | ||
|
|
||
| SES is not available in every region. `SES_REGION` defaults to the stack region | ||
| (`AWS_REGION`); if SES is not offered there, verify your identity in a supported region | ||
| and set `SES_REGION` accordingly (a small stack tweak). | ||
|
|
||
| ## Disable it | ||
|
|
||
| Leave `ses_from_email` empty (the default): the plugin stays inert and falls back to | ||
| native `mail()` (which is a no-op on Lambda — i.e. no transactional email). To remove the | ||
| code path entirely, drop the `COPY spip/plugins/ses_mail/ …` line from `spip/Dockerfile`. | ||
|
|
||
| ## Sandbox → production | ||
|
|
||
| To send to arbitrary recipients, request SES production access for the account/region | ||
| (AWS console → SES → Account dashboard). Until then, only verified identities receive mail. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| # Emails transactionnels via Amazon SES (optionnel) | ||
|
|
||
| **Français** · [English](../en/email-ses.md) | ||
|
|
||
| Le mail natif de SPIP utilise `mail()`/sendmail de PHP, qui **ne fonctionne pas** sur | ||
| Lambda/Bref (pas de MTA local). Le plugin `ses_mail` route les emails transactionnels de | ||
| SPIP (réinitialisation de mot de passe, notifications, formulaires…) via **Amazon SES**. | ||
|
|
||
| Il est **optionnel et auto-désactivé** : sans configuration SES, le plugin retombe sur | ||
| `mail()` natif — rien ne change tant que tu ne l'actives pas. | ||
|
|
||
| ## Comment ça marche | ||
|
|
||
| - `spip/plugins/ses_mail/inc/envoyer_mail.php` surcharge `inc_envoyer_mail` (le mécanisme | ||
| standard de surcharge de fonction SPIP) : chaque appel `envoyer_mail(...)` y passe | ||
| de façon transparente. | ||
| - Si `SES_FROM` (et `SES_REGION`) sont définis → appel `SES:SendEmail` via le SDK AWS | ||
| (déjà embarqué). Sinon → repli sur `mail()` natif. | ||
| - Le `From` est `SES_FROM` (qui doit être une identité SES vérifiée) ; un From fourni par | ||
| l'appelant devient le `Reply-To`. Corps texte + HTML, `cc`/`bcc` et le charset du site | ||
| sont pris en compte. | ||
| - Les résultats sont journalisés sur le canal SPIP `ses` (→ CloudWatch via `logs_stderr`). | ||
|
|
||
| ## Activer | ||
|
|
||
| 1. **Vérifie une identité SES** (un domaine ou une adresse) dans la région SES de ce | ||
| compte. Un compte SES neuf est en **sandbox** : tu ne peux envoyer qu'à des | ||
| destinataires *vérifiés* tant que tu n'as pas demandé l'accès production. | ||
| 2. Renseigne l'adresse d'expéditeur dans `iac/spip/app/var/<env>/values.tfvars` : | ||
| ```hcl | ||
| ses_from_email = "Mon site <noreply@example.com>" | ||
| ``` | ||
| C'est tout — Terraform : | ||
| - attache la policy IAM `ses:SendEmail` / `ses:SendRawEmail` au rôle du Lambda | ||
| (`aws_iam_role_policy.ses_send`, créée seulement si `ses_from_email != ""`), et | ||
| - injecte `SES_FROM` + `SES_REGION` (= la région de la stack) dans l'env du Lambda. | ||
| 3. `make deploy ENV=<env>` (ou push sur `main` pour la CI). Le mail SPIP passe alors par SES. | ||
|
|
||
| ## Région | ||
|
|
||
| SES n'est pas disponible dans toutes les régions. `SES_REGION` vaut par défaut la région | ||
| de la stack (`AWS_REGION`) ; si SES n'y est pas proposé, vérifie ton identité dans une | ||
| région supportée et positionne `SES_REGION` en conséquence (petit ajustement de stack). | ||
|
|
||
| ## Désactiver | ||
|
|
||
| Laisse `ses_from_email` vide (défaut) : le plugin reste inerte et retombe sur `mail()` | ||
| natif (qui est un no-op sur Lambda — donc pas d'email transactionnel). Pour retirer | ||
| complètement le chemin de code, supprime la ligne `COPY spip/plugins/ses_mail/ …` de | ||
| `spip/Dockerfile`. | ||
|
|
||
| ## Sandbox → production | ||
|
|
||
| Pour envoyer à n'importe quel destinataire, demande l'accès production SES pour le | ||
| compte/la région (console AWS → SES → Tableau de bord du compte). Avant ça, seules les | ||
| identités vérifiées reçoivent les emails. |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,142 @@ | ||||||||||||||
| <?php | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * OPTIONAL — route SPIP transactional emails through Amazon SES. | ||||||||||||||
| * | ||||||||||||||
| * SPIP's native inc_envoyer_mail_dist() relies on PHP mail()/sendmail, which does not | ||||||||||||||
| * work on Lambda/Bref (no local MTA). This override sends via the SES API (AWS SDK, | ||||||||||||||
| * already bundled) using a verified identity. | ||||||||||||||
| * | ||||||||||||||
| * It is OPTIONAL and self-disabling: if SES_FROM (or SES_REGION) is not set it falls back | ||||||||||||||
| * to SPIP's native mail(), so nothing changes unless you opt in. Enable it by setting | ||||||||||||||
| * `ses_from_email` in your app stack tfvars (that also attaches the ses:SendEmail IAM | ||||||||||||||
| * policy and injects SES_FROM/SES_REGION into the Lambda). | ||||||||||||||
| * | ||||||||||||||
| * Env (Lambda, set by Terraform when ses_from_email is provided): | ||||||||||||||
| * - SES_FROM : e.g. "My Site <noreply@example.com>" (default From; a verified identity) | ||||||||||||||
| * - SES_REGION : SES region for this account/env (defaults to AWS_REGION) | ||||||||||||||
| * | ||||||||||||||
| * The function signature mirrors inc_envoyer_mail_dist so SPIP calls it transparently. | ||||||||||||||
| */ | ||||||||||||||
|
|
||||||||||||||
| if (!defined('_ECRIRE_INC_VERSION')) { | ||||||||||||||
| return; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function inc_envoyer_mail($destinataire, $sujet, $corps, $from = '', $headers = '') { | ||||||||||||||
| $ses_from = getenv('SES_FROM') ?: ''; | ||||||||||||||
| $ses_region = getenv('SES_REGION') ?: (getenv('AWS_REGION') ?: ''); | ||||||||||||||
|
|
||||||||||||||
| // SES not configured → native behavior (opt-in feature stays off). | ||||||||||||||
| if (!$ses_from || !$ses_region) { | ||||||||||||||
| return sesmail_envoyer_natif($destinataire, $sujet, $corps, $from, $headers); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (!sesmail_email_valide($destinataire)) { | ||||||||||||||
| return false; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // SPIP passes either a plain-text string or an array with | ||||||||||||||
| // 'texte', optional 'html', 'from', 'headers', 'cc', 'bcc'. | ||||||||||||||
| $texte = ''; | ||||||||||||||
| $html = ''; | ||||||||||||||
| $cc = []; | ||||||||||||||
| $bcc = []; | ||||||||||||||
| if (is_array($corps)) { | ||||||||||||||
| $texte = $corps['texte'] ?? ''; | ||||||||||||||
| $html = $corps['html'] ?? ''; | ||||||||||||||
| $from = $corps['from'] ?? $from; | ||||||||||||||
| if (!empty($corps['cc'])) { | ||||||||||||||
| $cc = is_array($corps['cc']) ? $corps['cc'] : [$corps['cc']]; | ||||||||||||||
| } | ||||||||||||||
| if (!empty($corps['bcc'])) { | ||||||||||||||
| $bcc = is_array($corps['bcc']) ? $corps['bcc'] : [$corps['bcc']]; | ||||||||||||||
| } | ||||||||||||||
| } else { | ||||||||||||||
| $texte = (string) $corps; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // SES requires the From to be (or belong to) the verified identity, so we keep | ||||||||||||||
| // SES_FROM as the envelope From and expose a requested address as Reply-To. | ||||||||||||||
| $source = $ses_from; | ||||||||||||||
| $reply_to = []; | ||||||||||||||
| if ($from && stripos($from, '@') !== false) { | ||||||||||||||
| $reply_to[] = $from; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| $charset = $GLOBALS['meta']['charset'] ?? 'utf-8'; | ||||||||||||||
|
|
||||||||||||||
| $body = []; | ||||||||||||||
| if ($texte !== '') { | ||||||||||||||
| $body['Text'] = ['Data' => $texte, 'Charset' => $charset]; | ||||||||||||||
| } | ||||||||||||||
| if ($html !== '') { | ||||||||||||||
| $body['Html'] = ['Data' => $html, 'Charset' => $charset]; | ||||||||||||||
| } | ||||||||||||||
| if (!$body) { | ||||||||||||||
| return false; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| try { | ||||||||||||||
| require_once '/var/task/vendor/autoload.php'; | ||||||||||||||
| $client = new \Aws\Ses\SesClient([ | ||||||||||||||
| 'version' => 'latest', | ||||||||||||||
| 'region' => $ses_region, | ||||||||||||||
| ]); | ||||||||||||||
|
|
||||||||||||||
| $destination = ['ToAddresses' => [$destinataire]]; | ||||||||||||||
| if ($cc) { | ||||||||||||||
| $destination['CcAddresses'] = $cc; | ||||||||||||||
| } | ||||||||||||||
| if ($bcc) { | ||||||||||||||
| $destination['BccAddresses'] = $bcc; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| $args = [ | ||||||||||||||
| 'Source' => $source, | ||||||||||||||
| 'Destination' => $destination, | ||||||||||||||
| 'Message' => [ | ||||||||||||||
| 'Subject' => ['Data' => $sujet, 'Charset' => $charset], | ||||||||||||||
| 'Body' => $body, | ||||||||||||||
| ], | ||||||||||||||
| ]; | ||||||||||||||
| if ($reply_to) { | ||||||||||||||
| $args['ReplyToAddresses'] = $reply_to; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| $res = $client->sendEmail($args); | ||||||||||||||
| spip_log('SES sendEmail OK to ' . $destinataire . ' — MessageId=' . ($res['MessageId'] ?? '?'), 'ses'); | ||||||||||||||
| return true; | ||||||||||||||
| } catch (\Throwable $e) { | ||||||||||||||
| spip_log('SES sendEmail FAILED to ' . $destinataire . ' : ' . $e->getMessage(), 'ses' . _LOG_ERREUR); | ||||||||||||||
| return false; | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Fallback via PHP mail() when SES is not configured (local dev with an MTA). | ||||||||||||||
| */ | ||||||||||||||
| function sesmail_envoyer_natif($destinataire, $sujet, $corps, $from = '', $headers = '') { | ||||||||||||||
| if (!sesmail_email_valide($destinataire)) { | ||||||||||||||
| return false; | ||||||||||||||
| } | ||||||||||||||
| $texte = is_array($corps) ? ($corps['texte'] ?? '') : (string) $corps; | ||||||||||||||
| $hdr = ''; | ||||||||||||||
| if ($from) { | ||||||||||||||
| $hdr .= 'From: ' . $from . "\r\n"; | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛑 Security Vulnerability: Email header injection risk. The
Suggested change
Footnotes
|
||||||||||||||
| } | ||||||||||||||
| if ($headers) { | ||||||||||||||
| $hdr .= is_array($headers) ? implode("\r\n", $headers) : $headers; | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+128
to
+130
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛑 Security Vulnerability: Email header injection risk via
Suggested change
Footnotes
|
||||||||||||||
| return @mail($destinataire, $sujet, $texte, $hdr); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Email validation that doesn't depend on SPIP's inc/filtres being loaded here. | ||||||||||||||
| */ | ||||||||||||||
| function sesmail_email_valide($email) { | ||||||||||||||
| if (function_exists('email_valide')) { | ||||||||||||||
| return email_valide($email); | ||||||||||||||
| } | ||||||||||||||
| return (bool) filter_var((string) $email, FILTER_VALIDATE_EMAIL); | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <paquet | ||
| prefix="sesmail" | ||
| categorie="communication" | ||
| version="1.0.0" | ||
| etat="stable" | ||
| compatibilite="[4.0.0;4.*]" | ||
| logo="" | ||
| > | ||
| <nom>Amazon SES mail</nom> | ||
| <auteur>spip-serverless</auteur> | ||
| <licence>MIT</licence> | ||
| <description> | ||
| Route les emails transactionnels de SPIP via Amazon SES (mail() ne fonctionne pas | ||
| sur Lambda/Bref). OPTIONNEL : inactif tant que SES_FROM n'est pas défini — retombe | ||
| alors sur le mail natif. Fournit la surcharge inc/envoyer_mail.php. | ||
| </description> | ||
| </paquet> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛑 Logic Error: Potential memory leak from repeated autoload. The
require_onceis inside a function that may be called multiple times during a request. Each call will evaluate the file path check. Move autoloader inclusion outside the function or cache the client instance to avoid repeated filesystem checks and potential performance degradation.