diff --git a/README.en.md b/README.en.md index eb1340d..9ff9b51 100644 --- a/README.en.md +++ b/README.en.md @@ -96,6 +96,7 @@ make run-local # SPIP on http://localhost:8080 (Apache) | Add a plugin (third-party or custom) | [docs/plugins.md](docs/en/plugins.md) | | Upgrade SPIP core | [docs/spip-upgrade.md](docs/en/spip-upgrade.md) | | Build the image / Composer deps / shrink-vendor | [docs/build.md](docs/en/build.md) | +| Transactional email via Amazon SES (optional) | [docs/email-ses.md](docs/en/email-ses.md) | | Add / configure an environment | [docs/environments.md](docs/en/environments.md) | | Initialise the database + admin | [docs/db-bootstrap.md](docs/en/db-bootstrap.md) | | Reset an admin password | [docs/spip-passwords.md](docs/en/spip-passwords.md) | diff --git a/README.md b/README.md index d8b8058..afc4f2c 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ make run-local # SPIP sur http://localhost:8080 (Apache) | Ajouter un plugin (tiers ou maison) | [docs/plugins.md](docs/fr/plugins.md) | | Mettre à jour le cœur SPIP | [docs/spip-upgrade.md](docs/fr/spip-upgrade.md) | | Build de l'image / dépendances Composer / shrink-vendor | [docs/build.md](docs/fr/build.md) | +| Emails transactionnels via Amazon SES (optionnel) | [docs/email-ses.md](docs/fr/email-ses.md) | | Ajouter / configurer un environnement | [docs/environments.md](docs/fr/environments.md) | | Initialiser la base + l'admin | [docs/db-bootstrap.md](docs/fr/db-bootstrap.md) | | Réinitialiser un mot de passe admin | [docs/spip-passwords.md](docs/fr/spip-passwords.md) | diff --git a/docs/en/build.md b/docs/en/build.md index f770754..32b5e5a 100644 --- a/docs/en/build.md +++ b/docs/en/build.md @@ -45,10 +45,9 @@ and cheaper storage. The script: `LICENSE*`, `README*`, `tests/`, `docs/`, `examples/`). - Prints the saved size (`AWS SDK: MB → MB`). -> Note: the current `KEEP_SERVICES` allow-list still contains a few services inherited from -> the original application (`bedrock-*`, `translate`) that the bare platform does not use. -> Trim them to `dsql, dynamodb, s3, ssm, sts, cloudfront, ses` for a leaner image, and add -> back whatever your own plugins need. +> `KEEP_SERVICES` is trimmed to what the platform actually uses: `dsql`, `dynamodb`, `s3`, +> `ssm`, `sts` (credential chain) and `ses`/`email` (optional mail — see +> [email-ses.md](email-ses.md)). Add whatever service your own plugins need. To add an SDK service your plugin needs: add it to `KEEP_SERVICES` (and to the `src/` `case` allow-list) in `spip/scripts/shrink-vendor.sh`, then rebuild. diff --git a/docs/en/email-ses.md b/docs/en/email-ses.md new file mode 100644 index 0000000..2887033 --- /dev/null +++ b/docs/en/email-ses.md @@ -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//values.tfvars`: + ```hcl + ses_from_email = "My Site " + ``` + 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=` (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. diff --git a/docs/fr/build.md b/docs/fr/build.md index 7422fb6..50953f9 100644 --- a/docs/fr/build.md +++ b/docs/fr/build.md @@ -45,10 +45,9 @@ et une image plus petite = cold start plus rapide et stockage moins cher. Le scr `LICENSE*`, `README*`, `tests/`, `docs/`, `examples/`). - Affiche la taille économisée (`AWS SDK: MB → MB`). -> Note : la liste blanche `KEEP_SERVICES` actuelle contient encore quelques services hérités -> de l'application d'origine (`bedrock-*`, `translate`) que la plateforme nue n'utilise pas. -> Réduis-la à `dsql, dynamodb, s3, ssm, sts, cloudfront, ses` pour une image plus légère, et -> rajoute ce dont tes propres plugins ont besoin. +> `KEEP_SERVICES` est réduit à ce que la plateforme utilise réellement : `dsql`, `dynamodb`, +> `s3`, `ssm`, `sts` (chaîne de credentials) et `ses`/`email` (mail optionnel — voir +> [email-ses.md](email-ses.md)). Ajoute le service dont tes propres plugins ont besoin. Pour ajouter un service SDK dont ton plugin a besoin : ajoute-le à `KEEP_SERVICES` (et à la liste `case` de `src/`) dans `spip/scripts/shrink-vendor.sh`, puis rebuild. diff --git a/docs/fr/email-ses.md b/docs/fr/email-ses.md new file mode 100644 index 0000000..029d49a --- /dev/null +++ b/docs/fr/email-ses.md @@ -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//values.tfvars` : + ```hcl + ses_from_email = "Mon site " + ``` + 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=` (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. diff --git a/spip/Dockerfile b/spip/Dockerfile index 17b28da..809624f 100644 --- a/spip/Dockerfile +++ b/spip/Dockerfile @@ -136,7 +136,8 @@ COPY spip/overlay/router.php /var/task/router.php # Our plugins (always active — placed in plugins-dist). COPY spip/plugins/s3upload/ /var/task/plugins-dist/s3upload/ COPY spip/plugins/s3upload/exec/ /var/task/ecrire/exec/ -RUN rm -rf /var/task/plugins/s3upload /var/task/plugins/sessions_dynamodb +COPY spip/plugins/ses_mail/ /var/task/plugins-dist/ses_mail/ +RUN rm -rf /var/task/plugins/s3upload /var/task/plugins/sessions_dynamodb /var/task/plugins/ses_mail # Third-party plugins (always active — in plugins-dist). COPY spip/plugins-vendor/ /var/task/plugins-dist/ diff --git a/spip/plugins/ses_mail/inc/envoyer_mail.php b/spip/plugins/ses_mail/inc/envoyer_mail.php new file mode 100644 index 0000000..fbd5882 --- /dev/null +++ b/spip/plugins/ses_mail/inc/envoyer_mail.php @@ -0,0 +1,142 @@ +" (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"; + } + if ($headers) { + $hdr .= is_array($headers) ? implode("\r\n", $headers) : $headers; + } + 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); +} diff --git a/spip/plugins/ses_mail/paquet.xml b/spip/plugins/ses_mail/paquet.xml new file mode 100644 index 0000000..1f371f3 --- /dev/null +++ b/spip/plugins/ses_mail/paquet.xml @@ -0,0 +1,17 @@ + + Amazon SES mail + spip-serverless + MIT + + 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. + + diff --git a/spip/scripts/shrink-vendor.sh b/spip/scripts/shrink-vendor.sh index ac39d1a..d75ce86 100644 --- a/spip/scripts/shrink-vendor.sh +++ b/spip/scripts/shrink-vendor.sh @@ -13,8 +13,9 @@ fi BEFORE=$(du -sm "$VENDOR_DIR/aws" | cut -f1) -# Services to keep (dsql + its dependencies: sts for credentials) -KEEP_SERVICES="bedrock-agent-runtime|bedrock-runtime|bedrockagentruntime|bedrockruntime|cloudfront|dsql|dynamodb|email|lambda|s3|ses|ssm|streams.dynamodb|sts|translate" +# Services to keep: what the bare platform uses (dsql, dynamodb, s3, ssm, sts for +# credentials) + SES ("email" is SES's SDK data-dir name) for optional transactional mail. +KEEP_SERVICES="dsql|dynamodb|email|s3|ses|ssm|sts" # Remove unused service data directories for dir in "$SDK_DATA"/*/; do @@ -29,7 +30,7 @@ for dir in "$SDK_SRC"/*/; do dirname=$(basename "$dir") # Skip non-service directories case "$dirname" in - data|Api|Arn|ClientSideMonitoring|Credentials|Crypto|DefaultsMode|Endpoint*|Exception|Handler*|Multipart|Retry|Signature|Token|BedrockAgentRuntime|BedrockRuntime|CloudFront|DSQL|DynamoDb|Lambda|S3|Ses|Ssm|Sts|Translate) continue ;; + data|Api|Arn|ClientSideMonitoring|Credentials|Crypto|DefaultsMode|Endpoint*|Exception|Handler*|Multipart|Retry|Signature|Token|DSQL|DynamoDb|S3|Ses|Ssm|Sts) continue ;; esac # If it's a service directory (has a Client.php), remove if not needed if [ -f "$dir/${dirname}Client.php" ]; then