Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
7 changes: 3 additions & 4 deletions docs/en/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,9 @@ and cheaper storage. The script:
`LICENSE*`, `README*`, `tests/`, `docs/`, `examples/`).
- Prints the saved size (`AWS SDK: <before>MB → <after>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.
Expand Down
53 changes: 53 additions & 0 deletions docs/en/email-ses.md
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.
7 changes: 3 additions & 4 deletions docs/fr/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <avant>MB → <après>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.
Expand Down
56 changes: 56 additions & 0 deletions docs/fr/email-ses.md
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.
3 changes: 2 additions & 1 deletion spip/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
142 changes: 142 additions & 0 deletions spip/plugins/ses_mail/inc/envoyer_mail.php
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,
]);
Comment on lines +80 to +85

Copy link
Copy Markdown

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_once is 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.

    static $client = null;

    try {
        if ($client === null) {
            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";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Security Vulnerability: Email header injection risk. The $from parameter is used directly without validation in the native fallback. An attacker could inject malicious headers (e.g., additional recipients, BCC addresses) by passing newline characters in the From address. Validate and sanitize the $from parameter before using it in mail headers to prevent header injection attacks1.

Suggested change
$hdr .= 'From: ' . $from . "\r\n";
$hdr .= 'From: ' . str_replace(["\r", "\n"], '', $from) . "\r\n";

Footnotes

  1. CWE-93: Improper Neutralization of CRLF Sequences - https://cwe.mitre.org/data/definitions/93.html

}
if ($headers) {
$hdr .= is_array($headers) ? implode("\r\n", $headers) : $headers;
}
Comment on lines +128 to +130

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Security Vulnerability: Email header injection risk via $headers parameter. An attacker could inject malicious email headers by manipulating the $headers parameter. Strip all CRLF sequences from headers to prevent header injection attacks1.

Suggested change
if ($headers) {
$hdr .= is_array($headers) ? implode("\r\n", $headers) : $headers;
}
if ($headers) {
$hdr .= is_array($headers) ? implode("\r\n", array_map(function($h) { return str_replace(["\r", "\n"], '', $h); }, $headers)) : str_replace(["\r", "\n"], '', $headers);
}

Footnotes

  1. CWE-93: Improper Neutralization of CRLF Sequences - https://cwe.mitre.org/data/definitions/93.html

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);
}
17 changes: 17 additions & 0 deletions spip/plugins/ses_mail/paquet.xml
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>
7 changes: 4 additions & 3 deletions spip/scripts/shrink-vendor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading