Files
CRAPP/docs/modules/notifiche-email.md
T
davideandClaude Sonnet 5.5 b7d3fde225 Implementa le notifiche email via Gmail da un worker Docker (DD-036)
Migration m22: coda notifiche_email_coda, preferenze_utente, trigger che
accoda ogni notifica di notifiche_utente e tre funzioni RPC riservate
alla service role. Worker in mailer/ (bun + nodemailer, solo polling in
uscita) con backoff, tetto giornaliero e healthcheck. Interruttore
«Email» in Profilo → Opzioni, acceso di default. Test unit e di
integrazione; documentazione aggiornata (DD-036 accettata).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:07:24 +02:00

20 KiB

Modulo — Notifiche email

Stato: implementato e verificato in locale (migration m22, worker in mailer/); non ancora applicato in produzione né messo in funzione sull'host (vedi Messa in funzione) File principali: supabase/migrations/20260929120000_m22_notifiche_email.sql, mailer/mailer.mjs, mailer/mailer-core.ts, mailer/healthcheck.mjs, mailer/Dockerfile, mailer/docker-compose.yml, src/lib/preferenze-utente.ts, src/routes/profilo.tsx (interruttore «Email»)

Obiettivo

Far arrivare le notifiche di CrAPP anche per email, all'indirizzo Gmail con cui il giocatore si è registrato, a costo zero. È un terzo canale accanto alla push e al centro notifiche in-app (notifiche.md): non li sostituisce e non introduce eventi nuovi. Manda per email ogni notifica che finisce già in notifiche_utente, di qualunque tipo e per qualunque destinatario. Le motivazioni delle scelte stanno in DD-036.

Architettura

App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ notifiche_email_coda   (Supabase)
                                                              ▲
                                            polling in uscita │ RPC con 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 e sta solo in uscita: interroga Supabase e parla con Gmail. Nessuna porta aperta sul router, nessun IP pubblico, nessun tunnel.
  • L'app non invia mai email direttamente. Continua a scrivere notifiche_utente come prima: le route e i cron di M17 non sono stati toccati. Se l'host è spento le mail restano in coda e partono alla ripartenza: consegna in ritardo, mai persa.
  • L'accodamento sta nel database (un trigger), quindi vale per ogni sorgente, cron pg_cron compresi.
  • Il worker gira con bun (immagine oven/bun:1-alpine, come il resto del progetto) e ha una sola dipendenza, nodemailer. Parla con Supabase via fetch sulle funzioni RPC di PostgREST, senza @supabase/supabase-js. Ha un proprio package.json e bun.lock, separati da quelli dell'app, che non guadagna nessuna dipendenza.

Quali notifiche e a chi

Ogni riga nuova di notifiche_utente genera una mail: messaggio admin, promemoria a 24 ore e a 3 ore, turno palloni, sollecito presenze. Un tipo nuovo che in futuro finisse in quella tabella viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration non vengono inviate: il trigger non ha backfill.

Chi le riceve: chi ha la riga in notifiche_utente, con la stessa logica delle notifiche in-app. Gli admin e gli allenatori non hanno un percorso a parte: ricevono ciò che riceverebbero come giocatori, secondo i destinatari 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). Se un giorno lo si volesse anche per email sarebbe una sorgente nuova di notifiche_utente, da decidere a parte.

Indirizzo: giocatori_squadra.email, la stessa colonna che collega account e giocatore (DD-018). Uno slot senza auth_user_id (giocatore non ancora registrato), senza email o il cui account ha spento le email non riceve nulla: la riga in coda passa a saltata, senza errore.

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); il worker si ferma a MAIL_LIMITE_GIORNO.
Intestazione From Gmail la riscrive con l'indirizzo dell'account: il nome visibile è «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 comprare né configurare un dominio. 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

Lo schema completo, con permessi e note, sta in DATABASE.md; qui il quadro funzionale.

notifiche_email_coda — una riga per notifica da mandare (notifica_id è chiave primaria e chiave esterna con ON DELETE CASCADE: cancellare la notifica, per esempio con lo swipe, toglie anche la mail non ancora partita).

Colonna Note
stato in_coda · in_invio · inviata · fallita · saltata
tentativi invii falliti finora; non cresce per i rinvii «differiti» (vedi sotto)
prossimo_tentativo il worker prende la riga solo se <= now() (backoff)
errore solo i codici SMTP dell'ultimo errore, mai il testo delle mail
inviata_il valorizzato a invio riuscito; alimenta il conteggio delle ultime 24 ore
aggiornata_il serve a riconoscere le righe rimaste in_invio per un worker morto

È una tabella a parte e non colonne su notifiche_utente perché quella ha una policy UPDATE per il giocatore su tutta la riga: un client potrebbe riportare a in_coda una mail già partita. La coda ha la RLS attiva senza alcuna policy e i permessi tolti a anon e authenticated: la legge e la scrive solo la service role, cioè il worker.

preferenze_utente — una riga per account (auth_user_id, non lo slot giocatore), per ora con la sola colonna email_notifiche (default true). Nessuna riga significa email attive: la riga nasce solo quando il giocatore tocca l'interruttore, con un upsert; ognuno legge e scrive solo la propria, per RLS. Non sta su giocatori_squadra, che ha una riga per slot e il giocatore può modificare solo in parte (DD-016).

Funzioni RPC — tutte SECURITY DEFINER e con EXECUTE solo alla service role:

Funzione Cosa fa
prendi_notifiche_email(p_max) Prende fino a p_max righe in_coda scadute con FOR UPDATE SKIP LOCKED, le marca in_invio e restituisce indirizzo, oggetto, testo, evento, tipo.
esito_notifica_email(id, esito, errore, ora) Registra inviata, riprova, differita o fallita; agisce solo su righe in_invio, così un esito in ritardo non ne sovrascrive uno già deciso.
email_inviate_ultime_24h() Conta le mail inviate nelle ultime 24 ore, per restare sotto il tetto.

Flusso

  1. Una sorgente M17 inserisce (o aggiorna con upsert) una riga in notifiche_utente.
  2. Il trigger notifiche_utente_accoda_email (AFTER INSERT OR UPDATE OF creato_il) la mette in notifiche_email_coda. L'upsert di turno palloni e sollecito presenze riscrive creato_il: è un rinvio voluto e rimette in coda anche la mail. Segnare come letta e l'ON CONFLICT DO NOTHING dei cron non toccano quella colonna e non accodano nulla.
  3. Ogni POLL_SECONDI (30) il worker conta le mail delle ultime 24 ore, ne chiede al massimo quante ne restano sotto il tetto (al più MAIL_LOTTO) a prendi_notifiche_email e le invia una a una. Prima di prendere il lotto la funzione SQL fa due pulizie: riporta a in_coda le righe ferme in_invio da più di 10 minuti (worker morto a metà) e segna saltata quelle senza destinatario o con le email spente.
  4. Ogni invio registra l'esito con esito_notifica_email.

La preferenza è valutata al momento dell'invio, non dell'accodamento: spegnere l'interruttore ferma anche le mail già in coda.

Affidabilità

Il worker classifica l'errore SMTP (classificaErroreSmtp):

Tipo Esempi Cosa succede
temporaneo 4xx, timeout, connessione caduta Riprova con backoff 1 · 5 · 30 · 120 min; al quinto invio fallito passa a fallita.
permanente 5xx sul destinatario o sul messaggio, indirizzo non valido fallita subito, senza altri tentativi.
limite 550 5.4.5 Daily user sending quota exceeded Tutto il lotto torna in coda tra un'ora senza consumare tentativi; il ciclo si ferma.
configurazione credenziali SMTP rifiutate (535, 534, 530, EAUTH) Come sopra ma tra 15 minuti, e il container non risulta più sano (vedi sotto).

Altre situazioni:

Situazione Comportamento
Worker fermo o host spento Le mail restano in_coda e partono alla ripartenza.
Crash tra l'invio e la registrazione Consegna almeno una volta: la riga resta in_invio, dopo 10 minuti torna in coda e la mail può arrivare due volte. Mai una perdita.
Errore nella registrazione dell'esito Non si tratta come errore SMTP (non parte un secondo invio): il ciclo si interrompe e la riga viene recuperata come sopra.
Tetto giornaliero raggiunto Finestra mobile di 24 ore su inviata_il, non «fino a mezzanotte»: il worker riprende da solo quando le mail più vecchie escono dalla finestra.

MAIL_LIMITE_GIORNO vale 400 di default, sotto i circa 500 di Gmail, per lasciare margine ai messaggi che l'account dedicato potesse mandare per altre vie.

Salute del container. mailer.mjs scrive un timestamp in /tmp/mailer-ok a ogni ciclo riuscito; healthcheck.mjs (eseguito da Docker ogni minuto) segnala non sano il container se l'ultimo è più vecchio di 5 minuti. Un ciclo fermato dalle credenziali SMTP rifiutate non aggiorna il file, così dopo qualche minuto docker ps mostra unhealthy invece di far credere che tutto funzioni. Un ciclo fermato dal solo tetto giornaliero è normale e resta sano.

Contenuto delle mail

Sobrio, in testo semplice più una versione HTML minima leggibile da smartphone: oggetto = titolo della notifica (su una riga sola, massimo 200 caratteri), corpo = corpo della notifica, un pulsante «Apri CrAPP» verso /evento/<id> quando la notifica riguarda un evento e verso la home altrimenti. In fondo una riga che spiega come disattivare le email (Profilo → Opzioni → Email). Nessuna immagine remota, nessun tracciamento delle aperture. Il testo scritto dall'admin viene sempre escapato nell'HTML. Il Message-ID è derivato dall'id della notifica.

Interfaccia

Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore «Email» (icona busta) che scrive preferenze_utente per l'account, con lo stesso stile dell'interruttore «Notifiche» (useEmailNotifiche() in src/lib/preferenze-utente.ts). Vale per tutti gli slot dell'account. Nessuna schermata nuova.

Privacy e sicurezza

  • Le email personali dei giocatori non lasciano Supabase se non verso Gmail. Il worker legge solo l'indirizzo dei destinatari del lotto che sta spedendo, non l'intera rosa.
  • La service role sta solo sull'host, in un .env con permessi 600; non passa da Vercel.
  • La password per app si può revocare da Google senza toccare l'account.
  • I log del worker riportano solo id notifica, tipo ed esito: mai indirizzi né testi, e degli errori SMTP solo i codici. Il log del container è limitato a 3 file da 5 MB.
  • Il canale è opt-out: acceso di default, spegnibile dal giocatore. Un interruttore unico, come per la push (nessuna preferenza per tipo).

Messa in funzione

Da fare una volta, sull'host che deve far girare il worker (serve Docker).

  1. Account Gmail dedicato. Creane uno nuovo, attiva la verifica in due passaggi e genera una password per app (vedi la tabella sopra).

  2. Applica la migration in produzione, solo dopo aver verificato tutto in locale: npx supabase db push. È additiva (due tabelle, un trigger, tre funzioni): non modifica nulla di esistente e non invia mail finché il worker non parte. Dal momento in cui è applicata, le nuove notifiche si accodano: avvia il worker nella stessa sessione, altrimenti al primo avvio partirebbero tutte insieme anche mail ormai vecchie (per esempio un promemoria di giorni prima).

  3. Configura il worker. In mailer/: cp .env.example .env, chmod 600 .env e compila SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SMTP_USER, SMTP_PASS e APP_URL. Le altre variabili hanno un default:

    Variabile Default 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 password per app (vanno insieme)
    APP_URL — base dei link nelle mail
    MAIL_FROM, MAIL_FROM_NAME SMTP_USER, CrAPP indirizzo e nome del mittente
    POLL_SECONDI 30 intervallo di polling
    MAIL_LIMITE_GIORNO 400 tetto delle ultime 24 ore
    MAIL_LOTTO 10 mail prese a ogni giro
    SMTP_HOST, SMTP_PORT, SMTP_SECURE smtp.gmail.com, 587, false server SMTP (true solo per la 465)
  4. Avvia: docker compose up -d --build dentro mailer/. Il container ha restart: unless-stopped, quindi riparte da solo dopo un riavvio dell'host.

  5. Verifica: docker compose logs -f deve mostrare «SMTP raggiungibile e credenziali accettate»; poi manda un messaggio a un giocatore dalla dashboard admin e controlla che la riga in coda passi a inviata e la mail arrivi. docker ps deve mostrare healthy.

Aggiornare il worker: git pull, poi docker compose up -d --build in mailer/.

Diagnosi rapida: SELECT stato, count(*) FROM notifiche_email_coda GROUP BY stato; mostra a colpo d'occhio se qualcosa si accumula in in_coda (worker fermo o tetto raggiunto) o in fallita (leggere errore).

Prova in locale, senza Gmail

npx supabase start avvia anche Mailpit (interfaccia su http://127.0.0.1:54324): le mail non escono dalla macchina. Il worker si punta al database locale e a Mailpit con un .env senza credenziali SMTP (SMTP_USER/SMTP_PASS assenti, MAIL_FROM e SMTP_HOST impostati, porta 1025). Se il worker gira in un container va collegato alla rete Docker di Supabase (supabase_network_<project_id>) e usa come host supabase_kong_<project_id>:8000 per il database e supabase_inbucket_<project_id> per l'SMTP. Con uno slot collegato a un account e con l'email valorizzata, una riga inserita in notifiche_utente compare in Mailpit entro un ciclo di polling.

Test

  • Unit — test/unit/mailer-core.test.ts: configurazione, classificazione degli errori SMTP, backoff, tetto giornaliero, composizione della mail (escape, oggetto, link, Message-ID), salute, e il ciclo di invio con dipendenze finte (esiti, fermata su tetto e credenziali, registrazione dell'esito che fallisce, log senza dati personali). test/unit/preferenze-utente.test.ts: il default «acceso» senza riga.
  • Integrazione — test/integration/notifiche-email.test.ts, sul database locale: trigger su ogni tipo, rinvio da upsert, cron che non accoda due volte, cascata alla cancellazione, prendi_notifiche_email (destinatario, una sola volta, chiamate concorrenti senza doppioni, slot senza account o senza email, righe orfane recuperate), preferenza (default, spenta, riaccesa, RLS sulle righe altrui), tutti gli esiti, e i permessi (un giocatore o un admin non vedono la coda né chiamano le funzioni del worker).
  • Nessun test spedisce mail vere: il trasporto SMTP è sostituito da un finto nei test unitari, e nella prova end-to-end si usa Mailpit.

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ò mandare in spam un account nuovo che invia subito molti messaggi o un testo promozionale: si tiene un tono di servizio e i volumi di una squadra sono bassi.
  • Un account collegato a più slot giocatore (auth_user_id ripetuto) riceve una mail per slot, dato che notifiche_utente ha una riga per giocatore_id.
  • Cambiare l'indirizzo in giocatori_squadra.email cambia dove vanno le mail future; le righe già inviata non si toccano.
  • Nessun test automatico ha inviato una mail a Gmail: la prova con l'account vero fa parte della messa in funzione.
  • notifiche_email_coda non ha pulizia automatica, come notifiche_utente (DD-030): le righe spariscono quando la notifica viene eliminata.

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, nella stessa preferenze_utente, insieme a quelle della push.
  • Rendere il certificato in scadenza una notifica vera (sorgente nuova di notifiche_utente).