Implementa le notifiche email via Gmail da un worker Docker (DD-036)

Migration m22: coda notifiche_email_coda, preferenze_utente, trigger che
accoda ogni notifica di notifiche_utente e tre funzioni RPC riservate
alla service role. Worker in mailer/ (bun + nodemailer, solo polling in
uscita) con backoff, tetto giornaliero e healthcheck. Interruttore
«Email» in Profilo → Opzioni, acceso di default. Test unit e di
integrazione; documentazione aggiornata (DD-036 accettata).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-09-29 11:07:24 +02:00
1 parent 2a0b611459
commit b7d3fde225
24 files changed
+2138 -186

No files matched your search

+13 -3
View File
@@ -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
+11
View File
@@ -13,6 +13,17 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato]
### Aggiunto
- **Notifiche email** — ogni notifica del centro notifiche in-app (messaggi dello staff,
promemoria a 24 e 3 ore, turno palloni, sollecito presenze) arriva anche per email
all'indirizzo Gmail con cui il giocatore si è registrato, gratis e senza aprire porte sul
router: un worker Docker (`mailer/`) sull'host di casa legge una coda su Supabase (migration
`m22_notifiche_email`) e invia tramite un account Gmail dedicato. In Profilo → Opzioni c'è
l'interruttore «Email», acceso di default. Il worker va messo in funzione a parte (vedi
[notifiche-email.md](modules/notifiche-email.md#messa-in-funzione)): finché la migration non è
applicata in produzione e il container non gira, non parte nessuna mail (DD-036).
## [1.1.1] - 2026-09-28
### Aggiunto
+10 -6
View File
@@ -19,6 +19,8 @@ nessuna di esse da M4 (DD-011).
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
@@ -71,12 +73,14 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
## Turni e notifiche
| Tabella | Scopo | Note |
| -------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
| Tabella | Scopo | Note |
| ---------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
| `notifiche_email_coda` | Coda delle email per le notifiche in-app: una riga per notifica, con stato d'invio. | Migration `m22_notifiche_email` (DD-036). Chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`. Stati: `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo` (backoff), `errore` (solo codici SMTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`): ogni notifica nuova e ogni rinvio via `upsert` finiscono in coda; le notifiche presenti prima della migration no. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`: la usa solo il worker `mailer/` con la service role, tramite tre funzioni `SECURITY DEFINER` riservate a `service_role` — `prendi_notifiche_email(p_max)` (lotto con `FOR UPDATE SKIP LOCKED`, salta gli slot senza account o email o con le email spente, recupera le righe ferme `in_invio` da più di 10 minuti), `esito_notifica_email(...)` e `email_inviate_ultime_24h()`. Tabella separata da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga gli permetterebbe di riportare a `in_coda` una mail già partita. Specifica in [notifiche-email.md](modules/notifiche-email.md). |
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Migration `m22_notifiche_email`. Chiave `auth_user_id` → `auth.users(id)` con cascade; oggi una sola colonna, `email_notifiche` (default `true`), più `aggiornata_il`. **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore «Email» in Profilo → Opzioni (upsert da `useEmailNotifiche()`, `src/lib/preferenze-utente.ts`). RLS: ognuno legge, inserisce e aggiorna solo la propria riga. |
## Funzioni speciali
+12 -6
View File
@@ -51,13 +51,13 @@ Serve a rispondere a domande del tipo:
| [DD-033](#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco) | Gestione eventi solo da calendario |
| [DD-034](#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore) | Ruolo allenatore |
| [DD-035](#dd-035--avviso-certificati-calcolato-in-home-visibile-solo-al-titolare-e-agli-admin) | Avviso certificati in Home |
| [DD-036](#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa) | Notifiche email via Gmail |
**In valutazione**
| ID | Titolo |
| --------------------------------------------------------------------------------- | ------------------------- |
| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB |
| [DD-036](#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa) | Notifiche email via Gmail |
| ID | Titolo |
| ---------------------------------------------------------------- | --------------------- |
| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB |
**Sostituite**
@@ -1515,7 +1515,7 @@ giorni si rivelano pochi per prenotare una visita, alzare la costante o renderla
### DD-036 — Notifiche email via Gmail, da un worker Docker sull'host di casa
**Data:** 29 settembre 2026
**Stato:** In valutazione
**Stato:** Accettata
**Contesto**
Le notifiche di CrAPP arrivano solo con la push (che dipende da Chrome vivo in background,
@@ -1566,7 +1566,13 @@ invia tramite `smtp.gmail.com` con un account Gmail dedicato e una password per
registrazione può arrivare un doppione.
- Il tetto di circa 500 destinatari al giorno di Gmail è largo per una squadra, ma è un limite
reale: con molte squadre o riepiloghi frequenti andrebbe rivisto.
- Nuova migration, nuova cartella `mailer/` e un segreto in più (la service role sull'host).
- Nuova migration (`m22_notifiche_email`), nuova cartella `mailer/` (worker bun con la sola
dipendenza `nodemailer`, separato dall'app) e un segreto in più (la service role sull'host).
L'app non guadagna nessuna dipendenza e le route di M17 non cambiano.
- Il certificato in scadenza resta fuori dalle email: non è una notifica (DD-035), quindi non
passa da `notifiche_utente`. Renderlo una notifica sarebbe una decisione a parte.
- Il tetto giornaliero è una finestra mobile di 24 ore, `MAIL_LIMITE_GIORNO` (400 di default,
sotto i circa 500 di Gmail).
- Le email dei giocatori si usano per un secondo scopo oltre al login: da qui l'opt-out e
l'indicazione in ogni mail su come disattivarlo.
- Una tabella in più, `preferenze_utente`, pensata per accogliere in futuro anche le
+3 -3
View File
@@ -41,12 +41,12 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima e nero dopo la
scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035)
- [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati,
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
([specifica](modules/notifiche-email.md), DD-036)
## Prossimo
- [ ] Notifiche email — le notifiche già in `notifiche_utente` inviate anche per email ai
giocatori, da un worker Docker sull'host di casa via Gmail
([specifica](modules/notifiche-email.md), DD-036)
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
Campionato usa solo quelle giocate
- [ ] Database esercizi
+218 -160
View File
@@ -1,23 +1,27 @@
# Modulo — Notifiche email
**Stato:** progettato, non implementato (DD-036, in valutazione)
**File previsti:** `mailer/` (worker Node + `Dockerfile` + `docker-compose.yml`), una migration
`m22_…` per la coda e la preferenza, `src/lib/notifiche-email-core.ts` (logica pura, testata)
**Stato:** implementato e verificato in locale (migration `m22`, worker in `mailer/`); non ancora
applicato in produzione né messo in funzione sull'host (vedi [Messa in funzione](#messa-in-funzione))
**File principali:** `supabase/migrations/20260929120000_m22_notifiche_email.sql`,
`mailer/mailer.mjs`, `mailer/mailer-core.ts`, `mailer/healthcheck.mjs`, `mailer/Dockerfile`,
`mailer/docker-compose.yml`, `src/lib/preferenze-utente.ts`, `src/routes/profilo.tsx`
(interruttore «Email»)
## Obiettivo
Far arrivare le notifiche di CrAPP anche per email, all'indirizzo Gmail con cui il giocatore
si è registrato, **a costo zero** e senza affidarsi a un servizio a pagamento. È un terzo
canale accanto alla push e al centro notifiche in-app ([notifiche.md](notifiche.md)): non li
sostituisce, e non introduce nuovi eventi. Manda per email ciò che già finisce in
`notifiche_utente`.
si è registrato, **a costo zero**. È un terzo canale accanto alla push e al centro notifiche
in-app ([notifiche.md](notifiche.md)): non li sostituisce e non introduce eventi nuovi. Manda
per email **ogni notifica che finisce già in `notifiche_utente`**, di qualunque tipo e per
qualunque destinatario. Le motivazioni delle scelte stanno in
[DD-036](../DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa).
## Architettura
```
App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ notifiche_email_coda (Supabase)
▲
polling in uscita │ service role
polling in uscita │ RPC con service role
│
Container Docker sul tuo host (mailer)
│ SMTP 587 (STARTTLS)
@@ -25,201 +29,255 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
smtp.gmail.com ──▶ casella Gmail del giocatore
```
- **Vercel non ospita Docker**, quindi il worker gira sull'host di casa. Sta **solo in
- **Vercel non ospita Docker**, quindi il worker gira sull'host di casa e sta **solo in
uscita**: interroga Supabase e parla con Gmail. Nessuna porta aperta sul router, nessun IP
pubblico, nessun DNS dinamico, nessun tunnel.
- **L'app non invia mai email direttamente.** Continua a scrivere `notifiche_utente` come oggi
(le quattro sorgenti di M17 restano invariate). Se l'host è spento le mail restano in coda
e partono alla ripartenza: la consegna è in ritardo, mai persa.
- **Tutta la logica di accodamento sta nel database**, così vale per ogni sorgente, cron
`pg_cron` compresi, senza toccare le route esistenti.
pubblico, nessun tunnel.
- **L'app non invia mai email direttamente.** Continua a scrivere `notifiche_utente` come
prima: le route e i cron di M17 non sono stati toccati. Se l'host è spento le mail restano in
coda e partono alla ripartenza: consegna in ritardo, mai persa.
- **L'accodamento sta nel database** (un trigger), quindi vale per ogni sorgente, cron
`pg_cron` compresi.
- Il worker gira con **bun** (immagine `oven/bun:1-alpine`, come il resto del progetto) e ha una
sola dipendenza, `nodemailer`. Parla con Supabase via `fetch` sulle funzioni RPC di PostgREST,
senza `@supabase/supabase-js`. Ha un proprio `package.json` e `bun.lock`, separati da quelli
dell'app, che non guadagna nessuna dipendenza.
## Quali notifiche e a chi
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, promemoria a 24 ore
e a 3 ore, turno palloni, sollecito presenze. Un tipo nuovo che in futuro finisse in quella
tabella viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica
la migration **non** vengono inviate: il trigger non ha backfill.
**Chi le riceve:** chi ha la riga in `notifiche_utente`, con la stessa logica delle notifiche
in-app. Gli admin e gli allenatori non hanno un percorso a parte: ricevono ciò che riceverebbero
come giocatori, secondo i destinatari già definiti in M17 e DD-034.
Il certificato in scadenza **non genera mail**: non è una notifica ma una card calcolata in Home
(DD-035). Se un giorno lo si volesse anche per email sarebbe una sorgente nuova di
`notifiche_utente`, da decidere a parte.
**Indirizzo:** `giocatori_squadra.email`, la stessa colonna che collega account e giocatore
(DD-018). Uno slot senza `auth_user_id` (giocatore non ancora registrato), senza email o il cui
account ha spento le email non riceve nulla: la riga in coda passa a `saltata`, senza errore.
## Mittente: Gmail con password per app
Il worker invia con un **account Gmail dedicato** (es. `crapp.notifiche@gmail.com`, non
quello personale) tramite `smtp.gmail.com:587`, autenticandosi con una **password per app**.
Il worker invia con un **account Gmail dedicato** (es. `crapp.notifiche@gmail.com`, non quello
personale) tramite `smtp.gmail.com:587`, autenticandosi con una **password per app**.
| Requisito | Dettaglio |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| Verifica in due passaggi | Obbligatoria sull'account: senza, Google non permette di creare le password per app. |
| Password per app | Da myaccount.google.com → Sicurezza → Password per le app. 16 caratteri, revocabile senza cambiare la password dell'account. |
| Limite di invio | Circa 500 destinatari al giorno per un account gratuito (verificare il valore corrente). Una squadra ne usa poche decine al giorno. |
| Intestazione `From` | Gmail la riscrive con l'indirizzo dell'account: il nome visibile può essere «CrAPP», l'indirizzo no. |
| Limite di invio | Circa 500 destinatari al giorno per un account gratuito (verificare il valore corrente); il worker si ferma a `MAIL_LIMITE_GIORNO`. |
| Intestazione `From` | Gmail la riscrive con l'indirizzo dell'account: il nome visibile è «CrAPP», l'indirizzo no. |
| Porta | 587 in uscita. La 25 è bloccata dai provider residenziali e non serve. |
Perché Gmail e non un relay come Brevo o Resend: i destinatari sono già su Gmail, quindi la
mail parte e arriva dentro la stessa infrastruttura, con SPF e DKIM già a posto e senza dover
comprare né configurare un dominio. Un relay dedicato ha senso solo se un giorno si vorrà un
mittente col dominio della squadra (vedi Riesame in DD-036). **Non si invia mai direttamente
dal proprio IP di casa**: gli IP residenziali sono nelle blocklist e senza reverse DNS le mail
finiscono in spam o vengono rifiutate.
Perché Gmail e non un relay come Brevo o Resend: i destinatari sono già su Gmail, quindi la mail
parte e arriva dentro la stessa infrastruttura, con SPF e DKIM già a posto e senza comprare né
configurare un dominio. **Non si invia mai direttamente dal proprio IP di casa**: gli IP
residenziali sono nelle blocklist e senza reverse DNS le mail finiscono in spam o vengono
rifiutate.
## Dati (bozza, non ancora in `DATABASE.md`)
## Dati
Lo schema si documenta in [DATABASE.md](../DATABASE.md) nella stessa modifica che crea la
migration. La bozza:
Lo schema completo, con permessi e note, sta in [DATABASE.md](../DATABASE.md); qui il quadro
funzionale.
**`notifiche_email_coda`** — una riga per notifica da mandare.
**`notifiche_email_coda`** — una riga per notifica da mandare (`notifica_id` è chiave primaria e
chiave esterna con `ON DELETE CASCADE`: cancellare la notifica, per esempio con lo swipe,
toglie anche la mail non ancora partita).
| Colonna | Tipo | Note |
| -------------------- | ------------------- | --------------------------------------------------------------------- |
| `notifica_id` | uuid PK, FK cascade | `notifiche_utente(id)`: cancellata la notifica sparisce anche la riga |
| `stato` | text | `in_coda` · `in_invio` · `inviata` · `fallita` · `saltata` |
| `tentativi` | int | incrementato a ogni invio fallito |
| `prossimo_tentativo` | timestamptz | il worker la prende solo se `<= now()` (backoff) |
| `errore` | text | ultimo messaggio d'errore SMTP |
| `inviata_il` | timestamptz | valorizzato a invio riuscito |
| Colonna | Note |
| -------------------- | ----------------------------------------------------------------------- |
| `stato` | `in_coda` · `in_invio` · `inviata` · `fallita` · `saltata` |
| `tentativi` | invii falliti finora; non cresce per i rinvii «differiti» (vedi sotto) |
| `prossimo_tentativo` | il worker prende la riga solo se `<= now()` (backoff) |
| `errore` | solo i codici SMTP dell'ultimo errore, mai il testo delle mail |
| `inviata_il` | valorizzato a invio riuscito; alimenta il conteggio delle ultime 24 ore |
| `aggiornata_il` | serve a riconoscere le righe rimaste `in_invio` per un worker morto |
Perché una tabella a parte e non colonne su `notifiche_utente`: quella tabella ha una policy
`UPDATE` per il giocatore su tutta la riga, quindi un client potrebbe riportare a `in_coda` una
mail già partita. La coda invece ha RLS attiva **senza nessuna policy**: la vede e la scrive
solo la service role (il worker), mai un client.
È una tabella a parte e non colonne su `notifiche_utente` perché quella ha una policy `UPDATE`
per il giocatore su tutta la riga: un client potrebbe riportare a `in_coda` una mail già partita.
La coda ha la RLS attiva **senza alcuna policy** e i permessi tolti a `anon` e `authenticated`:
la legge e la scrive solo la service role, cioè il worker.
**`preferenze_utente`** — tabella nuova, una riga per **account** (`auth_user_id` PK, FK su
`auth.users` con cascade), non per slot giocatore. Per ora ha una sola colonna,
`email_notifiche boolean NOT NULL DEFAULT true`. RLS: ognuno legge e scrive solo la propria
riga (`auth_user_id = auth.uid()`), il worker la legge con la service role. **Nessuna riga
significa acceso**: la riga nasce solo quando il giocatore tocca l'interruttore (upsert), e
la funzione `prendi_notifiche_email` tratta l'assenza come `true`. Non va su
`giocatori_squadra`, che ha una riga per slot e il giocatore può modificare solo in parte
(DD-016).
**`preferenze_utente`** — una riga per **account** (`auth_user_id`, non lo slot giocatore), per
ora con la sola colonna `email_notifiche` (default `true`). **Nessuna riga significa email
attive**: la riga nasce solo quando il giocatore tocca l'interruttore, con un upsert; ognuno
legge e scrive solo la propria, per RLS. Non sta su `giocatori_squadra`, che ha una riga per slot
e il giocatore può modificare solo in parte (DD-016).
**Indirizzo destinatario** — `giocatori_squadra.email`, già usata per il collegamento
automatico account↔giocatore (DD-018). Niente lettura di `auth.users`.
**Funzioni RPC** — tutte `SECURITY DEFINER` e con `EXECUTE` solo alla service role:
| Funzione | Cosa fa |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `prendi_notifiche_email(p_max)` | Prende fino a `p_max` righe `in_coda` scadute con `FOR UPDATE SKIP LOCKED`, le marca `in_invio` e restituisce indirizzo, oggetto, testo, evento, tipo. |
| `esito_notifica_email(id, esito, errore, ora)` | Registra `inviata`, `riprova`, `differita` o `fallita`; agisce solo su righe `in_invio`, così un esito in ritardo non ne sovrascrive uno già deciso. |
| `email_inviate_ultime_24h()` | Conta le mail inviate nelle ultime 24 ore, per restare sotto il tetto. |
## Flusso
1. Una sorgente M17 inserisce (o aggiorna via `upsert`) una riga in `notifiche_utente`.
2. Un trigger `AFTER INSERT OR UPDATE OF creato_il` la accoda in `notifiche_email_coda`,
L'`upsert` di turno palloni e sollecito
presenze aggiorna `creato_il`: è un rinvio voluto, e rimette in coda anche la mail.
3. Il worker ogni ~30 secondi chiama la funzione RPC `prendi_notifiche_email(n)`, riservata
alla service role. Seleziona fino a `n` righe `in_coda` con `FOR UPDATE SKIP LOCKED`, le
marca `in_invio` e restituisce titolo, corpo, `evento_id` e indirizzo, già filtrate per
preferenza attiva e slot con `auth_user_id` valorizzato. Con `SKIP LOCKED` due worker
avviati per errore non mandano la stessa mail due volte.
4. Il worker invia con `nodemailer`, poi segna `inviata` o `fallita`.
5. Riga senza destinatario (slot non ancora collegato a un account, preferenza spenta,
email assente): `saltata`, senza errore.
1. Una sorgente M17 inserisce (o aggiorna con `upsert`) una riga in `notifiche_utente`.
2. Il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`) la mette in
`notifiche_email_coda`. L'`upsert` di turno palloni e sollecito presenze riscrive `creato_il`:
è un rinvio voluto e rimette in coda anche la mail. Segnare come letta e l'`ON CONFLICT DO NOTHING`
dei cron non toccano quella colonna e non accodano nulla.
3. Ogni `POLL_SECONDI` (30) il worker conta le mail delle ultime 24 ore, ne chiede al massimo
quante ne restano sotto il tetto (al più `MAIL_LOTTO`) a `prendi_notifiche_email` e le invia
una a una. Prima di prendere il lotto la funzione SQL fa due pulizie: riporta a `in_coda` le
righe ferme `in_invio` da più di 10 minuti (worker morto a metà) e segna `saltata` quelle
senza destinatario o con le email spente.
4. Ogni invio registra l'esito con `esito_notifica_email`.
**Ogni notifica presente in `notifiche_utente` genera una mail**, di qualunque tipo:
`admin`, `evento_promemoria_24h`, `evento_promemoria_3h`, `turno_palloni`,
`sollecita_presenze`. Il modulo non aggiunge sorgenti né cambia la logica di M17: un tipo
nuovo che in futuro finisse in `notifiche_utente` viaggerebbe per email senza altro lavoro.
**Chi le riceve.** Con la stessa logica delle notifiche in-app: la mail va a chi ha la riga in
`notifiche_utente`, admin e allenatori compresi. Non c'è un percorso separato per gli admin: un
admin riceve ciò che riceverebbe come giocatore (i destinatari di ogni sorgente restano quelli
già definiti in M17 e DD-034).
Il certificato in scadenza **non genera mail**: non è una notifica ma una card calcolata in
Home (DD-035), e questo modulo non la trasforma in una. Se un giorno la si volesse anche per
email, sarebbe una sorgente nuova di `notifiche_utente` da decidere a parte.
La preferenza è valutata **al momento dell'invio**, non dell'accodamento: spegnere l'interruttore
ferma anche le mail già in coda.
## Affidabilità
| Situazione | Comportamento |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Errore temporaneo (rete, 4xx SMTP) | Backoff 1 · 5 · 30 · 120 min, poi `fallita` dopo 5 tentativi. |
| Errore permanente (5xx, indirizzo KO) | `fallita` subito, senza altri tentativi. |
| Worker fermo o host spento | Le mail restano `in_coda`; partono alla ripartenza. |
| Crash tra l'invio e l'aggiornamento | Possibile un doppione: la consegna è _almeno una volta_. Il `Message-ID` è deterministico (derivato da `notifica_id`). |
| Righe `in_invio` orfane | All'avvio il worker riporta a `in_coda` quelle ferme da più di 10 minuti. |
| Limite giornaliero Gmail raggiunto | Il worker si ferma fino a mezzanotte (fuso Europe/Rome) e riprende; nessuna riga viene scartata. |
Il worker classifica l'errore SMTP (`classificaErroreSmtp`):
## Worker (`mailer/`)
| Tipo | Esempi | Cosa succede |
| ---------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `temporaneo` | 4xx, timeout, connessione caduta | Riprova con backoff 1 · 5 · 30 · 120 min; al quinto invio fallito passa a `fallita`. |
| `permanente` | 5xx sul destinatario o sul messaggio, indirizzo non valido | `fallita` subito, senza altri tentativi. |
| `limite` | `550 5.4.5 Daily user sending quota exceeded` | Tutto il lotto torna in coda tra un'ora **senza consumare tentativi**; il ciclo si ferma. |
| `configurazione` | credenziali SMTP rifiutate (535, 534, 530, `EAUTH`) | Come sopra ma tra 15 minuti, e il container **non** risulta più sano (vedi sotto). |
Servizio Node piccolo: `@supabase/supabase-js` e `nodemailer`. Le dipendenze passano dal
controllo `minimumReleaseAge` di `bunfig.toml` come per il resto del progetto.
Altre situazioni:
```yaml
# mailer/docker-compose.yml (bozza)
services:
mailer:
build: .
restart: unless-stopped
env_file: .env
healthcheck:
test: ["CMD", "node", "healthcheck.js"]
interval: 60s
retries: 3
```
| Situazione | Comportamento |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Worker fermo o host spento | Le mail restano `in_coda` e partono alla ripartenza. |
| Crash tra l'invio e la registrazione | Consegna _almeno una volta_: la riga resta `in_invio`, dopo 10 minuti torna in coda e la mail può arrivare due volte. Mai una perdita. |
| Errore nella registrazione dell'esito | Non si tratta come errore SMTP (non parte un secondo invio): il ciclo si interrompe e la riga viene recuperata come sopra. |
| Tetto giornaliero raggiunto | Finestra mobile di 24 ore su `inviata_il`, non «fino a mezzanotte»: il worker riprende da solo quando le mail più vecchie escono dalla finestra. |
Variabili d'ambiente, mai committate (il `.env` sta nel `.gitignore`):
`MAIL_LIMITE_GIORNO` vale 400 di default, sotto i circa 500 di Gmail, per lasciare margine ai
messaggi che l'account dedicato potesse mandare per altre vie.
| Variabile | Contenuto |
| --------------------------- | ---------------------------------------------------------------- |
| `SUPABASE_URL` | URL del progetto |
| `SUPABASE_SERVICE_ROLE_KEY` | chiave service role: salta la RLS, va trattata come una password |
| `SMTP_USER`, `SMTP_PASS` | account Gmail dedicato e relativa password per app |
| `MAIL_FROM_NAME` | nome visibile, es. «CrAPP» |
| `POLL_SECONDI` | intervallo di polling, default 30 |
| `APP_URL` | base dei link nelle mail (es. `/evento/<id>`) |
`healthcheck.js` segnala non sano il container se l'ultimo ciclo di polling riuscito è più
vecchio di qualche minuto. Il log dice solo id, tipo e esito: **mai indirizzi o testi**.
**Salute del container.** `mailer.mjs` scrive un timestamp in `/tmp/mailer-ok` a ogni ciclo
riuscito; `healthcheck.mjs` (eseguito da Docker ogni minuto) segnala non sano il container se
l'ultimo è più vecchio di 5 minuti. Un ciclo fermato dalle credenziali SMTP rifiutate **non**
aggiorna il file, così dopo qualche minuto `docker ps` mostra `unhealthy` invece di far credere
che tutto funzioni. Un ciclo fermato dal solo tetto giornaliero è normale e resta sano.
## Contenuto delle mail
Sobrio, testo semplice più una versione HTML minima, leggibile da smartphone: oggetto = titolo
della notifica, corpo = corpo della notifica, un pulsante «Apri CrAPP» verso l'evento quando
`evento_id` c'è. In fondo una riga che spiega come disattivare le email (Profilo → Opzioni).
Nessuna immagine remota, nessun tracciamento delle aperture.
## Privacy e sicurezza
- Le email personali dei giocatori non lasciano Supabase se non verso Gmail, che è già dove
stanno. Il worker legge solo l'indirizzo dei destinatari del lotto, non l'intera rosa.
- La service role sta solo sull'host, in un `.env` con permessi `600`; non passa da Vercel.
- La password per app si può revocare da Google senza toccare l'account.
- Il canale è **opt-out**: acceso di default, spegnibile dal giocatore. Un interruttore
unico, come per la push (nessuna preferenza per tipo).
Sobrio, in testo semplice più una versione HTML minima leggibile da smartphone: oggetto = titolo
della notifica (su una riga sola, massimo 200 caratteri), corpo = corpo della notifica, un
pulsante «Apri CrAPP» verso `/evento/<id>` quando la notifica riguarda un evento e verso la home
altrimenti. In fondo una riga che spiega come disattivare le email (Profilo → Opzioni → Email).
Nessuna immagine remota, nessun tracciamento delle aperture. Il testo scritto dall'admin viene
sempre escapato nell'HTML. Il `Message-ID` è derivato dall'id della notifica.
## Interfaccia
Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore «Email» che scrive la
preferenza per account. Nessuna schermata nuova. Nella tab «Notifiche» della dashboard admin
si può aggiungere in un secondo momento uno stato per giocatore (email attive o no), come già
fa la campana per la push.
Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore **«Email»** (icona
busta) che scrive `preferenze_utente` per l'account, con lo stesso stile dell'interruttore
«Notifiche» (`useEmailNotifiche()` in `src/lib/preferenze-utente.ts`). Vale per tutti gli slot
dell'account. Nessuna schermata nuova.
## Privacy e sicurezza
- Le email personali dei giocatori non lasciano Supabase se non verso Gmail. Il worker legge solo
l'indirizzo dei destinatari del lotto che sta spedendo, non l'intera rosa.
- La service role sta solo sull'host, in un `.env` con permessi `600`; non passa da Vercel.
- La password per app si può revocare da Google senza toccare l'account.
- I log del worker riportano solo id notifica, tipo ed esito: **mai indirizzi né testi**, e degli
errori SMTP solo i codici. Il log del container è limitato a 3 file da 5 MB.
- Il canale è **opt-out**: acceso di default, spegnibile dal giocatore. Un interruttore unico,
come per la push (nessuna preferenza per tipo).
## Messa in funzione
Da fare una volta, sull'host che deve far girare il worker (serve Docker).
1. **Account Gmail dedicato.** Creane uno nuovo, attiva la verifica in due passaggi e genera una
password per app (vedi la tabella sopra).
2. **Applica la migration in produzione**, solo dopo aver verificato tutto in locale:
`npx supabase db push`. È additiva (due tabelle, un trigger, tre funzioni): non modifica nulla
di esistente e non invia mail finché il worker non parte. Dal momento in cui è applicata, le
nuove notifiche si accodano: **avvia il worker nella stessa sessione**, altrimenti al primo
avvio partirebbero tutte insieme anche mail ormai vecchie (per esempio un promemoria di giorni
prima).
3. **Configura il worker.** In `mailer/`: `cp .env.example .env`, `chmod 600 .env` e compila
`SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `SMTP_USER`, `SMTP_PASS` e `APP_URL`. Le altre
variabili hanno un default:
| Variabile | Default | Contenuto |
| --------------------------------------- | ------------------------------ | ---------------------------------------------------------------- |
| `SUPABASE_URL` | — | URL del progetto |
| `SUPABASE_SERVICE_ROLE_KEY` | — | chiave service role: salta la RLS, va trattata come una password |
| `SMTP_USER`, `SMTP_PASS` | — | account Gmail dedicato e password per app (vanno insieme) |
| `APP_URL` | — | base dei link nelle mail |
| `MAIL_FROM`, `MAIL_FROM_NAME` | `SMTP_USER`, `CrAPP` | indirizzo e nome del mittente |
| `POLL_SECONDI` | 30 | intervallo di polling |
| `MAIL_LIMITE_GIORNO` | 400 | tetto delle ultime 24 ore |
| `MAIL_LOTTO` | 10 | mail prese a ogni giro |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | `smtp.gmail.com`, 587, `false` | server SMTP (`true` solo per la 465) |
4. **Avvia:** `docker compose up -d --build` dentro `mailer/`. Il container ha
`restart: unless-stopped`, quindi riparte da solo dopo un riavvio dell'host.
5. **Verifica:** `docker compose logs -f` deve mostrare «SMTP raggiungibile e credenziali
accettate»; poi manda un messaggio a un giocatore dalla dashboard admin e controlla che la
riga in coda passi a `inviata` e la mail arrivi. `docker ps` deve mostrare `healthy`.
**Aggiornare il worker:** `git pull`, poi `docker compose up -d --build` in `mailer/`.
**Diagnosi rapida:** `SELECT stato, count(*) FROM notifiche_email_coda GROUP BY stato;` mostra a
colpo d'occhio se qualcosa si accumula in `in_coda` (worker fermo o tetto raggiunto) o in `fallita`
(leggere `errore`).
## Prova in locale, senza Gmail
`npx supabase start` avvia anche Mailpit (interfaccia su http://127.0.0.1:54324): le mail non
escono dalla macchina. Il worker si punta al database locale e a Mailpit con un `.env` senza
credenziali SMTP (`SMTP_USER`/`SMTP_PASS` assenti, `MAIL_FROM` e `SMTP_HOST` impostati, porta 1025).
Se il worker gira in un container va collegato alla rete Docker di Supabase
(`supabase_network_<project_id>`) e usa come host `supabase_kong_<project_id>:8000` per il
database e `supabase_inbucket_<project_id>` per l'SMTP. Con uno slot collegato a un account e con
l'email valorizzata, una riga inserita in `notifiche_utente` compare in Mailpit entro un ciclo di
polling.
## Test
- **Unit** — [test/unit/mailer-core.test.ts](../../test/unit/mailer-core.test.ts): configurazione,
classificazione degli errori SMTP, backoff, tetto giornaliero, composizione della mail (escape,
oggetto, link, `Message-ID`), salute, e il ciclo di invio con dipendenze finte (esiti, fermata su
tetto e credenziali, registrazione dell'esito che fallisce, log senza dati personali).
[test/unit/preferenze-utente.test.ts](../../test/unit/preferenze-utente.test.ts): il default
«acceso» senza riga.
- **Integrazione** — [test/integration/notifiche-email.test.ts](../../test/integration/notifiche-email.test.ts),
sul database locale: trigger su ogni tipo, rinvio da `upsert`, cron che non accoda due volte,
cascata alla cancellazione, `prendi_notifiche_email` (destinatario, una sola volta, chiamate
concorrenti senza doppioni, slot senza account o senza email, righe orfane recuperate),
preferenza (default, spenta, riaccesa, RLS sulle righe altrui), tutti gli esiti, e i permessi (un
giocatore o un admin non vedono la coda né chiamano le funzioni del worker).
- Nessun test spedisce mail vere: il trasporto SMTP è sostituito da un finto nei test unitari, e
nella prova end-to-end si usa Mailpit.
## Limiti noti
- Il canale email dipende da un host di casa acceso: non c'è alta disponibilità.
- Un 250 da Gmail vuol dire «accettata», non «letta»: non c'è conferma di lettura e non se ne
vuole una.
- Gmail può finire in spam se il testo è promozionale o se l'account nuovo invia subito
molti messaggi: si parte con pochi invii e si tiene il tono di servizio.
- Un account collegato a più slot giocatore (`auth_user_id` ripetuto) riceve una mail per
slot, dato che `notifiche_utente` ha una riga per `giocatore_id`.
- Cambiare l'indirizzo in `giocatori_squadra.email` cambia dove vanno le mail future; le
righe già `inviata` non si toccano.
## Test previsti
Come da [AGENTS.md](../../AGENTS.md), le funzioni si scrivono con il loro test.
- **Unit** (`test/unit/`, su `notifiche-email-core.ts`): calcolo
del backoff, classificazione errore temporaneo/permanente, composizione di oggetto e corpo,
`Message-ID` deterministico.
- **Integrazione** (`test/integration/`, contro il database locale): il trigger accoda ogni
nuova notifica; `upsert` rimette in coda; RLS senza policy (un client non legge né scrive
la coda); `prendi_notifiche_email` non restituisce due volte la stessa riga con due chiamate
concorrenti, salta chi ha la preferenza spenta o non ha un account collegato.
- Il transport SMTP si sostituisce con un finto nei test: nessun invio reale in CI.
## Piano di realizzazione
1. Migration `m22_…`: coda, preferenza, trigger, funzione RPC, RLS (poi `DATABASE.md`).
2. `notifiche-email-core.ts` con i test unitari.
3. Worker `mailer/` con `Dockerfile`, compose, healthcheck.
4. Interruttore in Profilo → Opzioni.
5. Prova end-to-end su `npx supabase start` con un server SMTP di prova (es. Mailpit in
Docker), poi con l'account Gmail vero.
6. `CHANGELOG.md`, `ROADMAP.md` (spunta), `PROJECT_STATE.md`, `notifiche.md` (rimando qui).
- Gmail può mandare in spam un account nuovo che invia subito molti messaggi o un testo
promozionale: si tiene un tono di servizio e i volumi di una squadra sono bassi.
- Un account collegato a più slot giocatore (`auth_user_id` ripetuto) riceve una mail per slot,
dato che `notifiche_utente` ha una riga per `giocatore_id`.
- Cambiare l'indirizzo in `giocatori_squadra.email` cambia dove vanno le mail future; le righe già
`inviata` non si toccano.
- Nessun test automatico ha inviato una mail a Gmail: la prova con l'account vero fa parte della
messa in funzione.
- `notifiche_email_coda` non ha pulizia automatica, come `notifiche_utente` (DD-030): le righe
spariscono quando la notifica viene eliminata.
## Evoluzioni possibili
- Un relay con dominio proprio (Brevo, Resend) se serve un mittente della squadra o si supera
il tetto di Gmail.
- Un relay con dominio proprio (Brevo, Resend) se serve un mittente della squadra o si supera il
tetto di Gmail.
- Riepilogo settimanale unico al posto di una mail per notifica.
- Preferenze per tipo di notifica, insieme a quelle della push.
- Preferenze per tipo di notifica, nella stessa `preferenze_utente`, insieme a quelle della push.
- Rendere il certificato in scadenza una notifica vera (sorgente nuova di `notifiche_utente`).
+8 -1
View File
@@ -164,6 +164,14 @@ sparire per sempre è eliminarle una per una, con uno swipe verso sinistra sulla
× che compare sopra ogni riga per chi non è su touch. Chi non tocca mai una notifica se la
ritrova per sempre nell'elenco, solo segnata come letta.
## Canale email (M22)
Ogni riga di `notifiche_utente` viene inviata anche per email da un worker esterno, con
un interruttore «Email» per account in Profilo → Opzioni: [notifiche-email.md](notifiche-email.md).
Non cambia nulla di quanto descritto sopra: push, notifiche smart e centro notifiche restano
indipendenti e le quattro sorgenti scrivono `notifiche_utente` come prima. Il certificato in
scadenza resta una card in Home e non genera mail (DD-035).
## Limiti noti
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
@@ -216,7 +224,6 @@ ritrova per sempre nell'elenco, solo segnata come letta.
## Evoluzioni possibili
- Canale email, progettato ma non ancora implementato: [notifiche-email.md](notifiche-email.md).
- Preferenze per canale (palloni, solleciti, smart), se servono davvero alla squadra.
- Eliminare `promemoria_push` con una migrazione.
- Gestire esplicitamente il caso iOS (messaggio se l'app non è installata da Home).
+5
View File
@@ -0,0 +1,5 @@
node_modules
.env
.env.*
!.env.example
README.md
+22
View File
@@ -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://<project-ref>.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://<dominio-dell-app>
# 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
+7
View File
@@ -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"]
+15
View File
@@ -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=="],
}
}
+7
View File
@@ -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 = []
+16
View File
@@ -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"
+11
View File
@@ -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);
+336
View File
@@ -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<string, string | undefined>): 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** 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()
? `<p style="margin:0 0 16px">${escapeHtml(n.testo.trim()).replace(/\n/g, "<br>")}</p>`
: "";
const html = [
`<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:16px;line-height:1.5;color:#111;max-width:480px">`,
`<h2 style="margin:0 0 12px;font-size:18px">${escapeHtml(subject)}</h2>`,
corpoHtml,
`<p style="margin:0 0 24px"><a href="${escapeHtml(link)}" style="display:inline-block;padding:12px 20px;border-radius:12px;background:#111;color:#fff;text-decoration:none">Apri CrAPP</a></p>`,
`<p style="margin:0;font-size:12px;color:#666">${escapeHtml(AVVISO_DISATTIVAZIONE)}</p>`,
`</div>`,
].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<NotificaEmail[]>;
segna: (id: string, esito: EsitoInvio, errore?: string, prossimo?: Date) => Promise<void>;
invia: (mail: MailComposta) => Promise<void>;
inviateUltime24h: () => Promise<number>;
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<RisultatoCiclo> {
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;
}
+95
View File
@@ -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");
+12
View File
@@ -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"
}
}
+60
View File
@@ -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<RigaPreferenze | null> => {
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 };
}
+39 -1
View File
@@ -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 }) {
<Bell className="h-4 w-4" />
</span>
</button>
<button
type="button"
onClick={cambiaEmail}
disabled={!email.pronta || email.imposta.isPending}
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
>
<span className="min-w-0">
<span className="block truncate">Email</span>
<span className="block text-xs text-muted-foreground">
{email.attive
? "Le notifiche arrivano anche via email"
: "Nessuna email, solo notifiche in app"}
</span>
</span>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
email.attive
? "bg-accent-grad text-accent-foreground"
: "bg-secondary text-muted-foreground",
)}
>
<Mail className="h-4 w-4" />
</span>
</button>
{admin ? (
<Link
to="/admin"
@@ -0,0 +1,239 @@
-- M22 — Notifiche email (DD-036)
--
-- Ogni riga di `notifiche_utente` diventa anche una mail, spedita da un worker Docker esterno
-- a Supabase (cartella `mailer/`) che interroga il database e invia via SMTP. Nessuna sorgente
-- di M17 cambia: un trigger accoda le notifiche NUOVE, quelle già presenti non vengono mai
-- inviate (il trigger non ha un backfill).
--
-- Tre pezzi:
-- 1. `notifiche_email_coda`: stato d'invio di ogni notifica. Tabella a parte, con RLS senza
-- alcuna policy: la policy UPDATE di `notifiche_utente` lascerebbe a un client la
-- possibilità di riportare a 'in_coda' una mail già partita. Solo la service role (il
-- worker) la legge e la scrive.
-- 2. `preferenze_utente`: interruttore «Email» per ACCOUNT (non per slot giocatore).
-- Nessuna riga = mail attive; la riga nasce quando il giocatore tocca l'interruttore.
-- 3. Tre funzioni RPC riservate alla service role, con cui il worker prende un lotto,
-- registra l'esito e conta le mail delle ultime 24 ore (tetto giornaliero di Gmail).
-- --- Coda ------------------------------------------------------------------------------------
CREATE TABLE public.notifiche_email_coda (
notifica_id uuid PRIMARY KEY REFERENCES public.notifiche_utente(id) ON DELETE CASCADE,
stato text NOT NULL DEFAULT 'in_coda'
CHECK (stato IN ('in_coda', 'in_invio', 'inviata', 'fallita', 'saltata')),
tentativi integer NOT NULL DEFAULT 0,
prossimo_tentativo timestamptz NOT NULL DEFAULT now(),
errore text,
inviata_il timestamptz,
aggiornata_il timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.notifiche_email_coda IS
'Coda delle mail per le notifiche in-app (M22): una riga per notifica, scritta solo dal trigger e dal worker.';
CREATE INDEX notifiche_email_coda_da_inviare_idx
ON public.notifiche_email_coda (prossimo_tentativo)
WHERE stato = 'in_coda';
CREATE INDEX notifiche_email_coda_inviate_idx
ON public.notifiche_email_coda (inviata_il)
WHERE stato = 'inviata';
ALTER TABLE public.notifiche_email_coda ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.notifiche_email_coda FROM PUBLIC, anon, authenticated;
GRANT ALL ON public.notifiche_email_coda TO service_role;
-- --- Preferenze per account ------------------------------------------------------------------
CREATE TABLE public.preferenze_utente (
auth_user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email_notifiche boolean NOT NULL DEFAULT true,
aggiornata_il timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.preferenze_utente IS
'Preferenze del singolo account (non dello slot giocatore). Nessuna riga = valori di default.';
REVOKE ALL ON public.preferenze_utente FROM PUBLIC, anon;
GRANT SELECT, INSERT, UPDATE ON public.preferenze_utente TO authenticated;
GRANT ALL ON public.preferenze_utente TO service_role;
ALTER TABLE public.preferenze_utente ENABLE ROW LEVEL SECURITY;
CREATE POLICY "L'utente legge le proprie preferenze" ON public.preferenze_utente
FOR SELECT TO authenticated
USING (auth_user_id = auth.uid());
CREATE POLICY "L'utente crea le proprie preferenze" ON public.preferenze_utente
FOR INSERT TO authenticated
WITH CHECK (auth_user_id = auth.uid());
CREATE POLICY "L'utente aggiorna le proprie preferenze" ON public.preferenze_utente
FOR UPDATE TO authenticated
USING (auth_user_id = auth.uid())
WITH CHECK (auth_user_id = auth.uid());
-- --- Trigger: ogni notifica nuova (o rinviata) finisce in coda ---------------------------------
--
-- `UPDATE OF creato_il` intercetta l'`upsert` di turno palloni e sollecito presenze, che
-- riscrive `creato_il`: è un rinvio voluto (testo fresco, `letta` a false) e rimette in coda
-- anche la mail. "Segna come letta" e l'ON CONFLICT DO NOTHING dei cron non toccano quella
-- colonna e non accodano nulla.
CREATE OR REPLACE FUNCTION public.accoda_notifica_email()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.notifiche_email_coda (notifica_id)
VALUES (NEW.id)
ON CONFLICT (notifica_id) DO UPDATE
SET stato = 'in_coda',
tentativi = 0,
prossimo_tentativo = now(),
errore = NULL,
inviata_il = NULL,
aggiornata_il = now();
RETURN NEW;
END;
$$;
CREATE TRIGGER notifiche_utente_accoda_email
AFTER INSERT OR UPDATE OF creato_il ON public.notifiche_utente
FOR EACH ROW EXECUTE FUNCTION public.accoda_notifica_email();
-- --- RPC del worker ----------------------------------------------------------------------------
-- Prende fino a `p_max` mail da spedire e le marca 'in_invio'. `FOR UPDATE SKIP LOCKED`: due
-- worker avviati per errore non prendono mai la stessa riga. Prima ripulisce due casi:
-- * righe rimaste 'in_invio' da più di 10 minuti (worker morto a metà invio) tornano 'in_coda';
-- * righe senza destinatario (slot non collegato a un account, email assente, preferenza
-- spenta) diventano 'saltata', senza errore.
-- L'indirizzo è `giocatori_squadra.email`, la stessa che collega account e giocatore (DD-018).
CREATE OR REPLACE FUNCTION public.prendi_notifiche_email(p_max integer DEFAULT 20)
RETURNS TABLE (
id_notifica uuid,
destinatario text,
oggetto text,
testo text,
id_evento text,
tipo_notifica text,
tentativi_fatti integer
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
UPDATE public.notifiche_email_coda c
SET stato = 'in_coda', aggiornata_il = now()
WHERE c.stato = 'in_invio'
AND c.aggiornata_il < now() - interval '10 minutes';
UPDATE public.notifiche_email_coda c
SET stato = 'saltata', aggiornata_il = now()
FROM public.notifiche_utente n
JOIN public.giocatori_squadra g ON g.id = n.giocatore_id
WHERE c.notifica_id = n.id
AND c.stato = 'in_coda'
AND c.prossimo_tentativo <= now()
AND (
g.auth_user_id IS NULL
OR coalesce(btrim(g.email), '') = ''
OR EXISTS (
SELECT 1 FROM public.preferenze_utente p
WHERE p.auth_user_id = g.auth_user_id AND NOT p.email_notifiche
)
);
RETURN QUERY
WITH scelte AS (
SELECT c.notifica_id
FROM public.notifiche_email_coda c
WHERE c.stato = 'in_coda' AND c.prossimo_tentativo <= now()
ORDER BY c.prossimo_tentativo
LIMIT greatest(p_max, 0)
FOR UPDATE SKIP LOCKED
),
presi AS (
UPDATE public.notifiche_email_coda c
SET stato = 'in_invio', aggiornata_il = now()
FROM scelte s
WHERE c.notifica_id = s.notifica_id
RETURNING c.notifica_id, c.tentativi
)
SELECT p.notifica_id, g.email, n.titolo, n.corpo, n.evento_id, n.tipo, p.tentativi
FROM presi p
JOIN public.notifiche_utente n ON n.id = p.notifica_id
JOIN public.giocatori_squadra g ON g.id = n.giocatore_id
ORDER BY n.creato_il;
END;
$$;
-- Registra l'esito di un invio. Agisce solo su righe 'in_invio', così un esito in ritardo o
-- ripetuto non sovrascrive uno stato già deciso.
-- 'inviata' → stato inviata
-- 'riprova' → torna in coda dopo `p_prossimo`, conta un tentativo
-- 'differita'→ torna in coda dopo `p_prossimo`, NON conta un tentativo (limite giornaliero
-- di Gmail o configurazione SMTP da correggere: non è colpa della mail)
-- 'fallita' → definitiva
CREATE OR REPLACE FUNCTION public.esito_notifica_email(
p_id uuid,
p_esito text,
p_errore text DEFAULT NULL,
p_prossimo timestamptz DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF p_esito NOT IN ('inviata', 'riprova', 'differita', 'fallita') THEN
RAISE EXCEPTION 'esito non valido: %', p_esito;
END IF;
UPDATE public.notifiche_email_coda c
SET stato = CASE p_esito
WHEN 'inviata' THEN 'inviata'
WHEN 'fallita' THEN 'fallita'
ELSE 'in_coda'
END,
tentativi = c.tentativi + CASE WHEN p_esito IN ('riprova', 'fallita') THEN 1 ELSE 0 END,
prossimo_tentativo = CASE
WHEN p_esito IN ('riprova', 'differita')
THEN coalesce(p_prossimo, now())
ELSE c.prossimo_tentativo
END,
errore = CASE WHEN p_esito = 'inviata' THEN NULL ELSE left(p_errore, 500) END,
inviata_il = CASE WHEN p_esito = 'inviata' THEN now() ELSE c.inviata_il END,
aggiornata_il = now()
WHERE c.notifica_id = p_id AND c.stato = 'in_invio';
END;
$$;
-- Mail inviate nelle ultime 24 ore: il worker la usa per restare sotto il tetto di Gmail.
CREATE OR REPLACE FUNCTION public.email_inviate_ultime_24h()
RETURNS integer
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT count(*)::integer
FROM public.notifiche_email_coda
WHERE stato = 'inviata' AND inviata_il > 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;
+6 -6
View File
@@ -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
+571
View File
@@ -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<T>(percorso: string, token = SERVIZIO): Promise<T[]> {
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<T = unknown>(nome: string, corpo: Record<string, unknown>, 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<string> {
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<string> {
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<string, unknown> = {},
): Promise<string> => {
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<Coda | undefined> =>
(await leggi<Coda>(`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<Preso[]> => {
const r = await rpc<Preso[]>("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<string, { email: string | null; auth_user_id: string | null }>();
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<number>("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<number>("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");
}
}
+406
View File
@@ -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> = {}): 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"), "<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: `<img src=x onerror=alert(1)>\nriga "2"`, id_evento: null }),
OPZ,
);
assert.ok(!m.html.includes("<img"));
assert.match(m.html, /&lt;img src=x onerror=alert\(1\)&gt;<br>riga &quot;2&quot;/);
assert.match(m.html, /href="https:\/\/crapp\.example"/);
assert.equal(escapeHtml(`<&>"'`), "&lt;&amp;&gt;&quot;&#39;");
});
await prova("titolo con HTML: l'intestazione della mail è sempre esclusa dal markup", () => {
const m = componiMail(notifica({ oggetto: "<b>Ciao</b>" }), OPZ);
assert.ok(!m.html.includes("<b>"));
});
await prova("corpo vuoto: niente paragrafo vuoto", () => {
const m = componiMail(notifica({ testo: "" }), OPZ);
assert.ok(!m.html.includes('<p style="margin:0 0 16px"></p>'));
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<void>;
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("<a@")) throw { responseCode: 550 };
},
});
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.fallite, 1);
assert.equal(r.inviate, 1);
assert.equal(r.fermato, null);
});
await prova(
"ciclo: tetto Gmail a metà lotto, rimanda questa e le successive e si ferma",
async () => {
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("<b@"))
throw { responseCode: 550, response: "550 mario@gmail.com" };
},
});
await elaboraCiclo(a.dip, OPZ_CICLO);
const tutto = a.log.join("\n");
assert.ok(!tutto.includes("mario@gmail.com"));
assert.ok(!tutto.includes("30/09/2026"));
assert.ok(!tutto.includes("Allenamento"));
});
riepilogo("mailer-core");
+16
View File
@@ -0,0 +1,16 @@
/** Preferenze dell'account (M22): `bun test/unit/preferenze-utente.test.ts`. */
import assert from "node:assert/strict";
import { emailAttive } from "@/lib/preferenze-utente";
import { prova, riepilogo } from "../helpers/prova";
await prova("senza riga le email sono attive (default DD-036)", () => {
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");