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>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-09-29 11:07:24 +02:00
1 parent 2a0b611459
commit b7d3fde225
24 files changed
+2138 -186

No files matched your search

+11
View File
@@ -13,6 +13,17 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato]
### Aggiunto
- **Notifiche email** — ogni notifica del centro notifiche in-app (messaggi dello staff,
promemoria a 24 e 3 ore, turno palloni, sollecito presenze) arriva anche per email
all'indirizzo Gmail con cui il giocatore si è registrato, gratis e senza aprire porte sul
router: un worker Docker (`mailer/`) sull'host di casa legge una coda su Supabase (migration
`m22_notifiche_email`) e invia tramite un account Gmail dedicato. In Profilo → Opzioni c'è
l'interruttore «Email», acceso di default. Il worker va messo in funzione a parte (vedi
[notifiche-email.md](modules/notifiche-email.md#messa-in-funzione)): finché la migration non è
applicata in produzione e il container non gira, non parte nessuna mail (DD-036).
## [1.1.1] - 2026-09-28
### Aggiunto
+10 -6
View File
@@ -19,6 +19,8 @@ nessuna di esse da M4 (DD-011).
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
@@ -71,12 +73,14 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
## Turni e notifiche
| Tabella | Scopo | Note |
| -------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
| Tabella | Scopo | Note |
| ---------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
| `notifiche_email_coda` | Coda delle email per le notifiche in-app: una riga per notifica, con stato d'invio. | Migration `m22_notifiche_email` (DD-036). Chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`. Stati: `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo` (backoff), `errore` (solo codici SMTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`): ogni notifica nuova e ogni rinvio via `upsert` finiscono in coda; le notifiche presenti prima della migration no. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`: la usa solo il worker `mailer/` con la service role, tramite tre funzioni `SECURITY DEFINER` riservate a `service_role` — `prendi_notifiche_email(p_max)` (lotto con `FOR UPDATE SKIP LOCKED`, salta gli slot senza account o email o con le email spente, recupera le righe ferme `in_invio` da più di 10 minuti), `esito_notifica_email(...)` e `email_inviate_ultime_24h()`. Tabella separata da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga gli permetterebbe di riportare a `in_coda` una mail già partita. Specifica in [notifiche-email.md](modules/notifiche-email.md). |
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Migration `m22_notifiche_email`. Chiave `auth_user_id` → `auth.users(id)` con cascade; oggi una sola colonna, `email_notifiche` (default `true`), più `aggiornata_il`. **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore «Email» in Profilo → Opzioni (upsert da `useEmailNotifiche()`, `src/lib/preferenze-utente.ts`). RLS: ognuno legge, inserisce e aggiorna solo la propria riga. |
## Funzioni speciali
+12 -6
View File
@@ -51,13 +51,13 @@ Serve a rispondere a domande del tipo:
| [DD-033](#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco) | Gestione eventi solo da calendario |
| [DD-034](#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore) | Ruolo allenatore |
| [DD-035](#dd-035--avviso-certificati-calcolato-in-home-visibile-solo-al-titolare-e-agli-admin) | Avviso certificati in Home |
| [DD-036](#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa) | Notifiche email via Gmail |
**In valutazione**
| 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 |
| ID | Titolo |
| ---------------------------------------------------------------- | --------------------- |
| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB |
**Sostituite**
@@ -1515,7 +1515,7 @@ giorni si rivelano pochi per prenotare una visita, alzare la costante o renderla
### DD-036 — Notifiche email via Gmail, da un worker Docker sull'host di casa
**Data:** 29 settembre 2026
**Stato:** In valutazione
**Stato:** Accettata
**Contesto**
Le notifiche di CrAPP arrivano solo con la push (che dipende da Chrome vivo in background,
@@ -1566,7 +1566,13 @@ invia tramite `smtp.gmail.com` con un account Gmail dedicato e una password per
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).
- Nuova migration (`m22_notifiche_email`), nuova cartella `mailer/` (worker bun con la sola
dipendenza `nodemailer`, separato dall'app) e un segreto in più (la service role sull'host).
L'app non guadagna nessuna dipendenza e le route di M17 non cambiano.
- Il certificato in scadenza resta fuori dalle email: non è una notifica (DD-035), quindi non
passa da `notifiche_utente`. Renderlo una notifica sarebbe una decisione a parte.
- Il tetto giornaliero è una finestra mobile di 24 ore, `MAIL_LIMITE_GIORNO` (400 di default,
sotto i circa 500 di Gmail).
- 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
+3 -3
View File
@@ -41,12 +41,12 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima e nero dopo la
scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035)
- [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati,
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
([specifica](modules/notifiche-email.md), DD-036)
## 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
+218 -160
View File
@@ -1,23 +1,27 @@
# 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)
**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](#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** 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`.
si è registrato, **a costo zero**. È un terzo canale accanto alla push e al centro notifiche
in-app ([notifiche.md](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](../DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa).
## Architettura
```
App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ notifiche_email_coda (Supabase)
▲
polling in uscita │ service role
polling in uscita │ RPC con service role
│
Container Docker sul tuo host (mailer)
│ SMTP 587 (STARTTLS)
@@ -25,201 +29,255 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
smtp.gmail.com ──▶ casella Gmail del giocatore
```
- **Vercel non ospita Docker**, quindi il worker gira sull'host di casa. Sta **solo in
- **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 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.
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**.
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. |
| 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 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.
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 (bozza, non ancora in `DATABASE.md`)
## Dati
Lo schema si documenta in [DATABASE.md](../DATABASE.md) nella stessa modifica che crea la
migration. La bozza:
Lo schema completo, con permessi e note, sta in [DATABASE.md](../DATABASE.md); qui il quadro
funzionale.
**`notifiche_email_coda`** — una riga per notifica da mandare.
**`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 | 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 |
| 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 |
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.
È 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`** — 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).
**`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).
**Indirizzo destinatario** — `giocatori_squadra.email`, già usata per il collegamento
automatico account↔giocatore (DD-018). Niente lettura di `auth.users`.
**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 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.
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`.
**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.
La preferenza è valutata **al momento dell'invio**, non dell'accodamento: spegnere l'interruttore
ferma anche le mail già in coda.
## 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. |
Il worker classifica l'errore SMTP (`classificaErroreSmtp`):
## Worker (`mailer/`)
| 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). |
Servizio Node piccolo: `@supabase/supabase-js` e `nodemailer`. Le dipendenze passano dal
controllo `minimumReleaseAge` di `bunfig.toml` come per il resto del progetto.
Altre situazioni:
```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
```
| 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. |
Variabili d'ambiente, mai committate (il `.env` sta nel `.gitignore`):
`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.
| 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**.
**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, 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).
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» 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.
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](../../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](../../test/unit/preferenze-utente.test.ts): il default
«acceso» senza riga.
- **Integrazione** — [test/integration/notifiche-email.test.ts](../../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ò 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).
- 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.
- 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.
- 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`).
+8 -1
View File
@@ -164,6 +164,14 @@ sparire per sempre è eliminarle una per una, con uno swipe verso sinistra sulla
× che compare sopra ogni riga per chi non è su touch. Chi non tocca mai una notifica se la
ritrova per sempre nell'elenco, solo segnata come letta.
## Canale email (M22)
Ogni riga di `notifiche_utente` viene inviata anche per email da un worker esterno, con
un interruttore «Email» per account in Profilo → Opzioni: [notifiche-email.md](notifiche-email.md).
Non cambia nulla di quanto descritto sopra: push, notifiche smart e centro notifiche restano
indipendenti e le quattro sorgenti scrivono `notifiche_utente` come prima. Il certificato in
scadenza resta una card in Home e non genera mail (DD-035).
## Limiti noti
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
@@ -216,7 +224,6 @@ 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).