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>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-09-29 10:04:04 +02:00
1 parent cf667453a7
commit 2a0b611459
4 files changed
+298 -3

No files matched your search

+69 -3
View File
@@ -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.
+3
View File
@@ -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
+225
View File
@@ -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/<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](../../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.
+1
View File
@@ -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).