Skip to content

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/.

FunzioneTipoCosa fa
smtpActionAction<I, O>Invia una mail per record e passa avanti il record con l'esito
smtpSinkSink<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.

VariabileDefaultNote
SMTP_HOST—obbligatoria
SMTP_FROM—obbligatoria: il mittente predefinito, Nome <indirizzo>
SMTP_SECUREfalsetrue = TLS implicito (465); false = STARTTLS
SMTP_PORT465 con TLS, 587 senza
SMTP_REQUIRE_TLStruesenza 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_AUTHdedottopassword / oauth2, come IMAP_AUTH
SMTP_OAUTH_INSTANCEdefaultquale 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 modulo oauth; 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. false solo 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 è un ValidationError del 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_FROM dell'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 di nodemailer come cause;
  • 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 imapFlagAction mette una keyword (NoevaNotificato) e la sorgente la esclude con filtro.senzaKeyword. Resta una finestra fra invio e flag in cui un errore fa rinotificare.
  • Message-ID deterministico. Con chiave il 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.ts con un trasporto finto (tests/helpers/smtp-finto.ts, condiviso con i test del nodo mailAction): config, opzioni del trasporto, traduzione degli errori, validazione della mail, Message-ID deterministico, action, sink sotto try-catch, log e descriptor senza segreti.
  • Integrazione in tests/integration/smtp.integration.test.ts contro 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'utente notifiche e si rilegge con il connettore IMAP. Si salta senza SMTP_HOST.

Noeva è un marchio registrato di 4D S.R.L.