From b7d3fde22549afb7d7f310e9a7a71c693d916f19 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 29 Sep 2026 11:07:24 +0200 Subject: [PATCH] Implementa le notifiche email via Gmail da un worker Docker (DD-036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PROJECT_STATE.md | 16 +- docs/CHANGELOG.md | 11 + docs/DATABASE.md | 16 +- docs/DESIGN_DECISIONS.md | 18 +- docs/ROADMAP.md | 6 +- docs/modules/notifiche-email.md | 378 +++++++----- docs/modules/notifiche.md | 9 +- mailer/.dockerignore | 5 + mailer/.env.example | 22 + mailer/Dockerfile | 7 + mailer/bun.lock | 15 + mailer/bunfig.toml | 7 + mailer/docker-compose.yml | 16 + mailer/healthcheck.mjs | 11 + mailer/mailer-core.ts | 336 +++++++++++ mailer/mailer.mjs | 95 +++ mailer/package.json | 12 + src/lib/preferenze-utente.ts | 60 ++ src/routes/profilo.tsx | 40 +- .../20260929120000_m22_notifiche_email.sql | 239 ++++++++ test/README.md | 12 +- test/integration/notifiche-email.test.ts | 571 ++++++++++++++++++ test/unit/mailer-core.test.ts | 406 +++++++++++++ test/unit/preferenze-utente.test.ts | 16 + 24 files changed, 2138 insertions(+), 186 deletions(-) create mode 100644 mailer/.dockerignore create mode 100644 mailer/.env.example create mode 100644 mailer/Dockerfile create mode 100644 mailer/bun.lock create mode 100644 mailer/bunfig.toml create mode 100644 mailer/docker-compose.yml create mode 100644 mailer/healthcheck.mjs create mode 100644 mailer/mailer-core.ts create mode 100644 mailer/mailer.mjs create mode 100644 mailer/package.json create mode 100644 src/lib/preferenze-utente.ts create mode 100644 supabase/migrations/20260929120000_m22_notifiche_email.sql create mode 100644 test/integration/notifiche-email.test.ts create mode 100644 test/unit/mailer-core.test.ts create mode 100644 test/unit/preferenze-utente.test.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 6aab49e..e00a69e 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # Project State -Ultimo aggiornamento: 27/09/2026 +Ultimo aggiornamento: 29/09/2026 ## Stato generale @@ -24,8 +24,13 @@ da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16). - Cursor e Claude Code come ambienti di sviluppo - Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production) - Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes` -- 32 migration in `supabase/migrations/`, fino a `m21_ruolo_allenatore` (24/09/2026), tutte - applicate in produzione (verificato con `npx supabase migration list` il 24/09/2026) +- 33 migration in `supabase/migrations/`, fino a `m22_notifiche_email` (29/09/2026). Le prime + 32 (fino a `m21_ruolo_allenatore`) sono applicate in produzione, verificato con + `npx supabase migration list` il 24/09/2026; **`m22` è verificata solo in locale e non ancora + applicata in produzione** +- Il worker delle notifiche email (`mailer/`) non è ancora in funzione su nessun host: la messa + in funzione (account Gmail dedicato, `.env`, `docker compose up`) sta in + [docs/modules/notifiche-email.md](docs/modules/notifiche-email.md#messa-in-funzione) - Sviluppo locale verificato con il nuovo Supabase --- @@ -55,6 +60,11 @@ da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16). - Migration `m19_backfill_nascita_da_profili_esistenti` (15/09/2026, DD-031): completa il backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata in `npx supabase migration list` del 24/09/2026) +- Migration `m22_notifiche_email` (29/09/2026, DD-036): tabelle `notifiche_email_coda` e + `preferenze_utente`, trigger che accoda ogni notifica di `notifiche_utente` e tre funzioni + RPC riservate alla service role, per il worker email. Additiva: non tocca lo schema + esistente. Verificata in locale con `npx supabase db reset`, test unit e di integrazione e + una prova end-to-end con il worker in Docker contro Mailpit; **non ancora in produzione** - Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (24/09/2026, DD-034): ruolo allenatore (colonna `giocatori_squadra.tipo`, valore `allenatore` di `app_role`, policy e trigger) — verificate in locale con `npx supabase db reset` e `npm run test:all`, **in diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 639fed3..7b33182 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 67056d0..ddc511b 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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 diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 80a634a..905f5de 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c9e80ca..c1f1fd5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/modules/notifiche-email.md b/docs/modules/notifiche-email.md index 060b455..6a08620 100644 --- a/docs/modules/notifiche-email.md +++ b/docs/modules/notifiche-email.md @@ -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/`) | - -`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/` 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_`) e usa come host `supabase_kong_:8000` per il +database e `supabase_inbucket_` 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`). diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index ae614bd..34aa82f 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -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). diff --git a/mailer/.dockerignore b/mailer/.dockerignore new file mode 100644 index 0000000..b90a5f6 --- /dev/null +++ b/mailer/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.env +.env.* +!.env.example +README.md diff --git a/mailer/.env.example b/mailer/.env.example new file mode 100644 index 0000000..7a0d7f8 --- /dev/null +++ b/mailer/.env.example @@ -0,0 +1,22 @@ +# Copia in .env (permessi 600) e compila. Il .env non va mai committato. + +# Progetto Supabase e chiave service role (salta la RLS: trattala come una password) +SUPABASE_URL=https://.supabase.co +SUPABASE_SERVICE_ROLE_KEY= + +# Account Gmail dedicato con verifica in due passaggi e una password per app +SMTP_USER=crapp.notifiche@gmail.com +SMTP_PASS= + +# URL pubblico dell'app: base dei link «Apri CrAPP» nelle mail +APP_URL=https:// + +# Facoltative (valori di default) +# MAIL_FROM_NAME=CrAPP +# MAIL_FROM= # default: SMTP_USER +# POLL_SECONDI=30 +# MAIL_LIMITE_GIORNO=400 # tetto delle ultime 24 ore, sotto i ~500 di Gmail +# MAIL_LOTTO=10 +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_SECURE=false # true solo per la porta 465 diff --git a/mailer/Dockerfile b/mailer/Dockerfile new file mode 100644 index 0000000..78ad7d5 --- /dev/null +++ b/mailer/Dockerfile @@ -0,0 +1,7 @@ +FROM oven/bun:1-alpine +WORKDIR /app +COPY package.json bun.lock bunfig.toml ./ +RUN bun install --frozen-lockfile --production +COPY mailer.mjs mailer-core.ts healthcheck.mjs ./ +USER bun +CMD ["bun", "mailer.mjs"] diff --git a/mailer/bun.lock b/mailer/bun.lock new file mode 100644 index 0000000..a8ddbf2 --- /dev/null +++ b/mailer/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "crapp-mailer", + "dependencies": { + "nodemailer": "^10.0.11", + }, + }, + }, + "packages": { + "nodemailer": ["nodemailer@10.0.11", "", {}, "sha512-/c4P7U7aGpiNu2Rl08q525q2zECkUpSaf2zQPgIStCcAjHAkkQQKwD4sTyi3l91mGcLQrKzL1syXPHymo+Ogtw=="], + } +} diff --git a/mailer/bunfig.toml b/mailer/bunfig.toml new file mode 100644 index 0000000..830f3c5 --- /dev/null +++ b/mailer/bunfig.toml @@ -0,0 +1,7 @@ +[install] +saveTextLockfile = true +# 24h supply-chain guard: skip package versions published less than a day ago. +minimumReleaseAge = 86400 +# Each entry bypasses the 24h guard for one package — confirm with the user +# before adding any. +minimumReleaseAgeExcludes = [] diff --git a/mailer/docker-compose.yml b/mailer/docker-compose.yml new file mode 100644 index 0000000..b580de1 --- /dev/null +++ b/mailer/docker-compose.yml @@ -0,0 +1,16 @@ +services: + mailer: + build: . + restart: unless-stopped + env_file: .env + healthcheck: + test: ["CMD", "bun", "healthcheck.mjs"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: json-file + options: + max-size: "5m" + max-file: "3" diff --git a/mailer/healthcheck.mjs b/mailer/healthcheck.mjs new file mode 100644 index 0000000..b6d6897 --- /dev/null +++ b/mailer/healthcheck.mjs @@ -0,0 +1,11 @@ +// Healthcheck del container: sano se l'ultimo ciclo riuscito è recente (vedi mailer.mjs). +import { readFile } from "node:fs/promises"; +import { staSano } from "./mailer-core.ts"; + +const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok"; +const MAX_MS = 5 * 60_000; + +const ultimo = await readFile(FILE_SALUTE, "utf8") + .then((t) => Number(t)) + .catch(() => null); +process.exit(staSano(Number.isFinite(ultimo) ? ultimo : null, Date.now(), MAX_MS) ? 0 : 1); diff --git a/mailer/mailer-core.ts b/mailer/mailer-core.ts new file mode 100644 index 0000000..ddc4508 --- /dev/null +++ b/mailer/mailer-core.ts @@ -0,0 +1,336 @@ +/** + * Logica pura del worker email (M22, DD-036), separata da rete e SMTP per poterla testare: + * configurazione, classificazione degli errori SMTP, backoff, composizione della mail e il + * ciclo di invio con le dipendenze iniettate. `mailer.mjs` fa solo da colla. + */ + +/** Riga restituita da `prendi_notifiche_email()`. */ +export type NotificaEmail = { + id_notifica: string; + destinatario: string; + oggetto: string; + testo: string; + id_evento: string | null; + tipo_notifica: string; + tentativi_fatti: number; +}; + +export type EsitoInvio = "inviata" | "riprova" | "differita" | "fallita"; + +export type MailComposta = { + to: string; + subject: string; + text: string; + html: string; + messageId: string; +}; + +// --- Configurazione --------------------------------------------------------------------------- + +export type Config = { + supabaseUrl: string; + serviceRoleKey: string; + smtp: { + host: string; + port: number; + secure: boolean; + auth: { user: string; pass: string } | null; + }; + from: string; + fromName: string; + appUrl: string; + pollSecondi: number; + limiteGiorno: number; + lotto: number; +}; + +export type LetturaConfig = { config: Config } | { errori: string[] }; + +/** + * Legge la configurazione dalle variabili d'ambiente. Torna tutti gli errori insieme (mancanti + * e non validi) per correggere il `.env` in un solo giro. `SMTP_USER`/`SMTP_PASS` vanno o + * entrambi o nessuno dei due: senza servono solo per un server di prova locale (Mailpit). + */ +export function leggiConfig(env: Record): LetturaConfig { + const errori: string[] = []; + const testo = (nome: string): string => (env[nome] ?? "").trim(); + const obbligatoria = (nome: string): string => { + const valore = testo(nome); + if (!valore) errori.push(`${nome} mancante`); + return valore; + }; + const intero = (nome: string, predefinito: number): number => { + const grezzo = testo(nome); + if (!grezzo) return predefinito; + const n = Number(grezzo); + if (!Number.isInteger(n) || n <= 0) { + errori.push(`${nome} deve essere un intero positivo`); + return predefinito; + } + return n; + }; + + const supabaseUrl = obbligatoria("SUPABASE_URL").replace(/\/+$/, ""); + const serviceRoleKey = obbligatoria("SUPABASE_SERVICE_ROLE_KEY"); + const appUrl = obbligatoria("APP_URL").replace(/\/+$/, ""); + const user = testo("SMTP_USER"); + const pass = testo("SMTP_PASS"); + if (Boolean(user) !== Boolean(pass)) errori.push("SMTP_USER e SMTP_PASS vanno impostati insieme"); + const from = testo("MAIL_FROM") || user; + if (!from) errori.push("MAIL_FROM mancante (o SMTP_USER)"); + const porta = intero("SMTP_PORT", 587); + const pollSecondi = intero("POLL_SECONDI", 30); + const limiteGiorno = intero("MAIL_LIMITE_GIORNO", 400); + const lotto = intero("MAIL_LOTTO", 10); + + if (errori.length > 0) return { errori }; + return { + config: { + supabaseUrl, + serviceRoleKey, + smtp: { + host: testo("SMTP_HOST") || "smtp.gmail.com", + port: porta, + secure: testo("SMTP_SECURE") === "true", + auth: user ? { user, pass } : null, + }, + from, + fromName: testo("MAIL_FROM_NAME") || "CrAPP", + appUrl, + pollSecondi, + limiteGiorno, + lotto, + }, + }; +} + +// --- Errori SMTP e backoff -------------------------------------------------------------------- + +export type TipoErrore = "temporaneo" | "permanente" | "configurazione" | "limite"; + +type ErroreSmtp = { code?: unknown; responseCode?: unknown; response?: unknown; message?: unknown }; + +/** + * Distingue cosa fare dopo un invio fallito: + * - `limite`: tetto giornaliero di Gmail (`550 5.4.5 Daily user sending quota exceeded`, che + * sarebbe un 5xx "permanente" ma non dipende dalla mail): si rimanda senza contare un tentativo; + * - `configurazione`: credenziali SMTP rifiutate; stessa cosa, e il worker si ferma; + * - `permanente`: 5xx sul destinatario o sul messaggio, inutile riprovare; + * - `temporaneo`: 4xx e errori di rete, si riprova con backoff. + */ +export function classificaErroreSmtp(errore: unknown): TipoErrore { + const e = (typeof errore === "object" && errore !== null ? errore : {}) as ErroreSmtp; + const codice = typeof e.code === "string" ? e.code : ""; + const risposta = typeof e.responseCode === "number" ? e.responseCode : 0; + const testo = `${typeof e.response === "string" ? e.response : ""} ${ + typeof e.message === "string" ? e.message : "" + }`.toLowerCase(); + + if (/5\.4\.5|quota exceeded|sending limit/.test(testo)) return "limite"; + if (codice === "EAUTH" || [530, 534, 535].includes(risposta)) return "configurazione"; + if (codice === "EENVELOPE" || risposta >= 500) return "permanente"; + return "temporaneo"; +} + +const RITARDI_MINUTI = [1, 5, 30, 120] as const; + +/** Numero massimo di invii per notifica: il primo più un tentativo per ogni ritardo. */ +export const MAX_TENTATIVI = RITARDI_MINUTI.length + 1; + +/** + * Minuti da attendere dopo l'invio fallito numero `tentativiFatti + 1`; `null` quando i + * tentativi sono finiti e la mail va segnata come fallita. + */ +export function ritardoRiprova(tentativiFatti: number): number | null { + return RITARDI_MINUTI[tentativiFatti] ?? null; +} + +/** Quante mail si possono ancora spedire rispetto al tetto delle ultime 24 ore. */ +export function invioConsentiti(limiteGiorno: number, inviate24h: number, lotto: number): number { + return Math.max(0, Math.min(lotto, limiteGiorno - inviate24h)); +} + +/** Descrizione breve dell'errore, senza indirizzi né testi delle mail: va nei log. */ +export function descriviErrore(errore: unknown): string { + const e = (typeof errore === "object" && errore !== null ? errore : {}) as ErroreSmtp; + const parti = [ + typeof e.code === "string" ? e.code : "", + typeof e.responseCode === "number" ? String(e.responseCode) : "", + ].filter(Boolean); + return parti.length > 0 ? parti.join(" ") : "errore sconosciuto"; +} + +// --- Composizione della mail ------------------------------------------------------------------ + +export function escapeHtml(testo: string): string { + return testo + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Oggetto su una riga sola (niente a-capo nelle intestazioni), con un limite di lunghezza. */ +export function pulisciOggetto(titolo: string): string { + const pulito = titolo + .replace(/[\r\n]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return (pulito || "Notifica CrAPP").slice(0, 200); +} + +/** Link da aprire dalla mail: la scheda dell'evento se c'è, altrimenti la home dell'app. */ +export function linkNotifica(appUrl: string, idEvento: string | null): string { + const base = appUrl.replace(/\/+$/, ""); + return idEvento ? `${base}/evento/${encodeURIComponent(idEvento)}` : base; +} + +/** Message-ID deterministico: lo stesso invio ripetuto porta lo stesso identificativo. */ +export function messageId(idNotifica: string, dominio: string): string { + return `<${idNotifica}@${dominio}>`; +} + +const AVVISO_DISATTIVAZIONE = "Non vuoi più ricevere queste email? Profilo → Opzioni → Email."; + +export function componiMail( + n: NotificaEmail, + opzioni: { appUrl: string; dominioMessageId: string }, +): MailComposta { + const link = linkNotifica(opzioni.appUrl, n.id_evento); + const subject = pulisciOggetto(n.oggetto); + const testo = [n.testo.trim(), `Apri CrAPP: ${link}`, AVVISO_DISATTIVAZIONE] + .filter(Boolean) + .join("\n\n"); + const corpoHtml = n.testo.trim() + ? `

${escapeHtml(n.testo.trim()).replace(/\n/g, "
")}

` + : ""; + const html = [ + `
`, + `

${escapeHtml(subject)}

`, + corpoHtml, + `

Apri CrAPP

`, + `

${escapeHtml(AVVISO_DISATTIVAZIONE)}

`, + `
`, + ].join(""); + return { + to: n.destinatario, + subject, + text: testo, + html, + messageId: messageId(n.id_notifica, opzioni.dominioMessageId), + }; +} + +// --- Salute del container --------------------------------------------------------------------- + +/** Il worker è sano se l'ultimo ciclo riuscito non è più vecchio di `maxMs`. */ +export function staSano(ultimoCicloOk: number | null, adesso: number, maxMs: number): boolean { + return ultimoCicloOk !== null && adesso - ultimoCicloOk <= maxMs; +} + +// --- Ciclo di invio --------------------------------------------------------------------------- + +export type Dipendenze = { + prendi: (max: number) => Promise; + segna: (id: string, esito: EsitoInvio, errore?: string, prossimo?: Date) => Promise; + invia: (mail: MailComposta) => Promise; + inviateUltime24h: () => Promise; + adesso: () => Date; + log: (messaggio: string) => void; +}; + +export type OpzioniCiclo = { + limiteGiorno: number; + lotto: number; + appUrl: string; + dominioMessageId: string; +}; + +export type RisultatoCiclo = { + inviate: number; + riprovate: number; + fallite: number; + differite: number; + /** Perché il ciclo si è interrotto prima di finire il lotto, se è successo. */ + fermato: "limite" | "configurazione" | null; +}; + +const ATTESA_LIMITE_MINUTI = 60; +const ATTESA_CONFIGURAZIONE_MINUTI = 15; + +const dopoMinuti = (adesso: Date, minuti: number) => new Date(adesso.getTime() + minuti * 60_000); + +/** + * Un giro: prende fino a un lotto di mail entro il tetto giornaliero e le spedisce una a una. + * I log riportano solo id, tipo ed esito: mai indirizzi né testi. + */ +export async function elaboraCiclo(d: Dipendenze, o: OpzioniCiclo): Promise { + const risultato: RisultatoCiclo = { + inviate: 0, + riprovate: 0, + fallite: 0, + differite: 0, + fermato: null, + }; + + const consentite = invioConsentiti(o.limiteGiorno, await d.inviateUltime24h(), o.lotto); + if (consentite === 0) { + risultato.fermato = "limite"; + return risultato; + } + + const lotto = await d.prendi(consentite); + for (const [indice, notifica] of lotto.entries()) { + const mail = componiMail(notifica, o); + // Solo `invia` sta nel try: se fallisse la registrazione dell'esito non è un errore SMTP e + // non deve far ripartire una mail già consegnata (la riga resta 'in_invio' e viene + // recuperata dalla funzione SQL dopo 10 minuti: al peggio un doppione, mai una perdita). + let errore: unknown; + let consegnata = false; + try { + await d.invia(mail); + consegnata = true; + } catch (e) { + errore = e; + } + + if (consegnata) { + await d.segna(notifica.id_notifica, "inviata"); + risultato.inviate += 1; + d.log(`inviata ${notifica.id_notifica} (${notifica.tipo_notifica})`); + continue; + } + + const tipo = classificaErroreSmtp(errore); + const motivo = descriviErrore(errore); + + if (tipo === "limite" || tipo === "configurazione") { + // Non è colpa della mail: si rimanda questa e tutte le successive del lotto senza + // consumare tentativi, e il ciclo si ferma. + const prossimo = dopoMinuti( + d.adesso(), + tipo === "limite" ? ATTESA_LIMITE_MINUTI : ATTESA_CONFIGURAZIONE_MINUTI, + ); + for (const restante of lotto.slice(indice)) { + await d.segna(restante.id_notifica, "differita", motivo, prossimo); + risultato.differite += 1; + } + risultato.fermato = tipo; + d.log(`ciclo fermato (${tipo}, ${motivo}): ${risultato.differite} mail rimandate`); + return risultato; + } + + const ritardo = tipo === "temporaneo" ? ritardoRiprova(notifica.tentativi_fatti) : null; + if (ritardo === null) { + await d.segna(notifica.id_notifica, "fallita", motivo); + risultato.fallite += 1; + d.log(`fallita ${notifica.id_notifica} (${notifica.tipo_notifica}, ${tipo}, ${motivo})`); + } else { + await d.segna(notifica.id_notifica, "riprova", motivo, dopoMinuti(d.adesso(), ritardo)); + risultato.riprovate += 1; + d.log(`da riprovare ${notifica.id_notifica} tra ${ritardo} min (${motivo})`); + } + } + return risultato; +} diff --git a/mailer/mailer.mjs b/mailer/mailer.mjs new file mode 100644 index 0000000..e48663d --- /dev/null +++ b/mailer/mailer.mjs @@ -0,0 +1,95 @@ +// Worker delle notifiche email (M22, DD-036): legge la coda su Supabase e invia via SMTP. +// Solo chiamate in uscita, nessuna porta da aprire. Uso: bun mailer/mailer.mjs +// La logica sta in mailer-core.ts; qui c'è solo la colla con rete e SMTP. +import nodemailer from "nodemailer"; +import { writeFile } from "node:fs/promises"; +import { elaboraCiclo, leggiConfig } from "./mailer-core.ts"; + +const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok"; + +const lettura = leggiConfig(process.env); +if ("errori" in lettura) { + console.error(`Configurazione non valida:\n- ${lettura.errori.join("\n- ")}`); + process.exit(1); +} +const { config } = lettura; + +const log = (messaggio) => console.log(`${new Date().toISOString()} ${messaggio}`); + +async function rpc(nome, corpo) { + const risposta = await fetch(`${config.supabaseUrl}/rest/v1/rpc/${nome}`, { + method: "POST", + headers: { + apikey: config.serviceRoleKey, + Authorization: `Bearer ${config.serviceRoleKey}`, + "content-type": "application/json", + }, + body: JSON.stringify(corpo), + signal: AbortSignal.timeout(15_000), + }); + if (!risposta.ok) throw new Error(`rpc ${nome}: HTTP ${risposta.status}`); + const testo = await risposta.text(); + return testo ? JSON.parse(testo) : null; +} + +const trasporto = nodemailer.createTransport({ + host: config.smtp.host, + port: config.smtp.port, + secure: config.smtp.secure, + ...(config.smtp.auth ? { auth: config.smtp.auth } : {}), +}); + +const dominioMessageId = config.from.split("@")[1] ?? "crapp.local"; + +const dipendenze = { + prendi: (max) => rpc("prendi_notifiche_email", { p_max: max }), + segna: (id, esito, errore, prossimo) => + rpc("esito_notifica_email", { + p_id: id, + p_esito: esito, + p_errore: errore ?? null, + p_prossimo: prossimo ? prossimo.toISOString() : null, + }), + invia: (mail) => + trasporto.sendMail({ + from: { name: config.fromName, address: config.from }, + to: mail.to, + subject: mail.subject, + text: mail.text, + html: mail.html, + messageId: mail.messageId, + }), + inviateUltime24h: () => rpc("email_inviate_ultime_24h", {}), + adesso: () => new Date(), + log, +}; + +const opzioni = { + limiteGiorno: config.limiteGiorno, + lotto: config.lotto, + appUrl: config.appUrl, + dominioMessageId, +}; + +let fermo = false; +for (const segnale of ["SIGTERM", "SIGINT"]) process.on(segnale, () => (fermo = true)); +const attendi = (ms) => new Promise((ok) => setTimeout(ok, ms)); + +log(`avviato: SMTP ${config.smtp.host}:${config.smtp.port}, ogni ${config.pollSecondi}s`); +trasporto.verify().then( + () => log("SMTP raggiungibile e credenziali accettate"), + (errore) => log(`SMTP non verificato (${errore?.code ?? "errore"}): controlla SMTP_* nel .env`), +); + +while (!fermo) { + try { + const esito = await elaboraCiclo(dipendenze, opzioni); + // Un ciclo fermato dalle credenziali SMTP non segna il container come sano: dopo qualche + // minuto `docker ps` lo mostra "unhealthy" invece di lasciar credere che tutto funzioni. + if (esito.fermato !== "configurazione") await writeFile(FILE_SALUTE, String(Date.now())); + } catch (errore) { + console.error(`${new Date().toISOString()} ciclo fallito: ${errore?.message ?? errore}`); + } + await attendi(config.pollSecondi * 1000); +} +log("arrestato"); diff --git a/mailer/package.json b/mailer/package.json new file mode 100644 index 0000000..8fe16c7 --- /dev/null +++ b/mailer/package.json @@ -0,0 +1,12 @@ +{ + "name": "crapp-mailer", + "private": true, + "type": "module", + "description": "Worker delle notifiche email di CrAPP (M22, DD-036)", + "scripts": { + "start": "bun mailer.mjs" + }, + "dependencies": { + "nodemailer": "^10.0.11" + } +} diff --git a/src/lib/preferenze-utente.ts b/src/lib/preferenze-utente.ts new file mode 100644 index 0000000..31e9199 --- /dev/null +++ b/src/lib/preferenze-utente.ts @@ -0,0 +1,60 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle"; +import { useSessione } from "./auth"; + +/** + * Preferenze del singolo account (M22), non dello slot giocatore: oggi solo l'interruttore + * «Email» di Profilo → Opzioni (DD-036). Nessuna riga in `preferenze_utente` vuol dire + * «mail attive»: la riga nasce solo quando il giocatore tocca l'interruttore. Lettura e + * scrittura passano dalla RLS (ognuno vede e scrive solo la propria), senza route API. + */ +export type RigaPreferenze = { email_notifiche: boolean }; + +/** Assenza di riga = valori di default: le email sono attive. */ +export function emailAttive(riga: RigaPreferenze | null | undefined): boolean { + return riga?.email_notifiche ?? true; +} + +const chiavePreferenze = (utenteId: string | null) => ["preferenze-utente", utenteId] as const; + +/** Stato dell'interruttore «Email» dell'account e mutazione per cambiarlo. */ +export function useEmailNotifiche() { + const { utenteId } = useSessione(); + const qc = useQueryClient(); + + const query = useQuery({ + queryKey: chiavePreferenze(utenteId), + queryFn: async (): Promise => { + const { data, error } = await supabaseNuoveTabelle + .from("preferenze_utente") + .select("email_notifiche") + .eq("auth_user_id", utenteId) + .maybeSingle(); + if (error) throw error; + return (data as RigaPreferenze | null) ?? null; + }, + enabled: !!utenteId, + staleTime: 5 * 60_000, + }); + + const imposta = useMutation({ + mutationFn: async (attive: boolean) => { + const { error } = await supabaseNuoveTabelle.from("preferenze_utente").upsert( + { + auth_user_id: utenteId, + email_notifiche: attive, + aggiornata_il: new Date().toISOString(), + }, + { onConflict: "auth_user_id" }, + ); + if (error) throw error; + return attive; + }, + onSuccess: (attive) => { + const riga: RigaPreferenze = { email_notifiche: attive }; + qc.setQueryData(chiavePreferenze(utenteId), riga); + }, + }); + + return { attive: emailAttive(query.data), pronta: !query.isPending, imposta }; +} diff --git a/src/routes/profilo.tsx b/src/routes/profilo.tsx index 020417c..9d4ce5b 100644 --- a/src/routes/profilo.tsx +++ b/src/routes/profilo.tsx @@ -1,7 +1,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { Flame, Trash2, Bell, LogOut, ShieldCheck, Bug, Lightbulb } from "lucide-react"; +import { Flame, Trash2, Bell, Mail, LogOut, ShieldCheck, Bug, Lightbulb } from "lucide-react"; import { cn } from "@/lib/utils"; import { Card, PageHeader, StatTile, TeamLogo } from "@/components/crapp/ui-bits"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; @@ -34,6 +34,7 @@ import { } from "@/lib/giocatori-squadra"; import { esci } from "@/lib/auth"; import { useIsAdmin } from "@/lib/ruoli"; +import { useEmailNotifiche } from "@/lib/preferenze-utente"; import { Reveal } from "@/components/motion/Reveal"; import { version as APP_VERSION } from "../../package.json"; @@ -255,6 +256,7 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) { const [notifiche, setNotifiche] = useState(false); const [inCorso, setInCorso] = useState(false); const [supportate, setSupportate] = useState(true); + const email = useEmailNotifiche(); useEffect(() => { setSupportate(pushSupportato()); @@ -283,6 +285,17 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) { } } + async function cambiaEmail() { + if (email.imposta.isPending) return; + const attive = !email.attive; + try { + await email.imposta.mutateAsync(attive); + toast.success(attive ? "Email attive" : "Email disattivate"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Impossibile salvare la preferenza"); + } + } + async function logout() { try { await esci(); @@ -322,6 +335,31 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) { + {admin ? ( now() - interval '24 hours'; +$$; + +REVOKE ALL ON FUNCTION public.accoda_notifica_email() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.prendi_notifiche_email(integer) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.esito_notifica_email(uuid, text, text, timestamptz) + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.email_inviate_ultime_24h() FROM PUBLIC, anon, authenticated; + +GRANT EXECUTE ON FUNCTION public.prendi_notifiche_email(integer) TO service_role; +GRANT EXECUTE ON FUNCTION public.esito_notifica_email(uuid, text, text, timestamptz) + TO service_role; +GRANT EXECUTE ON FUNCTION public.email_inviate_ultime_24h() TO service_role; diff --git a/test/README.md b/test/README.md index 1601c86..2160a81 100644 --- a/test/README.md +++ b/test/README.md @@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push ## Struttura -| Cartella | Cosa verifica | Serve rete? | -| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup) | No | -| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì | -| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | -| `helpers/` | Avvio del server di test e mini-harness condiviso | — | +| Cartella | Cosa verifica | Serve rete? | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | +| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker email in `mailer/` e preferenza «Email») | No | +| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì | +| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | +| `helpers/` | Avvio del server di test e mini-harness condiviso | — | ## Database locale in Docker diff --git a/test/integration/notifiche-email.test.ts b/test/integration/notifiche-email.test.ts new file mode 100644 index 0000000..8a7861a --- /dev/null +++ b/test/integration/notifiche-email.test.ts @@ -0,0 +1,571 @@ +/** + * Notifiche email (M22, DD-036): `bun test/integration/notifiche-email.test.ts`. + * + * Copre il lato database del canale email: il trigger che accoda ogni notifica di + * `notifiche_utente`, le tre funzioni RPC del worker (`prendi_notifiche_email`, + * `esito_notifica_email`, `email_inviate_ultime_24h`), la preferenza per account e la RLS che + * tiene la coda lontana dai client. Il worker vero e la logica di invio sono coperti da + * `unit/mailer-core.test.ts`; qui non parte nessuna mail. + * + * Gira solo sullo stack locale (`npx supabase start`): usa gli slot `g4` (collegato a un + * account di prova) e `g5` (senza account) della rosa seed, con l'email impostata per la + * durata del test e ripristinata alla fine. + */ +import assert from "node:assert/strict"; +import { statoLocale } from "../helpers/locale"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const locale = statoLocale(); + +if (!locale) { + salta("notifiche email", "stack locale non attivo (npx supabase start)"); + riepilogo("notifiche-email"); +} else { + const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale; + console.log(`notifiche email su ${URL_BASE}`); + + const PREFISSO = "test-notifiche-email"; + // Un evento per test che ne ha bisogno: (giocatore, evento, tipo) è UNIQUE. + const EVENTO_TIPI = `${PREFISSO}-tipi`; + const EVENTO_UPSERT = `${PREFISSO}-upsert`; + const EVENTO_PRESA = `${PREFISSO}-presa`; + const EVENTO_CRON = `${PREFISSO}-cron`; + const SLOT_COLLEGATO = "g4"; + const SLOT_LIBERO = "g5"; + const EMAIL_COLLEGATO = `${PREFISSO}-g4@example.test`; + const EMAIL_LIBERO = `${PREFISSO}-g5@example.test`; + const PASSWORD = "prova-notifiche-email-123"; + + const rest = (percorso: string, token: string, init?: RequestInit) => + fetch(`${URL_BASE}/rest/v1/${percorso}`, { + ...init, + headers: { + apikey: token === SERVIZIO ? SERVIZIO : ANON, + Authorization: `Bearer ${token}`, + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + }); + + async function leggi(percorso: string, token = SERVIZIO): Promise { + const res = await rest(percorso, token); + if (!res.ok) throw new Error(`select su ${percorso}: ${res.status} ${await res.text()}`); + return (await res.json()) as T[]; + } + + async function scrivi(percorso: string, token: string, metodo: string, corpo: unknown) { + const res = await rest(percorso, token, { method: metodo, body: JSON.stringify(corpo) }); + if (!res.ok) throw new Error(`${metodo} su ${percorso}: ${res.status} ${await res.text()}`); + } + + async function rpc(nome: string, corpo: Record, token = SERVIZIO) { + const res = await fetch(`${URL_BASE}/rest/v1/rpc/${nome}`, { + method: "POST", + headers: { + apikey: token === SERVIZIO ? SERVIZIO : ANON, + Authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify(corpo), + }); + const testo = await res.text(); + return { ok: res.ok, stato: res.status, dati: (testo ? JSON.parse(testo) : null) as T }; + } + + async function creaUtente(email: string): Promise { + const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, { + method: "POST", + headers: { + apikey: SERVIZIO, + Authorization: `Bearer ${SERVIZIO}`, + "content-type": "application/json", + }, + body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }), + }); + const corpo = (await res.json()) as { id?: string }; + if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`); + return corpo.id; + } + + async function accedi(email: string): Promise { + const res = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, { + method: "POST", + headers: { apikey: ANON, "content-type": "application/json" }, + body: JSON.stringify({ email, password: PASSWORD }), + }); + const corpo = (await res.json()) as { access_token?: string }; + if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`); + return corpo.access_token; + } + + const eliminaUtente = (id: string) => + fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, { + method: "DELETE", + headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` }, + }); + + type Coda = { + notifica_id: string; + stato: string; + tentativi: number; + prossimo_tentativo: string; + errore: string | null; + inviata_il: string | null; + }; + type Preso = { + id_notifica: string; + destinatario: string; + oggetto: string; + testo: string; + id_evento: string | null; + tipo_notifica: string; + tentativi_fatti: number; + }; + + const creaNotifica = async ( + giocatoreId: string, + extra: Record = {}, + ): Promise => { + const res = await rest("notifiche_utente", SERVIZIO, { + method: "POST", + headers: { Prefer: "return=representation" }, + body: JSON.stringify({ + giocatore_id: giocatoreId, + tipo: "admin", + titolo: `${PREFISSO} titolo`, + corpo: `${PREFISSO} corpo`, + ...extra, + }), + }); + if (!res.ok) throw new Error(`insert notifica: ${res.status} ${await res.text()}`); + return ((await res.json()) as { id: string }[])[0]!.id; + }; + + const coda = async (id: string): Promise => + (await leggi(`notifiche_email_coda?notifica_id=eq.${id}&select=*`))[0]; + + /** Prende il lotto e tiene solo le righe di questo test: la coda locale può avere altro. */ + const prendi = async (mie: string[], max = 100): Promise => { + const r = await rpc("prendi_notifiche_email", { p_max: max }); + assert.ok(r.ok, `prendi_notifiche_email: ${r.stato}`); + return r.dati.filter((p) => mie.includes(p.id_notifica)); + }; + + const esito = async ( + id: string, + tipo: string, + errore: string | null = null, + prossimo: string | null = null, + ) => { + const r = await rpc("esito_notifica_email", { + p_id: id, + p_esito: tipo, + p_errore: errore, + p_prossimo: prossimo, + }); + assert.ok(r.ok, `esito_notifica_email: ${r.stato}`); + }; + + const idUtenti: string[] = []; + const creati: string[] = []; + let tokenAdmin = ""; + const originali = new Map(); + + try { + // --- preparazione: un admin, un giocatore collegato a g4, g5 senza account ----------- + const emailAdmin = `${PREFISSO}-admin-${Date.now()}@example.test`; + const emailGiocatore = `${PREFISSO}-giocatore-${Date.now()}@example.test`; + const idAdmin = await creaUtente(emailAdmin); + const idGiocatore = await creaUtente(emailGiocatore); + idUtenti.push(idAdmin, idGiocatore); + await scrivi("user_roles", SERVIZIO, "POST", { user_id: idAdmin, role: "admin" }); + tokenAdmin = await accedi(emailAdmin); + const tokenGiocatore = await accedi(emailGiocatore); + + for (const id of [SLOT_COLLEGATO, SLOT_LIBERO]) { + const [riga] = await leggi<{ email: string | null; auth_user_id: string | null }>( + `giocatori_squadra?id=eq.${id}&select=email,auth_user_id`, + ); + originali.set(id, riga ?? { email: null, auth_user_id: null }); + } + // Il trigger `enforce_giocatori_squadra_update` rifiuta la service key: serve l'admin. + await scrivi(`giocatori_squadra?id=eq.${SLOT_COLLEGATO}`, tokenAdmin, "PATCH", { + email: EMAIL_COLLEGATO, + auth_user_id: idGiocatore, + }); + await scrivi(`giocatori_squadra?id=eq.${SLOT_LIBERO}`, tokenAdmin, "PATCH", { + email: EMAIL_LIBERO, + }); + for (const id of [EVENTO_TIPI, EVENTO_UPSERT, EVENTO_PRESA]) { + await scrivi("eventi_app", SERVIZIO, "POST", { + id, + tipo: "allenamento", + titolo: `${PREFISSO} evento`, + data: "2030-01-01", + ora: "20:00", + convocati: [], + }); + } + + // --- 1. Trigger --------------------------------------------------------------------- + await prova("una notifica nuova finisce in coda, in_coda e con zero tentativi", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + const riga = await coda(id); + assert.equal(riga?.stato, "in_coda"); + assert.equal(riga?.tentativi, 0); + assert.equal(riga?.inviata_il, null); + }); + + await prova("ogni tipo di notifica esistente viene accodato", async () => { + for (const tipo of ["evento_promemoria_24h", "evento_promemoria_3h", "turno_palloni"]) { + const id = await creaNotifica(SLOT_COLLEGATO, { tipo, evento_id: EVENTO_TIPI }); + creati.push(id); + assert.equal((await coda(id))?.stato, "in_coda", tipo); + } + const id = await creaNotifica(SLOT_COLLEGATO, { + tipo: "sollecita_presenze", + evento_id: EVENTO_TIPI, + }); + creati.push(id); + assert.equal((await coda(id))?.stato, "in_coda", "sollecita_presenze"); + }); + + await prova("segnare come letta non rimette in coda una mail già inviata", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + const [presa] = await prendi([id]); + assert.ok(presa); + await esito(id, "inviata"); + await scrivi(`notifiche_utente?id=eq.${id}`, tokenGiocatore, "PATCH", { letta: true }); + assert.equal((await coda(id))?.stato, "inviata"); + }); + + await prova("il rinvio con upsert (creato_il riscritto) rimette in coda la mail", async () => { + const id = await creaNotifica(SLOT_COLLEGATO, { + tipo: "turno_palloni", + evento_id: EVENTO_UPSERT, + }); + creati.push(id); + await prendi([id]); + await esito(id, "inviata"); + assert.equal((await coda(id))?.stato, "inviata"); + + // Come `promemoria-palloni.ts` e `sollecita-presenze.ts`: stesso (giocatore, evento, tipo). + const res = await rest("notifiche_utente?on_conflict=giocatore_id,evento_id,tipo", SERVIZIO, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=minimal" }, + body: JSON.stringify({ + giocatore_id: SLOT_COLLEGATO, + tipo: "turno_palloni", + titolo: `${PREFISSO} aggiornato`, + corpo: "nuovo testo", + evento_id: EVENTO_UPSERT, + letta: false, + creato_il: new Date().toISOString(), + }), + }); + assert.ok(res.ok, `upsert: ${res.status}`); + const riga = await coda(id); + assert.equal(riga?.stato, "in_coda"); + assert.equal(riga?.tentativi, 0); + assert.equal(riga?.inviata_il, null); + }); + + await prova( + "il promemoria del cron accoda una volta sola, anche se il cron rigira", + async () => { + const adesso = new Date(Date.now() + 20 * 60 * 60 * 1000); + const data = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(adesso); + const ora = new Intl.DateTimeFormat("it-IT", { + timeZone: "Europe/Rome", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(adesso); + await scrivi("eventi_app", SERVIZIO, "POST", { + id: EVENTO_CRON, + tipo: "allenamento", + titolo: `${PREFISSO} cron`, + data, + ora, + convocati: [SLOT_COLLEGATO], + }); + const args = { p_tipo: "evento_promemoria_24h", p_finestra: "24 hours" }; + await rpc("genera_promemoria_eventi", args); + const [notifica] = await leggi<{ id: string }>( + `notifiche_utente?evento_id=eq.${EVENTO_CRON}&tipo=eq.evento_promemoria_24h&select=id`, + ); + assert.ok(notifica, "il cron ha creato la notifica"); + assert.equal((await coda(notifica.id))?.stato, "in_coda"); + + await prendi([notifica.id]); + await esito(notifica.id, "inviata"); + await rpc("genera_promemoria_eventi", args); + assert.equal( + (await coda(notifica.id))?.stato, + "inviata", + "ON CONFLICT DO NOTHING: nessun rinvio", + ); + }, + ); + + await prova("cancellare la notifica (swipe) toglie anche la riga in coda", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + assert.ok(await coda(id)); + await scrivi(`notifiche_utente?id=eq.${id}`, tokenGiocatore, "DELETE", undefined); + assert.equal(await coda(id), undefined); + }); + + // --- 2. prendi_notifiche_email ------------------------------------------------------ + await prova("prendi restituisce destinatario e testo e marca la riga in_invio", async () => { + const id = await creaNotifica(SLOT_COLLEGATO, { + titolo: `${PREFISSO} oggetto`, + corpo: `${PREFISSO} testo`, + evento_id: EVENTO_PRESA, + tipo: "sollecita_presenze", + }); + creati.push(id); + const [p] = await prendi([id]); + assert.ok(p, "la riga è stata presa"); + assert.equal(p.destinatario, EMAIL_COLLEGATO); + assert.equal(p.id_evento, EVENTO_PRESA); + assert.equal(p.tipo_notifica, "sollecita_presenze"); + assert.equal((await coda(id))?.stato, "in_invio"); + }); + + await prova("una seconda chiamata non restituisce di nuovo la stessa riga", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + assert.equal((await prendi([id])).length, 1); + assert.equal((await prendi([id])).length, 0); + }); + + await prova("due chiamate concorrenti si dividono le righe senza doppioni", async () => { + const ids = await Promise.all(Array.from({ length: 6 }, () => creaNotifica(SLOT_COLLEGATO))); + creati.push(...ids); + const [a, b] = await Promise.all([prendi(ids, 3), prendi(ids, 3)]); + const tutti = [...a, ...b].map((p) => p.id_notifica); + assert.equal(tutti.length, new Set(tutti).size, "nessuna riga presa due volte"); + assert.ok(tutti.length >= 3 && tutti.length <= 6); + // Le righe rimaste tornano prese al giro dopo, una sola volta ciascuna. + const resto = (await prendi(ids)).map((p) => p.id_notifica); + assert.deepEqual([...tutti, ...resto].sort(), [...ids].sort()); + }); + + await prova("slot senza account collegato: mail 'saltata', mai presa", async () => { + const id = await creaNotifica(SLOT_LIBERO); + creati.push(id); + assert.equal((await prendi([id])).length, 0); + const riga = await coda(id); + assert.equal(riga?.stato, "saltata"); + assert.equal(riga?.errore, null); + }); + + await prova("slot collegato ma senza email: 'saltata'", async () => { + await scrivi(`giocatori_squadra?id=eq.${SLOT_COLLEGATO}`, tokenAdmin, "PATCH", { + email: null, + }); + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + assert.equal((await prendi([id])).length, 0); + assert.equal((await coda(id))?.stato, "saltata"); + await scrivi(`giocatori_squadra?id=eq.${SLOT_COLLEGATO}`, tokenAdmin, "PATCH", { + email: EMAIL_COLLEGATO, + }); + }); + + await prova("la mail in coda viene ripresa dopo 10 minuti se il worker è morto", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + assert.equal((await prendi([id])).length, 1); + assert.equal((await prendi([id])).length, 0, "ancora in invio"); + const vecchia = new Date(Date.now() - 11 * 60_000).toISOString(); + await scrivi(`notifiche_email_coda?notifica_id=eq.${id}`, SERVIZIO, "PATCH", { + aggiornata_il: vecchia, + }); + assert.equal((await prendi([id])).length, 1, "recuperata"); + }); + + // --- 3. Preferenza per account ------------------------------------------------------ + await prova("nessuna riga in preferenze_utente vuol dire mail attive", async () => { + const righe = await leggi(`preferenze_utente?auth_user_id=eq.${idGiocatore}`); + assert.equal(righe.length, 0); + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + assert.equal((await prendi([id])).length, 1); + }); + + await prova("il giocatore spegne le email dal proprio account: 'saltata'", async () => { + const res = await rest("preferenze_utente?on_conflict=auth_user_id", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=minimal" }, + body: JSON.stringify({ auth_user_id: idGiocatore, email_notifiche: false }), + }); + assert.ok(res.ok, `upsert preferenza: ${res.status}`); + + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + assert.equal((await prendi([id])).length, 0); + assert.equal((await coda(id))?.stato, "saltata"); + }); + + await prova("riacceso l'interruttore le mail nuove ripartono", async () => { + await scrivi(`preferenze_utente?auth_user_id=eq.${idGiocatore}`, tokenGiocatore, "PATCH", { + email_notifiche: true, + }); + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + assert.equal((await prendi([id])).length, 1); + }); + + await prova("un giocatore non scrive né legge le preferenze di un altro account", async () => { + const res = await rest("preferenze_utente", tokenGiocatore, { + method: "POST", + body: JSON.stringify({ auth_user_id: idAdmin, email_notifiche: false }), + }); + assert.ok(!res.ok, "insert per un altro account rifiutato dalla RLS"); + await scrivi("preferenze_utente", SERVIZIO, "POST", { + auth_user_id: idAdmin, + email_notifiche: false, + }); + const viste = await leggi(`preferenze_utente?auth_user_id=eq.${idAdmin}`, tokenGiocatore); + assert.equal(viste.length, 0, "la riga altrui è invisibile"); + await scrivi(`preferenze_utente?auth_user_id=eq.${idAdmin}`, tokenGiocatore, "PATCH", { + email_notifiche: true, + }); + const [altrui] = await leggi<{ email_notifiche: boolean }>( + `preferenze_utente?auth_user_id=eq.${idAdmin}`, + ); + assert.equal(altrui?.email_notifiche, false, "l'update altrui non ha toccato nulla"); + }); + + // --- 4. esito_notifica_email -------------------------------------------------------- + await prova( + "'inviata': stato, orario, e la riga entra nel conteggio delle 24 ore", + async () => { + const prima = (await rpc("email_inviate_ultime_24h", {})).dati; + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + await prendi([id]); + await esito(id, "inviata"); + const riga = await coda(id); + assert.equal(riga?.stato, "inviata"); + assert.ok(riga?.inviata_il); + assert.equal((await rpc("email_inviate_ultime_24h", {})).dati, prima + 1); + }, + ); + + await prova( + "'riprova': conta un tentativo e non viene ripresa prima dell'ora fissata", + async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + await prendi([id]); + const tra1h = new Date(Date.now() + 60 * 60_000).toISOString(); + await esito(id, "riprova", "ETIMEDOUT", tra1h); + const riga = await coda(id); + assert.equal(riga?.stato, "in_coda"); + assert.equal(riga?.tentativi, 1); + assert.equal(riga?.errore, "ETIMEDOUT"); + assert.equal((await prendi([id])).length, 0, "non ancora scaduto il ritardo"); + + const passato = new Date(Date.now() - 1000).toISOString(); + await scrivi(`notifiche_email_coda?notifica_id=eq.${id}`, SERVIZIO, "PATCH", { + prossimo_tentativo: passato, + }); + const [p] = await prendi([id]); + assert.equal(p?.tentativi_fatti, 1, "il worker vede quanti tentativi sono già stati fatti"); + }, + ); + + await prova("'differita': torna in coda senza consumare un tentativo", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + await prendi([id]); + await esito(id, "differita", "550", new Date(Date.now() + 60 * 60_000).toISOString()); + const riga = await coda(id); + assert.equal(riga?.stato, "in_coda"); + assert.equal(riga?.tentativi, 0); + }); + + await prova("'fallita' è definitiva e non viene più presa", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + await prendi([id]); + await esito(id, "fallita", "550"); + assert.equal((await coda(id))?.stato, "fallita"); + assert.equal((await prendi([id])).length, 0); + }); + + await prova("un esito su una riga non 'in_invio' non cambia nulla", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + await esito(id, "inviata"); // ancora in_coda: nessuno l'ha presa + assert.equal((await coda(id))?.stato, "in_coda"); + }); + + await prova("un esito sconosciuto è rifiutato", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + const r = await rpc("esito_notifica_email", { p_id: id, p_esito: "boh" }); + assert.ok(!r.ok); + }); + + // --- 5. Permessi -------------------------------------------------------------------- + await prova("un giocatore non vede né scrive la coda", async () => { + const id = await creaNotifica(SLOT_COLLEGATO); + creati.push(id); + const lettura = await rest("notifiche_email_coda?select=*", tokenGiocatore); + const righe = lettura.ok ? ((await lettura.json()) as unknown[]) : []; + assert.equal(righe.length, 0); + const scrittura = await rest(`notifiche_email_coda?notifica_id=eq.${id}`, tokenGiocatore, { + method: "PATCH", + body: JSON.stringify({ stato: "in_coda", tentativi: 0 }), + }); + assert.ok(!scrittura.ok || (await scrittura.text()) === ""); + const inserimento = await rest("notifiche_email_coda", tokenGiocatore, { + method: "POST", + body: JSON.stringify({ notifica_id: id }), + }); + assert.ok(!inserimento.ok); + assert.equal((await coda(id))?.stato, "in_coda"); + }); + + await prova("un admin non può usare le funzioni del worker: solo la service role", async () => { + for (const [nome, corpo] of [ + ["prendi_notifiche_email", { p_max: 1 }], + ["esito_notifica_email", { p_id: creati[0], p_esito: "fallita" }], + ["email_inviate_ultime_24h", {}], + ] as const) { + for (const token of [tokenAdmin, tokenGiocatore]) { + const r = await rpc(nome, { ...corpo }, token); + assert.ok(!r.ok, `${nome} non deve essere chiamabile da un utente`); + } + } + }); + } finally { + await rest(`notifiche_utente?titolo=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" }); + await rest( + `notifiche_utente?evento_id=in.(${[EVENTO_TIPI, EVENTO_UPSERT, EVENTO_PRESA, EVENTO_CRON].join(",")})`, + SERVIZIO, + { method: "DELETE" }, + ); + for (const id of [EVENTO_TIPI, EVENTO_UPSERT, EVENTO_PRESA, EVENTO_CRON]) { + await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" }); + } + if (tokenAdmin) { + for (const [id, valori] of originali) { + await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, { + method: "PATCH", + body: JSON.stringify(valori), + }); + } + } + for (const id of idUtenti) { + await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" }); + await eliminaUtente(id); + } + riepilogo("notifiche-email"); + } +} diff --git a/test/unit/mailer-core.test.ts b/test/unit/mailer-core.test.ts new file mode 100644 index 0000000..0e32e80 --- /dev/null +++ b/test/unit/mailer-core.test.ts @@ -0,0 +1,406 @@ +/** Logica pura del worker email (M22): `bun test/unit/mailer-core.test.ts`. Nessuna rete né SMTP. */ +import assert from "node:assert/strict"; +import { + MAX_TENTATIVI, + classificaErroreSmtp, + componiMail, + descriviErrore, + elaboraCiclo, + escapeHtml, + invioConsentiti, + leggiConfig, + linkNotifica, + messageId, + pulisciOggetto, + ritardoRiprova, + staSano, + type Dipendenze, + type EsitoInvio, + type MailComposta, + type NotificaEmail, +} from "../../mailer/mailer-core.ts"; +import { prova, riepilogo } from "../helpers/prova"; + +const ENV_BASE = { + SUPABASE_URL: "https://x.supabase.co/", + SUPABASE_SERVICE_ROLE_KEY: "chiave", + SMTP_USER: "crapp@gmail.com", + SMTP_PASS: "password-per-app", + APP_URL: "https://crapp.example/", +}; + +// --- leggiConfig -------------------------------------------------------------- +await prova("configurazione completa: default, slash finali tolti, From = SMTP_USER", () => { + const r = leggiConfig(ENV_BASE); + assert.ok("config" in r); + const c = r.config; + assert.equal(c.supabaseUrl, "https://x.supabase.co"); + assert.equal(c.appUrl, "https://crapp.example"); + assert.equal(c.from, "crapp@gmail.com"); + assert.equal(c.fromName, "CrAPP"); + assert.deepEqual(c.smtp, { + host: "smtp.gmail.com", + port: 587, + secure: false, + auth: { user: "crapp@gmail.com", pass: "password-per-app" }, + }); + assert.equal(c.pollSecondi, 30); + assert.equal(c.limiteGiorno, 400); + assert.equal(c.lotto, 10); +}); + +await prova("elenca insieme tutte le variabili mancanti", () => { + const r = leggiConfig({}); + assert.ok("errori" in r); + assert.deepEqual(r.errori, [ + "SUPABASE_URL mancante", + "SUPABASE_SERVICE_ROLE_KEY mancante", + "APP_URL mancante", + "MAIL_FROM mancante (o SMTP_USER)", + ]); +}); + +await prova("SMTP_USER e SMTP_PASS vanno insieme", () => { + const r = leggiConfig({ ...ENV_BASE, SMTP_PASS: "" }); + assert.ok("errori" in r); + assert.ok(r.errori.includes("SMTP_USER e SMTP_PASS vanno impostati insieme")); +}); + +await prova("senza credenziali SMTP (server di prova) serve MAIL_FROM e non c'è auth", () => { + const { SMTP_USER: _u, SMTP_PASS: _p, ...senza } = ENV_BASE; + assert.ok("errori" in leggiConfig(senza)); + const r = leggiConfig({ ...senza, MAIL_FROM: "prova@crapp.local", SMTP_PORT: "1025" }); + assert.ok("config" in r); + assert.equal(r.config.smtp.auth, null); + assert.equal(r.config.smtp.port, 1025); +}); + +await prova("un numero non valido è un errore, non un default silenzioso", () => { + for (const valore of ["0", "-5", "abc", "1.5"]) { + const r = leggiConfig({ ...ENV_BASE, POLL_SECONDI: valore }); + assert.ok("errori" in r, valore); + assert.deepEqual(r.errori, ["POLL_SECONDI deve essere un intero positivo"]); + } +}); + +// --- classificaErroreSmtp ----------------------------------------------------- +await prova("il tetto giornaliero di Gmail non è un errore permanente", () => { + const errore = { + responseCode: 550, + response: "550-5.4.5 Daily user sending quota exceeded.", + }; + assert.equal(classificaErroreSmtp(errore), "limite"); +}); + +await prova("credenziali rifiutate: configurazione, non colpa della mail", () => { + assert.equal(classificaErroreSmtp({ code: "EAUTH", responseCode: 535 }), "configurazione"); + assert.equal(classificaErroreSmtp({ responseCode: 534 }), "configurazione"); +}); + +await prova("5xx sul destinatario e indirizzo non valido sono permanenti", () => { + assert.equal( + classificaErroreSmtp({ responseCode: 550, response: "550 no such user" }), + "permanente", + ); + assert.equal(classificaErroreSmtp({ code: "EENVELOPE" }), "permanente"); +}); + +await prova("4xx e errori di rete sono temporanei", () => { + assert.equal(classificaErroreSmtp({ responseCode: 421 }), "temporaneo"); + assert.equal(classificaErroreSmtp({ code: "ETIMEDOUT" }), "temporaneo"); + assert.equal(classificaErroreSmtp({ code: "ECONNECTION" }), "temporaneo"); + assert.equal(classificaErroreSmtp(new Error("boh")), "temporaneo"); + assert.equal(classificaErroreSmtp(null), "temporaneo"); +}); + +await prova("descriviErrore riporta solo codici, mai il testo della risposta", () => { + const errore = { code: "EMESSAGE", responseCode: 550, response: "550 nome@gmail.com non esiste" }; + assert.equal(descriviErrore(errore), "EMESSAGE 550"); + assert.equal(descriviErrore(new Error("mario@gmail.com")), "errore sconosciuto"); +}); + +// --- backoff e tetto ---------------------------------------------------------- +await prova("backoff 1 · 5 · 30 · 120 minuti, poi nessun altro tentativo", () => { + assert.deepEqual([0, 1, 2, 3].map(ritardoRiprova), [1, 5, 30, 120]); + assert.equal(ritardoRiprova(4), null); + assert.equal(MAX_TENTATIVI, 5); +}); + +await prova("invioConsentiti non supera né il lotto né il tetto delle 24 ore", () => { + assert.equal(invioConsentiti(400, 0, 10), 10); + assert.equal(invioConsentiti(400, 395, 10), 5); + assert.equal(invioConsentiti(400, 400, 10), 0); + assert.equal(invioConsentiti(400, 450, 10), 0); +}); + +await prova("staSano: sano solo con un ciclo riuscito abbastanza recente", () => { + assert.equal(staSano(null, 1_000_000, 300_000), false); + assert.equal(staSano(900_000, 1_000_000, 300_000), true); + assert.equal(staSano(600_000, 1_000_000, 300_000), false); +}); + +// --- composizione della mail -------------------------------------------------- +const notifica = (extra: Partial = {}): NotificaEmail => ({ + id_notifica: "n-1", + destinatario: "mario@gmail.com", + oggetto: "Promemoria: Allenamento", + testo: "30/09/2026 alle 20:30", + id_evento: "ev-1", + tipo_notifica: "evento_promemoria_24h", + tentativi_fatti: 0, + ...extra, +}); +const OPZ = { appUrl: "https://crapp.example", dominioMessageId: "gmail.com" }; + +await prova("link: scheda evento se c'è, altrimenti la home; id codificato", () => { + assert.equal(linkNotifica("https://a.it/", "ev-1"), "https://a.it/evento/ev-1"); + assert.equal(linkNotifica("https://a.it", null), "https://a.it"); + assert.equal(linkNotifica("https://a.it", "a b/c"), "https://a.it/evento/a%20b%2Fc"); +}); + +await prova("Message-ID deterministico per notifica", () => { + assert.equal(messageId("abc", "gmail.com"), ""); + assert.equal(componiMail(notifica(), OPZ).messageId, componiMail(notifica(), OPZ).messageId); +}); + +await prova("oggetto su una riga sola, con un tetto e un ripiego", () => { + assert.equal(pulisciOggetto("Ciao\r\nBcc: x@y.it"), "Ciao Bcc: x@y.it"); + assert.equal(pulisciOggetto(" "), "Notifica CrAPP"); + assert.equal(pulisciOggetto("a".repeat(500)).length, 200); +}); + +await prova("la mail ha testo, HTML, link all'evento e come disattivarle", () => { + const m = componiMail(notifica(), OPZ); + assert.equal(m.to, "mario@gmail.com"); + assert.equal(m.subject, "Promemoria: Allenamento"); + assert.match(m.text, /30\/09\/2026 alle 20:30/); + assert.match(m.text, /https:\/\/crapp\.example\/evento\/ev-1/); + assert.match(m.text, /Profilo → Opzioni → Email/); + assert.match(m.html, /href="https:\/\/crapp\.example\/evento\/ev-1"/); + assert.match(m.html, /Apri CrAPP/); +}); + +await prova("il testo dell'admin non inietta HTML e conserva gli a-capo", () => { + const m = componiMail( + notifica({ testo: `\nriga "2"`, id_evento: null }), + OPZ, + ); + assert.ok(!m.html.includes("riga "2"/); + assert.match(m.html, /href="https:\/\/crapp\.example"/); + assert.equal(escapeHtml(`<&>"'`), "<&>"'"); +}); + +await prova("titolo con HTML: l'intestazione della mail è sempre esclusa dal markup", () => { + const m = componiMail(notifica({ oggetto: "Ciao" }), OPZ); + assert.ok(!m.html.includes("")); +}); + +await prova("corpo vuoto: niente paragrafo vuoto", () => { + const m = componiMail(notifica({ testo: "" }), OPZ); + assert.ok(!m.html.includes('

')); + assert.ok(!m.text.startsWith("\n")); +}); + +// --- elaboraCiclo ------------------------------------------------------------- +type Chiamata = { id: string; esito: EsitoInvio; errore?: string; prossimo?: Date }; + +function ambiente(opzioni: { + lotto?: NotificaEmail[]; + inviate24h?: number; + invia?: (mail: MailComposta) => Promise; + segna?: Dipendenze["segna"]; +}) { + const chiamate: Chiamata[] = []; + const inviate: MailComposta[] = []; + const log: string[] = []; + let richiesto = -1; + const ADESSO = new Date("2026-09-29T10:00:00Z"); + const dip: Dipendenze = { + prendi: async (max) => { + richiesto = max; + return opzioni.lotto ?? []; + }, + segna: + opzioni.segna ?? + (async (id, esito, errore, prossimo) => { + chiamate.push({ + id, + esito, + ...(errore !== undefined ? { errore } : {}), + ...(prossimo ? { prossimo } : {}), + }); + }), + invia: + opzioni.invia ?? + (async (mail) => { + inviate.push(mail); + }), + inviateUltime24h: async () => opzioni.inviate24h ?? 0, + adesso: () => ADESSO, + log: (m) => log.push(m), + }; + return { dip, chiamate, inviate, log, ADESSO, richiesto: () => richiesto }; +} +const OPZ_CICLO = { limiteGiorno: 400, lotto: 10, ...OPZ }; + +await prova("ciclo: invia ogni mail e segna 'inviata'", async () => { + const a = ambiente({ lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })] }); + const r = await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(r.inviate, 2); + assert.deepEqual( + a.chiamate.map((c) => [c.id, c.esito]), + [ + ["a", "inviata"], + ["b", "inviata"], + ], + ); + assert.equal(a.inviate.length, 2); +}); + +await prova("ciclo: chiede al massimo quanto consente il tetto giornaliero", async () => { + const a = ambiente({ inviate24h: 396 }); + await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(a.richiesto(), 4); +}); + +await prova("ciclo: tetto raggiunto, non prende nulla dalla coda", async () => { + const a = ambiente({ inviate24h: 400, lotto: [notifica()] }); + const r = await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(r.fermato, "limite"); + assert.equal(a.richiesto(), -1); + assert.equal(a.chiamate.length, 0); +}); + +await prova("ciclo: errore temporaneo, riprova con backoff sul numero di tentativi", async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a", tentativi_fatti: 2 })], + invia: async () => { + throw { code: "ETIMEDOUT" }; + }, + }); + const r = await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(r.riprovate, 1); + const [c] = a.chiamate; + assert.equal(c?.esito, "riprova"); + assert.equal(c?.prossimo?.getTime(), a.ADESSO.getTime() + 30 * 60_000); +}); + +await prova("ciclo: all'ultimo tentativo l'errore temporaneo diventa 'fallita'", async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a", tentativi_fatti: 4 })], + invia: async () => { + throw { responseCode: 421 }; + }, + }); + const r = await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(r.fallite, 1); + assert.equal(a.chiamate[0]?.esito, "fallita"); +}); + +await prova("ciclo: errore permanente, subito 'fallita' senza riprovare", async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a" })], + invia: async () => { + throw { responseCode: 550, response: "550 no such user" }; + }, + }); + await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(a.chiamate[0]?.esito, "fallita"); + assert.equal(a.chiamate[0]?.errore, "550"); +}); + +await prova("ciclo: una mail fallita non ferma le successive", async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })], + invia: async (mail) => { + if (mail.messageId.startsWith(" { + let n = 0; + const a = ambiente({ + lotto: [ + notifica({ id_notifica: "a" }), + notifica({ id_notifica: "b" }), + notifica({ id_notifica: "c" }), + ], + invia: async () => { + n += 1; + if (n === 2) + throw { responseCode: 550, response: "550-5.4.5 Daily user sending quota exceeded" }; + }, + }); + const r = await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(r.fermato, "limite"); + assert.equal(r.inviate, 1); + assert.equal(r.differite, 2); + assert.deepEqual( + a.chiamate.map((c) => [c.id, c.esito]), + [ + ["a", "inviata"], + ["b", "differita"], + ["c", "differita"], + ], + ); + assert.equal(a.chiamate[1]?.prossimo?.getTime(), a.ADESSO.getTime() + 60 * 60_000); + }, +); + +await prova( + "ciclo: credenziali SMTP rifiutate, tutto differito senza consumare tentativi", + async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })], + invia: async () => { + throw { code: "EAUTH", responseCode: 535 }; + }, + }); + const r = await elaboraCiclo(a.dip, OPZ_CICLO); + assert.equal(r.fermato, "configurazione"); + assert.deepEqual( + a.chiamate.map((c) => c.esito), + ["differita", "differita"], + ); + assert.equal(r.fallite, 0); + }, +); + +await prova( + "ciclo: se la registrazione dell'esito fallisce non si riprova la mail già inviata", + async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a" })], + segna: async () => { + throw new Error("rete giù"); + }, + }); + await assert.rejects(elaboraCiclo(a.dip, OPZ_CICLO), /rete giù/); + assert.equal(a.inviate.length, 1); + }, +); + +await prova("ciclo: i log non contengono indirizzi né testi", async () => { + const a = ambiente({ + lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })], + invia: async (mail) => { + if (mail.messageId.startsWith(" { + assert.equal(emailAttive(null), true); + assert.equal(emailAttive(undefined), true); +}); + +await prova("con la riga vale il valore scelto dal giocatore", () => { + assert.equal(emailAttive({ email_notifiche: true }), true); + assert.equal(emailAttive({ email_notifiche: false }), false); +}); + +riepilogo("preferenze-utente");