From 2a0b611459e35d37e3dbdb32fb7faacd27ea9b7c Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 29 Sep 2026 10:04:04 +0200 Subject: [PATCH] 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 --- docs/DESIGN_DECISIONS.md | 72 +++++++++- docs/ROADMAP.md | 3 + docs/modules/notifiche-email.md | 225 ++++++++++++++++++++++++++++++++ docs/modules/notifiche.md | 1 + 4 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 docs/modules/notifiche-email.md diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 4a7bea2..80a634a 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -54,9 +54,10 @@ Serve a rispondere a domande del tipo: **In valutazione** -| ID | Titolo | -| ---------------------------------------------------------------- | --------------------- | -| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB | +| ID | Titolo | +| --------------------------------------------------------------------------------- | ------------------------- | +| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB | +| [DD-036](#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa) | Notifiche email via Gmail | **Sostituite** @@ -1510,3 +1511,68 @@ certificato viene rinnovato. Specifica completa in Se gli avvisi in Home non bastano (certificati che scadono comunque senza essere rinnovati), valutare una push, legata a un cron o a un invio manuale dell'admin come per i palloni. Se 7 giorni si rivelano pochi per prenotare una visita, alzare la costante o renderla regolabile. + +### DD-036 — Notifiche email via Gmail, da un worker Docker sull'host di casa + +**Data:** 29 settembre 2026 +**Stato:** In valutazione + +**Contesto** +Le notifiche di CrAPP arrivano solo con la push (che dipende da Chrome vivo in background, +vedi i limiti in `docs/modules/notifiche.md`) e nel centro notifiche in-app, che si vede solo +aprendo l'app. Chi non ha attivato la push o ha un telefono aggressivo sul risparmio +energetico non riceve nulla ad app chiusa. Serve un canale che arrivi comunque, **gratis**, ai +giocatori registrati, che sono tutti su Gmail perché l'accesso è con Google. L'app gira su +Vercel, che non esegue container né processi sempre attivi. + +**Decisione** +Le email partono da un **worker Docker sull'host di casa** che legge una coda su Supabase e +invia tramite `smtp.gmail.com` con un account Gmail dedicato e una password per app. + +- La coda è `notifiche_email_coda`, riempita da un trigger su `notifiche_utente`: ogni + notifica già esistente, di qualunque tipo e per qualunque destinatario (admin compresi, + con la stessa logica degli altri), diventa anche una mail. Le sorgenti di M17 non cambiano, + non ne nascono di nuove e l'app non conosce le email. Il certificato in scadenza resta + fuori: è una card in Home, non una notifica (DD-035). +- Il worker fa solo polling **in uscita** con la service role: nessuna porta aperta sull'host. +- La coda è una tabella a parte, con RLS senza policy, perché la policy `UPDATE` di + `notifiche_utente` lascerebbe a un client la possibilità di rimettere in coda una mail. +- Canale **opt-out**: acceso di default per tutti, con un interruttore per account in + Profilo → Opzioni. La preferenza sta in una tabella nuova, `preferenze_utente`, una riga + per account (non per slot); l'assenza di riga vale «acceso». +- Specifica completa in [notifiche-email.md](modules/notifiche-email.md). + +**Alternative scartate** + +- Inviare direttamente dal proprio IP con un MTA (Postfix) → scartata: porta 25 bloccata dai + provider residenziali, IP nelle blocklist, niente reverse DNS; le mail finirebbero in spam o + sarebbero rifiutate. +- Relay Brevo o Resend → scartati per ora: il piano gratuito funziona ma per una buona + consegna richiede un dominio con SPF/DKIM/DMARC, che oggi non c'è. Restano l'alternativa + se servirà un mittente della squadra. +- Supabase Edge Function con `pg_cron` e un relay → scartata su richiesta esplicita di un + servizio dockerizzato sull'host; resta la strada senza host acceso, ma dipende comunque da un + relay esterno. +- Vercel Cron → scartato: sul piano gratuito gira al massimo una volta al giorno, troppo poco + per i promemoria a 24h e 3h. +- Chiamare l'host dall'app (webhook verso casa) → scartato: richiederebbe un IP pubblico o un + tunnel e una superficie d'attacco in più; il polling in uscita non ne ha. +- Colonne di stato sulla riga di `notifiche_utente` → scartate per la RLS spiegata sopra. + +**Conseguenze** + +- Costo zero, ma il canale email dipende da un host acceso: se è spento le mail arrivano in + ritardo (mai perse). Consegna _almeno una volta_: in caso di crash tra invio e + registrazione può arrivare un doppione. +- Il tetto di circa 500 destinatari al giorno di Gmail è largo per una squadra, ma è un limite + reale: con molte squadre o riepiloghi frequenti andrebbe rivisto. +- Nuova migration, nuova cartella `mailer/` e un segreto in più (la service role sull'host). +- Le email dei giocatori si usano per un secondo scopo oltre al login: da qui l'opt-out e + l'indicazione in ogni mail su come disattivarlo. +- Una tabella in più, `preferenze_utente`, pensata per accogliere in futuro anche le + preferenze per tipo di notifica senza toccare `giocatori_squadra`. + +**Riesame** +Se serve un mittente col dominio della squadra, se si supera il tetto giornaliero di Gmail, o +se l'host di casa si rivela poco affidabile: passare a un relay con dominio e, in quel caso, +valutare Edge Function più `pg_cron` al posto del container. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2f329a1..c9e80ca 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -44,6 +44,9 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`). ## Prossimo +- [ ] Notifiche email — le notifiche già in `notifiche_utente` inviate anche per email ai + giocatori, da un worker Docker sull'host di casa via Gmail + ([specifica](modules/notifiche-email.md), DD-036) - [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina Campionato usa solo quelle giocate - [ ] Database esercizi diff --git a/docs/modules/notifiche-email.md b/docs/modules/notifiche-email.md new file mode 100644 index 0000000..060b455 --- /dev/null +++ b/docs/modules/notifiche-email.md @@ -0,0 +1,225 @@ +# 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](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](../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. + +```yaml +# 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/`) | + +`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](../../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. diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index 9778260..ae614bd 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -216,6 +216,7 @@ ritrova per sempre nell'elenco, solo segnata come letta. ## Evoluzioni possibili +- Canale email, progettato ma non ancora implementato: [notifiche-email.md](notifiche-email.md). - Preferenze per canale (palloni, solleciti, smart), se servono davvero alla squadra. - Eliminare `promemoria_push` con una migrazione. - Gestire esplicitamente il caso iOS (messaggio se l'app non è installata da Home).