Tema
Connettore: SendGrid (mail via API REST)
Invia mail con l'API v3 di SendGrid (POST /v3/mail/send), con una chiave propria dell'ETL e fetch nativo, senza SDK. Codice in src/connectors/sendgrid/, subpath @noeva/etl-core/connectors/sendgrid; mailAction e mailSink stanno in @noeva/etl-core/connectors/mail. La mail è la stessa del connettore SMTP: una pipeline cambia trasporto cambiando nodo e istanza, non il template.
| Funzione | Tipo | Cosa fa |
|---|---|---|
sendgridAction | Action<I, O> | Invia una mail per record e passa avanti il record con l'esito |
sendgridSink | Sink<T> | Invia una mail per record come ultimo passo; isolabile per record |
Configurazione
loadSendgridConfig(prefix?, env?) legge le variabili con prefisso (default SENDGRID_); loadSendgridRegistry(env?) le risolve per istanza: con SENDGRID_INSTANCES=notifiche ogni account ha le sue SENDGRID_<ID>_*, senza lista valgono le variabili base come istanza default. La configurazione si valida all'avvio, e il ConfigError elenca tutte le variabili mancanti, mai i valori.
| Variabile | Default | Note |
|---|---|---|
SENDGRID_API_KEY | — | obbligatoria: una chiave ristretta al solo Mail Send |
SENDGRID_FROM | — | obbligatorio: x@dominio o Nome <x@dominio>, una Sender Identity |
SENDGRID_SANDBOX | false | true = sandbox_mode: SendGrid valida e risponde 200, non spedisce |
SENDGRID_BASE_URL | https://api.sendgrid.com | per un account EU https://api.eu.sendgrid.com; solo https |
- Sandbox per le prove: in locale e in sviluppo
SENDGRID_SANDBOX=truecontrolla mittente, destinatari e corpo senza mandare nulla. L'esito hasandbox: truee nessunmessageId. - La chiave non esce: viaggia solo nell'header
Authorization. Il descriptor ne dice lo stato e gli errori la tolgono dai messaggi di SendGrid. - Il mittente non è nella mail: è
SENDGRID_FROMdell'istanza, e un'identità diversa è un'altra istanza.
La mail
È la MailInUscita condivisa con SMTP (src/connectors/shared/mail.ts): a, cc, ccn, oggetto, testo e/o html, allegati, rispondiA, chiave. Si valida allo stesso modo prima di partire (vedi smtp.md § La mail): un problema è un ValidationError del record e non parte nessuna richiesta.
Verso l'API diventa una sola personalization con tutti i destinatari, il testo prima dell'HTML, gli allegati in base64 con disposition: attachment. Della chiave parte solo l'impronta, nel custom arg noeva_chiave: si ritrova nell'Activity e negli eventi di SendGrid e rende riconoscibile un reinvio.
Cambiare trasporto da configurazione
Per una pipeline che deve poter usare SMTP o SendGrid c'è il nodo neutro di src/connectors/mail/: riceve un TrasportoMail ({ tipo: "smtp", config } o { tipo: "sendgrid", config }) e diventa smtpAction/sendgridAction (o i sink), con il loro descriptor e i loro errori. Il merge riceve l'esito comune EsitoInvioMail (messageId?, accettati). Il grafo resta lo stesso, e il trasporto è un valore della configurazione.
ts
const trasporto: TrasportoMail =
config.trasportoMail === "sendgrid"
? { tipo: "sendgrid", config: loadSendgridRegistry(env).get("notifiche") }
: { tipo: "smtp", config: loadSmtpRegistry(env).get("notifiche") };
.action("esito", "mail-esito", mailAction<Esito, Esito>(trasporto, {
instanceId: "notifiche",
mail: mailEsito,
merge: (r) => r,
}))Action — sendgridAction
ts
.action("esito", "mail-esito", sendgridAction<Esito, Esito>(config, {
instanceId: "notifiche",
mail: mailEsito,
merge: (r, invio) => ({ ...r, invio }), // EsitoInvioSendgrid: messageId?, accettati, sandbox
}), { deadLetter })Sink — sendgridSink
ts
.tryCatch("esito", "prova")
.sink("prova", sendgridSink<Esito>(config, { name: "mail-esito", mail: mailEsito }))
.sink("prova", mailNonInviate, { fromPort: "catch" })Implementa loadOne, quindi sotto un try-catch è isolabile per record. Non è retrySafe.
Esito ed errori
| Risposta | Che cosa vuol dire | Effetto |
|---|---|---|
202 | presa in carico | esito, con messageId da X-Message-Id |
200 | validata in sandbox | esito con sandbox: true |
altro 2xx | inatteso per la modalità | esito, con un avviso sendgrid.esito-inatteso |
4xx (tranne 429) | richiesta rifiutata | LoadError del record coi messaggi di SendGrid |
429, 5xx | non presa in carico, da riprovare | retry con backoff; finiti i tentativi, LoadError |
| rete, timeout | la richiesta potrebbe essere arrivata | LoadError del record, senza retry |
- I messaggi di SendGrid arrivano nel report senza la chiave, senza gli indirizzi (sostituiti da
[indirizzo]) e troncati; icampirifiutati (personalizations.0.to.0.email) stanno neidetails. - Il retry usa
withRetrydel core:RETRY_SENDGRIDè quattro tentativi da 1 a 30 secondi, rispettaRetry-Aftere si ferma con l'annullamento della run. Si cambia contrasporto: { retry }, si spegne con{ attempts: 1 }. Ogni retry è un logsendgrid.retrycon lo status, senza dati della mail. trasporto: { timeoutMs }cambia il timeout della richiesta (default 30 secondi);trasporto: { fetchImpl }sostituiscefetch, nei test.- L'annullamento della run si propaga com'è, non diventa un errore del record.
Idempotenza
Una mail inviata non si ritira, e un retry della run la rimanda: la difesa è quella di SMTP, l'invio va dopo il passo che fa da commit (vedi smtp.md § Idempotenza). Il retry interno dei 5xx può, raramente, duplicare una mail che SendGrid aveva preso in carico: è un rischio accettato.
Descriptor (UI del DAG)
Kind sendgrid per action e sink, con connection = instanceId se passato. Dettagli: mittente, base e sandbox, ognuno con la sua variabile e la sua origine, e in fondo credenziale: API key · impostata, o la variabile che manca. Mai la chiave né i destinatari. Le variabili sono dichiarate in MANIFEST_SENDGRID; per il piano ci sono istanzaSendgridPerIlPiano(id, env) e loadSendgridInstance(id, env). Per etl env check, istanzaSendgridDichiarata(id) è l'account da passare ad ambienteDellaPipeline: con lo stesso manifest della run il check vede anche SENDGRID_API_KEY, che il piano non mostra.
Test
Unit colocati in src/connectors/sendgrid/*.test.ts con un fetch finto (fetchSendgridFinto di @noeva/etl-core/testing): invio riuscito, corpo della richiesta, allegati, sandbox, 4xx con messaggio leggibile e senza chiave, 429 e 5xx ritentati, rete senza retry, annullamento della run (prima, durante la richiesta, durante l'attesa del retry), istanze con nome e configurazione mancante o non valida. Nessun test d'integrazione: la sandbox vuole una chiave vera.

