Tema
Connettore: SMTP (notifiche email)
Invia email di notifica, per esempio per avvisare l'operatore di una mail in revisione o di un errore tecnico. Basato su nodemailer. Codice in src/connectors/smtp/.
| Funzione | Tipo | Cosa fa |
|---|---|---|
smtpAction | Action<I, O> | Invia una mail per record e passa avanti il record con l'esito |
smtpSink | Sink<T> | Invia una mail per record come ultimo passo; isolabile per record |
Configurazione
loadSmtpConfig(prefix?, env?) legge le variabili con prefisso (default SMTP_); loadSmtpRegistry(env?) le risolve per istanza: con SMTP_INSTANCES=notifiche ogni server ha le sue SMTP_<ID>_*, senza lista valgono le variabili base come istanza default.
| Variabile | Default | Note |
|---|---|---|
SMTP_HOST | — | obbligatoria |
SMTP_FROM | — | obbligatoria: il mittente predefinito, Nome <indirizzo> |
SMTP_SECURE | false | true = TLS implicito (465); false = STARTTLS |
SMTP_PORT | 465 con TLS, 587 senza | |
SMTP_REQUIRE_TLS | true | senza TLS implicito, rifiuta un server che non offre STARTTLS |
SMTP_USER | — | assente = nessuna autenticazione (relay interno) |
SMTP_PASSWORD | — | con SMTP_USER: una fra password e token |
SMTP_ACCESS_TOKEN | — | OAuth2 (XOAUTH2), per esempio Microsoft 365 |
SMTP_AUTH | dedotto | password / oauth2, come IMAP_AUTH |
SMTP_OAUTH_INSTANCE | default | quale identità OAUTH_* rinnova il token |
- OAuth2 come per IMAP: il token scade, e chi sa rinnovarlo passa
trasporto: { accessToken: () => ottieniToken() }, chiamata a ogni invio. La funzione viene dal modulooauth; di norma è la stessa identità di IMAP, perché una casella Google si legge e si scrive con lo stesso consenso. Runbook: oauth-google-posta.md. - Credenziali mai in chiaro: con
SMTP_REQUIRE_TLS=true(default) un server senza STARTTLS non riceve né la password né la mail.falsesolo per server di test o relay interni. - Niente credenziali nei log né nel DAG: il logger di
nodemailerè spento, i descriptor mostrano host, porta e mittente, mai utente, password o destinatari.
La mail: una funzione del record
Niente motore di template: la mail è codice della pipeline, tipato e testato. MailSmtp è un alias della MailInUscita condivisa con il connettore SendGrid (src/connectors/shared/mail.ts): la stessa funzione serve a entrambi i trasporti, e così la validazione qui sotto. Per scegliere il trasporto da configurazione c'è mailAction / mailSink (sendgrid.md § Cambiare trasporto).
ts
const avviso = (r: Verificata): MailSmtp => ({
a: config.operatori, // destinatari dalla config della pipeline
cc: [], // facoltativi: cc, ccn
oggetto: `Ordine ${r.ordine} in revisione`,
testo: `Motivo: ${r.motivo}`, // almeno uno fra testo e html
html: `<p>Motivo: <b>${escapeHtml(r.motivo)}</b></p>`,
allegati: [
{
nome: "originale.eml",
contentType: "message/rfc822",
contenuto: r.mail.eml,
},
],
rispondiA: "ufficio@example.test", // facoltativo
chiave: r.mail.messageId, // facoltativa: Message-ID deterministico
});- Validata prima di partire: almeno un destinatario, un indirizzo per stringa (
x@dominio, anche con nome), oggetto non vuoto, un corpo, niente a capo nelle intestazioni (header injection). Un problema è unValidationErrordel record, e non parte nulla. Gli errori non citano gli indirizzi. - L'HTML lo scrive la pipeline: i valori del record che ci finiscono vanno escapati da lei.
- Il mittente non è nella mail: è
SMTP_FROMdell'istanza. Un'identità di invio diversa è un'altra istanza (SMTP_INSTANCES=ordini→SMTP_ORDINI_FROM).
Action — smtpAction
ts
.action("verifica", "avvisa", smtpAction<Verificata, Avvisata>(config, {
instanceId: "notifiche",
mail: avviso,
merge: (r, invio) => ({ ...r, avviso: invio }), // EsitoInvio: messageId, accettati
}), { deadLetter })Sink — smtpSink
ts
.tryCatch("verifica", "prova")
.sink("prova", smtpSink<Verificata>(config, { name: "avvisa", mail: avviso }))
.sink("prova", mailNonInviate, { fromPort: "catch" })Implementa loadOne: sotto un try-catch è isolabile per record, e una mail rifiutata va nel ramo catch mentre le altre partono. Senza guard, il primo errore ferma il sink.
Errori
Errori del record, quindi dead-letter (action) o ramo catch:
ValidationError: mail non valida;LoadError: rete, TLS, credenziali, o un destinatario rifiutato anche se altri hanno accettato (una notifica non consegnata a qualcuno non passa in silenzio). Il messaggio dice quanti destinatari, non quali: della risposta del server tiene solo codice e stato esteso (550 5.1.1), perché il testo cita spesso l'indirizzo. Chi aveva accettato l'ha già ricevuta, e un retry del record gliela rimanda. Niente errore originale dinodemailercomecause;ConfigError: OAuth2 senza token.
Idempotenza: dove metterlo
Una mail inviata non si ritira, e un retry la rimanda. Due difese, a carico della pipeline:
- Dopo il commit. L'invio sta dopo il passo che rende il record «lavorato», tipicamente lo spostamento IMAP del messaggio (
imapSpostaAction): un retry della run non rilegge un messaggio già spostato, quindi non lo notifica di nuovo. Un invio prima del commit ripartirebbe a ogni errore più a valle. - Un flag. Se il messaggio resta nella cartella, dopo l'invio un
imapFlagActionmette una keyword (NoevaNotificato) e la sorgente la esclude confiltro.senzaKeyword. Resta una finestra fra invio e flag in cui un errore fa rinotificare. - Message-ID deterministico. Con
chiaveil Message-ID è un hash della chiave con il dominio del mittente (messageIdDa): un reinvio porta lo stesso ID, e chi deduplica per Message-ID lo riconosce. Non impedisce l'invio: lo rende riconoscibile.
Una connessione per invio, senza pool: niente da chiudere a fine run.
Descriptor (UI del DAG)
Kind smtp per action e sink, con connection = instanceId se passato. Dettagli: host, porta e mittente (SMTP_FROM), ognuno con la sua variabile e la sua origine, e in fondo credenziale: la variante in uso, le alternative se manca, o nessuna: relay senza autenticazione senza SMTP_USER. Nel piano, con OAuth2, dice anche se l'identità OAUTH_* è completa o quali sue variabili mancano. Mai utente, segreti né destinatari. Contratto generale: node-descriptor.md.
Le variabili sono dichiarate in MANIFEST_SMTP (config.ts): con un utente le credenziali diventano obbligatorie, e il ConfigError elenca tutte le mancanti. SMTP_AUTH si valida anche senza SMTP_USER: un valore fuori da password/oauth2 è un errore pure su un relay, dove prima era ignorato. Per il piano ci sono istanzaSmtpPerIlPiano(id, env) e loadSmtpInstance(id, env), come per IMAP, e per etl env check istanzaSmtpDichiarata(id).
Test
- Unit colocati in
src/connectors/smtp/*.test.tscon un trasporto finto (tests/helpers/smtp-finto.ts, condiviso con i test del nodomailAction): config, opzioni del trasporto, traduzione degli errori, validazione della mail, Message-ID deterministico, action, sink sottotry-catch, log e descriptor senza segreti. - Integrazione in
tests/integration/smtp.integration.test.tscontro GreenMail (docker compose up -d imap-test, SMTP su 3025): invio con destinatari, oggetto, testo, HTML e allegato, cc e ccn, Message-ID dalla chiave, credenziali errate, server irraggiungibile. La mail va all'utentenotifichee si rilegge con il connettore IMAP. Si salta senzaSMTP_HOST.

