Files
CRAPP/docs/modules/notifiche-email.md
T
davideandClaude Sonnet 5.5 2a0b611459 Documenta le notifiche email via Gmail da un worker Docker (DD-036)
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>
2026-09-29 10:04:04 +02:00

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_utente come 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_cron compresi, 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

  1. Una sorgente M17 inserisce (o aggiorna via upsert) una riga in notifiche_utente.
  2. Un trigger AFTER INSERT OR UPDATE OF creato_il la accoda in notifiche_email_coda, L'upsert di turno palloni e sollecito presenze aggiorna creato_il: è un rinvio voluto, e rimette in coda anche la mail.
  3. Il worker ogni ~30 secondi chiama la funzione RPC prendi_notifiche_email(n), riservata alla service role. Seleziona fino a n righe in_coda con FOR UPDATE SKIP LOCKED, le marca in_invio e restituisce titolo, corpo, evento_id e indirizzo, già filtrate per preferenza attiva e slot con auth_user_id valorizzato. Con SKIP LOCKED due worker avviati per errore non mandano la stessa mail due volte.
  4. Il worker invia con nodemailer, poi segna inviata o fallita.
  5. 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 .env con permessi 600; 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_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.

Test previsti

Come da AGENTS.md, le funzioni si scrivono con il loro test.

  • Unit (test/unit/, su notifiche-email-core.ts): calcolo del backoff, classificazione errore temporaneo/permanente, composizione di oggetto e corpo, Message-ID deterministico.
  • Integrazione (test/integration/, contro il database locale): il trigger accoda ogni nuova notifica; upsert rimette in coda; RLS senza policy (un client non legge né scrive la coda); prendi_notifiche_email non 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

  1. Migration m22_…: coda, preferenza, trigger, funzione RPC, RLS (poi DATABASE.md).
  2. notifiche-email-core.ts con i test unitari.
  3. Worker mailer/ con Dockerfile, compose, healthcheck.
  4. Interruttore in Profilo → Opzioni.
  5. Prova end-to-end su npx supabase start con un server SMTP di prova (es. Mailpit in Docker), poi con l'account Gmail vero.
  6. 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.