Specifica del modulo, decisione DD-036 e voce in roadmap. Nessun codice: ogni notifica di notifiche_utente diventa anche una mail, con coda su tabella dedicata e preferenza per account in preferenze_utente. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
14 KiB
Modulo — Notifiche email
Stato: progettato, non implementato (DD-036, in valutazione)
File previsti: mailer/ (worker Node + Dockerfile + docker-compose.yml), una migration
m22_… per la coda e la preferenza, src/lib/notifiche-email-core.ts (logica pura, testata)
Obiettivo
Far arrivare le notifiche di CrAPP anche per email, all'indirizzo Gmail con cui il giocatore
si è registrato, a costo zero e senza affidarsi a un servizio a pagamento. È un terzo
canale accanto alla push e al centro notifiche in-app (notifiche.md): non li
sostituisce, e non introduce nuovi eventi. Manda per email ciò che già finisce in
notifiche_utente.
Architettura
App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ notifiche_email_coda (Supabase)
▲
polling in uscita │ service role
│
Container Docker sul tuo host (mailer)
│ SMTP 587 (STARTTLS)
▼
smtp.gmail.com ──▶ casella Gmail del giocatore
- Vercel non ospita Docker, quindi il worker gira sull'host di casa. Sta solo in uscita: interroga Supabase e parla con Gmail. Nessuna porta aperta sul router, nessun IP pubblico, nessun DNS dinamico, nessun tunnel.
- L'app non invia mai email direttamente. Continua a scrivere
notifiche_utentecome oggi (le quattro sorgenti di M17 restano invariate). Se l'host è spento le mail restano in coda e partono alla ripartenza: la consegna è in ritardo, mai persa. - Tutta la logica di accodamento sta nel database, così vale per ogni sorgente, cron
pg_croncompresi, senza toccare le route esistenti.
Mittente: Gmail con password per app
Il worker invia con un account Gmail dedicato (es. crapp.notifiche@gmail.com, non
quello personale) tramite smtp.gmail.com:587, autenticandosi con una password per app.
| Requisito | Dettaglio |
|---|---|
| Verifica in due passaggi | Obbligatoria sull'account: senza, Google non permette di creare le password per app. |
| Password per app | Da myaccount.google.com → Sicurezza → Password per le app. 16 caratteri, revocabile senza cambiare la password dell'account. |
| Limite di invio | Circa 500 destinatari al giorno per un account gratuito (verificare il valore corrente). Una squadra ne usa poche decine al giorno. |
Intestazione From |
Gmail la riscrive con l'indirizzo dell'account: il nome visibile può essere «CrAPP», l'indirizzo no. |
| Porta | 587 in uscita. La 25 è bloccata dai provider residenziali e non serve. |
Perché Gmail e non un relay come Brevo o Resend: i destinatari sono già su Gmail, quindi la mail parte e arriva dentro la stessa infrastruttura, con SPF e DKIM già a posto e senza dover comprare né configurare un dominio. Un relay dedicato ha senso solo se un giorno si vorrà un mittente col dominio della squadra (vedi Riesame in DD-036). Non si invia mai direttamente dal proprio IP di casa: gli IP residenziali sono nelle blocklist e senza reverse DNS le mail finiscono in spam o vengono rifiutate.
Dati (bozza, non ancora in DATABASE.md)
Lo schema si documenta in DATABASE.md nella stessa modifica che crea la migration. La bozza:
notifiche_email_coda — una riga per notifica da mandare.
| Colonna | Tipo | Note |
|---|---|---|
notifica_id |
uuid PK, FK cascade | notifiche_utente(id): cancellata la notifica sparisce anche la riga |
stato |
text | in_coda · in_invio · inviata · fallita · saltata |
tentativi |
int | incrementato a ogni invio fallito |
prossimo_tentativo |
timestamptz | il worker la prende solo se <= now() (backoff) |
errore |
text | ultimo messaggio d'errore SMTP |
inviata_il |
timestamptz | valorizzato a invio riuscito |
Perché una tabella a parte e non colonne su notifiche_utente: quella tabella ha una policy
UPDATE per il giocatore su tutta la riga, quindi un client potrebbe riportare a in_coda una
mail già partita. La coda invece ha RLS attiva senza nessuna policy: la vede e la scrive
solo la service role (il worker), mai un client.
preferenze_utente — tabella nuova, una riga per account (auth_user_id PK, FK su
auth.users con cascade), non per slot giocatore. Per ora ha una sola colonna,
email_notifiche boolean NOT NULL DEFAULT true. RLS: ognuno legge e scrive solo la propria
riga (auth_user_id = auth.uid()), il worker la legge con la service role. Nessuna riga
significa acceso: la riga nasce solo quando il giocatore tocca l'interruttore (upsert), e
la funzione prendi_notifiche_email tratta l'assenza come true. Non va su
giocatori_squadra, che ha una riga per slot e il giocatore può modificare solo in parte
(DD-016).
Indirizzo destinatario — giocatori_squadra.email, già usata per il collegamento
automatico account↔giocatore (DD-018). Niente lettura di auth.users.
Flusso
- Una sorgente M17 inserisce (o aggiorna via
upsert) una riga innotifiche_utente. - Un trigger
AFTER INSERT OR UPDATE OF creato_illa accoda innotifiche_email_coda, L'upsertdi turno palloni e sollecito presenze aggiornacreato_il: è un rinvio voluto, e rimette in coda anche la mail. - Il worker ogni ~30 secondi chiama la funzione RPC
prendi_notifiche_email(n), riservata alla service role. Seleziona fino anrighein_codaconFOR UPDATE SKIP LOCKED, le marcain_invioe restituisce titolo, corpo,evento_ide indirizzo, già filtrate per preferenza attiva e slot conauth_user_idvalorizzato. ConSKIP LOCKEDdue worker avviati per errore non mandano la stessa mail due volte. - Il worker invia con
nodemailer, poi segnainviataofallita. - Riga senza destinatario (slot non ancora collegato a un account, preferenza spenta,
email assente):
saltata, senza errore.
Ogni notifica presente in notifiche_utente genera una mail, di qualunque tipo:
admin, evento_promemoria_24h, evento_promemoria_3h, turno_palloni,
sollecita_presenze. Il modulo non aggiunge sorgenti né cambia la logica di M17: un tipo
nuovo che in futuro finisse in notifiche_utente viaggerebbe per email senza altro lavoro.
Chi le riceve. Con la stessa logica delle notifiche in-app: la mail va a chi ha la riga in
notifiche_utente, admin e allenatori compresi. Non c'è un percorso separato per gli admin: un
admin riceve ciò che riceverebbe come giocatore (i destinatari di ogni sorgente restano quelli
già definiti in M17 e DD-034).
Il certificato in scadenza non genera mail: non è una notifica ma una card calcolata in
Home (DD-035), e questo modulo non la trasforma in una. Se un giorno la si volesse anche per
email, sarebbe una sorgente nuova di notifiche_utente da decidere a parte.
Affidabilità
| Situazione | Comportamento |
|---|---|
| Errore temporaneo (rete, 4xx SMTP) | Backoff 1 · 5 · 30 · 120 min, poi fallita dopo 5 tentativi. |
| Errore permanente (5xx, indirizzo KO) | fallita subito, senza altri tentativi. |
| Worker fermo o host spento | Le mail restano in_coda; partono alla ripartenza. |
| Crash tra l'invio e l'aggiornamento | Possibile un doppione: la consegna è almeno una volta. Il Message-ID è deterministico (derivato da notifica_id). |
Righe in_invio orfane |
All'avvio il worker riporta a in_coda quelle ferme da più di 10 minuti. |
| Limite giornaliero Gmail raggiunto | Il worker si ferma fino a mezzanotte (fuso Europe/Rome) e riprende; nessuna riga viene scartata. |
Worker (mailer/)
Servizio Node piccolo: @supabase/supabase-js e nodemailer. Le dipendenze passano dal
controllo minimumReleaseAge di bunfig.toml come per il resto del progetto.
# mailer/docker-compose.yml (bozza)
services:
mailer:
build: .
restart: unless-stopped
env_file: .env
healthcheck:
test: ["CMD", "node", "healthcheck.js"]
interval: 60s
retries: 3
Variabili d'ambiente, mai committate (il .env sta nel .gitignore):
| Variabile | Contenuto |
|---|---|
SUPABASE_URL |
URL del progetto |
SUPABASE_SERVICE_ROLE_KEY |
chiave service role: salta la RLS, va trattata come una password |
SMTP_USER, SMTP_PASS |
account Gmail dedicato e relativa password per app |
MAIL_FROM_NAME |
nome visibile, es. «CrAPP» |
POLL_SECONDI |
intervallo di polling, default 30 |
APP_URL |
base dei link nelle mail (es. /evento/<id>) |
healthcheck.js segnala non sano il container se l'ultimo ciclo di polling riuscito è più
vecchio di qualche minuto. Il log dice solo id, tipo e esito: mai indirizzi o testi.
Contenuto delle mail
Sobrio, testo semplice più una versione HTML minima, leggibile da smartphone: oggetto = titolo
della notifica, corpo = corpo della notifica, un pulsante «Apri CrAPP» verso l'evento quando
evento_id c'è. In fondo una riga che spiega come disattivare le email (Profilo → Opzioni).
Nessuna immagine remota, nessun tracciamento delle aperture.
Privacy e sicurezza
- Le email personali dei giocatori non lasciano Supabase se non verso Gmail, che è già dove stanno. Il worker legge solo l'indirizzo dei destinatari del lotto, non l'intera rosa.
- La service role sta solo sull'host, in un
.envcon permessi600; non passa da Vercel. - La password per app si può revocare da Google senza toccare l'account.
- Il canale è opt-out: acceso di default, spegnibile dal giocatore. Un interruttore unico, come per la push (nessuna preferenza per tipo).
Interfaccia
Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore «Email» che scrive la preferenza per account. Nessuna schermata nuova. Nella tab «Notifiche» della dashboard admin si può aggiungere in un secondo momento uno stato per giocatore (email attive o no), come già fa la campana per la push.
Limiti noti
- Il canale email dipende da un host di casa acceso: non c'è alta disponibilità.
- Un 250 da Gmail vuol dire «accettata», non «letta»: non c'è conferma di lettura e non se ne vuole una.
- Gmail può finire in spam se il testo è promozionale o se l'account nuovo invia subito molti messaggi: si parte con pochi invii e si tiene il tono di servizio.
- Un account collegato a più slot giocatore (
auth_user_idripetuto) riceve una mail per slot, dato chenotifiche_utenteha una riga pergiocatore_id. - Cambiare l'indirizzo in
giocatori_squadra.emailcambia dove vanno le mail future; le righe giàinviatanon si toccano.
Test previsti
Come da AGENTS.md, le funzioni si scrivono con il loro test.
- Unit (
test/unit/, sunotifiche-email-core.ts): calcolo del backoff, classificazione errore temporaneo/permanente, composizione di oggetto e corpo,Message-IDdeterministico. - Integrazione (
test/integration/, contro il database locale): il trigger accoda ogni nuova notifica;upsertrimette in coda; RLS senza policy (un client non legge né scrive la coda);prendi_notifiche_emailnon restituisce due volte la stessa riga con due chiamate concorrenti, salta chi ha la preferenza spenta o non ha un account collegato. - Il transport SMTP si sostituisce con un finto nei test: nessun invio reale in CI.
Piano di realizzazione
- Migration
m22_…: coda, preferenza, trigger, funzione RPC, RLS (poiDATABASE.md). notifiche-email-core.tscon i test unitari.- Worker
mailer/conDockerfile, compose, healthcheck. - Interruttore in Profilo → Opzioni.
- Prova end-to-end su
npx supabase startcon un server SMTP di prova (es. Mailpit in Docker), poi con l'account Gmail vero. CHANGELOG.md,ROADMAP.md(spunta),PROJECT_STATE.md,notifiche.md(rimando qui).
Evoluzioni possibili
- Un relay con dominio proprio (Brevo, Resend) se serve un mittente della squadra o si supera il tetto di Gmail.
- Riepilogo settimanale unico al posto di una mail per notifica.
- Preferenze per tipo di notifica, insieme a quelle della push.