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:
1 parent
2a0b611459
commit
b7d3fde225
24 files changed
+2138
-186
No files matched your search
+13
-3
@@ -1,6 +1,6 @@
|
|||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
Ultimo aggiornamento: 27/09/2026
|
Ultimo aggiornamento: 29/09/2026
|
||||||
|
|
||||||
## Stato generale
|
## 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
|
- Cursor e Claude Code come ambienti di sviluppo
|
||||||
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
|
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
|
||||||
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
|
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
|
||||||
- 32 migration in `supabase/migrations/`, fino a `m21_ruolo_allenatore` (24/09/2026), tutte
|
- 33 migration in `supabase/migrations/`, fino a `m22_notifiche_email` (29/09/2026). Le prime
|
||||||
applicate in produzione (verificato con `npx supabase migration list` il 24/09/2026)
|
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
|
- 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
|
- 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
|
backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata
|
||||||
in `npx supabase migration list` del 24/09/2026)
|
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
|
- 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
|
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
|
trigger) — verificate in locale con `npx supabase db reset` e `npm run test:all`, **in
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ versioni `0.y.z` erano pre-release.
|
|||||||
|
|
||||||
## [Non rilasciato]
|
## [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
|
## [1.1.1] - 2026-09-28
|
||||||
|
|
||||||
### Aggiunto
|
### Aggiunto
|
||||||
|
|||||||
+10
-6
@@ -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 |
|
| `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 |
|
| `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_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) |
|
| `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) |
|
| `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) |
|
| `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
|
## Turni e notifiche
|
||||||
|
|
||||||
| Tabella | Scopo | Note |
|
| 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. |
|
| `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. | |
|
| `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. |
|
| `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_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
|
## Funzioni speciali
|
||||||
|
|
||||||
|
|||||||
@@ -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-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-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-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**
|
**In valutazione**
|
||||||
|
|
||||||
| ID | Titolo |
|
| ID | Titolo |
|
||||||
| --------------------------------------------------------------------------------- | ------------------------- |
|
| ---------------------------------------------------------------- | --------------------- |
|
||||||
| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB |
|
| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB |
|
||||||
| [DD-036](#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa) | Notifiche email via Gmail |
|
|
||||||
|
|
||||||
**Sostituite**
|
**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
|
### DD-036 — Notifiche email via Gmail, da un worker Docker sull'host di casa
|
||||||
|
|
||||||
**Data:** 29 settembre 2026
|
**Data:** 29 settembre 2026
|
||||||
**Stato:** In valutazione
|
**Stato:** Accettata
|
||||||
|
|
||||||
**Contesto**
|
**Contesto**
|
||||||
Le notifiche di CrAPP arrivano solo con la push (che dipende da Chrome vivo in background,
|
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.
|
registrazione può arrivare un doppione.
|
||||||
- Il tetto di circa 500 destinatari al giorno di Gmail è largo per una squadra, ma è un limite
|
- 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.
|
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
|
- 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.
|
l'indicazione in ogni mail su come disattivarlo.
|
||||||
- Una tabella in più, `preferenze_utente`, pensata per accogliere in futuro anche le
|
- Una tabella in più, `preferenze_utente`, pensata per accogliere in futuro anche le
|
||||||
|
|||||||
+3
-3
@@ -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
|
- [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
|
scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio
|
||||||
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035)
|
([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
|
## 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
|
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
|
||||||
Campionato usa solo quelle giocate
|
Campionato usa solo quelle giocate
|
||||||
- [ ] Database esercizi
|
- [ ] Database esercizi
|
||||||
|
|||||||
+218
-160
@@ -1,23 +1,27 @@
|
|||||||
# Modulo — Notifiche email
|
# Modulo — Notifiche email
|
||||||
|
|
||||||
**Stato:** progettato, non implementato (DD-036, in valutazione)
|
**Stato:** implementato e verificato in locale (migration `m22`, worker in `mailer/`); non ancora
|
||||||
**File previsti:** `mailer/` (worker Node + `Dockerfile` + `docker-compose.yml`), una migration
|
applicato in produzione né messo in funzione sull'host (vedi [Messa in funzione](#messa-in-funzione))
|
||||||
`m22_…` per la coda e la preferenza, `src/lib/notifiche-email-core.ts` (logica pura, testata)
|
**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
|
## Obiettivo
|
||||||
|
|
||||||
Far arrivare le notifiche di CrAPP anche per email, all'indirizzo Gmail con cui il giocatore
|
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
|
si è registrato, **a costo zero**. È un terzo canale accanto alla push e al centro notifiche
|
||||||
canale accanto alla push e al centro notifiche in-app ([notifiche.md](notifiche.md)): non li
|
in-app ([notifiche.md](notifiche.md)): non li sostituisce e non introduce eventi nuovi. Manda
|
||||||
sostituisce, e non introduce nuovi eventi. Manda per email ciò che già finisce in
|
per email **ogni notifica che finisce già in `notifiche_utente`**, di qualunque tipo e per
|
||||||
`notifiche_utente`.
|
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
|
## Architettura
|
||||||
|
|
||||||
```
|
```
|
||||||
App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ notifiche_email_coda (Supabase)
|
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)
|
Container Docker sul tuo host (mailer)
|
||||||
│ SMTP 587 (STARTTLS)
|
│ SMTP 587 (STARTTLS)
|
||||||
@@ -25,201 +29,255 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
|
|||||||
smtp.gmail.com ──▶ casella Gmail del giocatore
|
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
|
uscita**: interroga Supabase e parla con Gmail. Nessuna porta aperta sul router, nessun IP
|
||||||
pubblico, nessun DNS dinamico, nessun tunnel.
|
pubblico, nessun tunnel.
|
||||||
- **L'app non invia mai email direttamente.** Continua a scrivere `notifiche_utente` come oggi
|
- **L'app non invia mai email direttamente.** Continua a scrivere `notifiche_utente` come
|
||||||
(le quattro sorgenti di M17 restano invariate). Se l'host è spento le mail restano in coda
|
prima: le route e i cron di M17 non sono stati toccati. Se l'host è spento le mail restano in
|
||||||
e partono alla ripartenza: la consegna è in ritardo, mai persa.
|
coda e partono alla ripartenza: consegna in ritardo, mai persa.
|
||||||
- **Tutta la logica di accodamento sta nel database**, così vale per ogni sorgente, cron
|
- **L'accodamento sta nel database** (un trigger), quindi vale per ogni sorgente, cron
|
||||||
`pg_cron` compresi, senza toccare le route esistenti.
|
`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
|
## Mittente: Gmail con password per app
|
||||||
|
|
||||||
Il worker invia con un **account Gmail dedicato** (es. `crapp.notifiche@gmail.com`, non
|
Il worker invia con un **account Gmail dedicato** (es. `crapp.notifiche@gmail.com`, non quello
|
||||||
quello personale) tramite `smtp.gmail.com:587`, autenticandosi con una **password per app**.
|
personale) tramite `smtp.gmail.com:587`, autenticandosi con una **password per app**.
|
||||||
|
|
||||||
| Requisito | Dettaglio |
|
| Requisito | Dettaglio |
|
||||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Verifica in due passaggi | Obbligatoria sull'account: senza, Google non permette di creare le password per app. |
|
| 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. |
|
| 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. |
|
| 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 può essere «CrAPP», l'indirizzo no. |
|
| 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. |
|
| 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
|
Perché Gmail e non un relay come Brevo o Resend: i destinatari sono già su Gmail, quindi la mail
|
||||||
mail parte e arriva dentro la stessa infrastruttura, con SPF e DKIM già a posto e senza dover
|
parte e arriva dentro la stessa infrastruttura, con SPF e DKIM già a posto e senza comprare né
|
||||||
comprare né configurare un dominio. Un relay dedicato ha senso solo se un giorno si vorrà un
|
configurare un dominio. **Non si invia mai direttamente dal proprio IP di casa**: gli IP
|
||||||
mittente col dominio della squadra (vedi Riesame in DD-036). **Non si invia mai direttamente
|
residenziali sono nelle blocklist e senza reverse DNS le mail finiscono in spam o vengono
|
||||||
dal proprio IP di casa**: gli IP residenziali sono nelle blocklist e senza reverse DNS le mail
|
rifiutate.
|
||||||
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
|
Lo schema completo, con permessi e note, sta in [DATABASE.md](../DATABASE.md); qui il quadro
|
||||||
migration. La bozza:
|
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 |
|
| Colonna | Note |
|
||||||
| -------------------- | ------------------- | --------------------------------------------------------------------- |
|
| -------------------- | ----------------------------------------------------------------------- |
|
||||||
| `notifica_id` | uuid PK, FK cascade | `notifiche_utente(id)`: cancellata la notifica sparisce anche la riga |
|
| `stato` | `in_coda` · `in_invio` · `inviata` · `fallita` · `saltata` |
|
||||||
| `stato` | text | `in_coda` · `in_invio` · `inviata` · `fallita` · `saltata` |
|
| `tentativi` | invii falliti finora; non cresce per i rinvii «differiti» (vedi sotto) |
|
||||||
| `tentativi` | int | incrementato a ogni invio fallito |
|
| `prossimo_tentativo` | il worker prende la riga solo se `<= now()` (backoff) |
|
||||||
| `prossimo_tentativo` | timestamptz | il worker la prende solo se `<= now()` (backoff) |
|
| `errore` | solo i codici SMTP dell'ultimo errore, mai il testo delle mail |
|
||||||
| `errore` | text | ultimo messaggio d'errore SMTP |
|
| `inviata_il` | valorizzato a invio riuscito; alimenta il conteggio delle ultime 24 ore |
|
||||||
| `inviata_il` | timestamptz | valorizzato a invio riuscito |
|
| `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
|
È una tabella a parte e non colonne su `notifiche_utente` perché quella ha una policy `UPDATE`
|
||||||
`UPDATE` per il giocatore su tutta la riga, quindi un client potrebbe riportare a `in_coda` una
|
per il giocatore su tutta la riga: un client potrebbe riportare a `in_coda` una mail già partita.
|
||||||
mail già partita. La coda invece ha RLS attiva **senza nessuna policy**: la vede e la scrive
|
La coda ha la RLS attiva **senza alcuna policy** e i permessi tolti a `anon` e `authenticated`:
|
||||||
solo la service role (il worker), mai un client.
|
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
|
**`preferenze_utente`** — una riga per **account** (`auth_user_id`, non lo slot giocatore), per
|
||||||
`auth.users` con cascade), non per slot giocatore. Per ora ha una sola colonna,
|
ora con la sola colonna `email_notifiche` (default `true`). **Nessuna riga significa email
|
||||||
`email_notifiche boolean NOT NULL DEFAULT true`. RLS: ognuno legge e scrive solo la propria
|
attive**: la riga nasce solo quando il giocatore tocca l'interruttore, con un upsert; ognuno
|
||||||
riga (`auth_user_id = auth.uid()`), il worker la legge con la service role. **Nessuna riga
|
legge e scrive solo la propria, per RLS. Non sta su `giocatori_squadra`, che ha una riga per slot
|
||||||
significa acceso**: la riga nasce solo quando il giocatore tocca l'interruttore (upsert), e
|
e il giocatore può modificare solo in parte (DD-016).
|
||||||
la funzione `prendi_notifiche_email` tratta l'assenza come `true`. Non va su
|
|
||||||
`giocatori_squadra`, che ha una riga per slot e il giocatore può modificare solo in parte
|
|
||||||
(DD-016).
|
|
||||||
|
|
||||||
**Indirizzo destinatario** — `giocatori_squadra.email`, già usata per il collegamento
|
**Funzioni RPC** — tutte `SECURITY DEFINER` e con `EXECUTE` solo alla service role:
|
||||||
automatico account↔giocatore (DD-018). Niente lettura di `auth.users`.
|
|
||||||
|
| 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
|
## Flusso
|
||||||
|
|
||||||
1. Una sorgente M17 inserisce (o aggiorna via `upsert`) una riga in `notifiche_utente`.
|
1. Una sorgente M17 inserisce (o aggiorna con `upsert`) una riga in `notifiche_utente`.
|
||||||
2. Un trigger `AFTER INSERT OR UPDATE OF creato_il` la accoda in `notifiche_email_coda`,
|
2. Il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`) la mette in
|
||||||
L'`upsert` di turno palloni e sollecito
|
`notifiche_email_coda`. L'`upsert` di turno palloni e sollecito presenze riscrive `creato_il`:
|
||||||
presenze aggiorna `creato_il`: è un rinvio voluto, e rimette in coda anche la mail.
|
è un rinvio voluto e rimette in coda anche la mail. Segnare come letta e l'`ON CONFLICT DO NOTHING`
|
||||||
3. Il worker ogni ~30 secondi chiama la funzione RPC `prendi_notifiche_email(n)`, riservata
|
dei cron non toccano quella colonna e non accodano nulla.
|
||||||
alla service role. Seleziona fino a `n` righe `in_coda` con `FOR UPDATE SKIP LOCKED`, le
|
3. Ogni `POLL_SECONDI` (30) il worker conta le mail delle ultime 24 ore, ne chiede al massimo
|
||||||
marca `in_invio` e restituisce titolo, corpo, `evento_id` e indirizzo, già filtrate per
|
quante ne restano sotto il tetto (al più `MAIL_LOTTO`) a `prendi_notifiche_email` e le invia
|
||||||
preferenza attiva e slot con `auth_user_id` valorizzato. Con `SKIP LOCKED` due worker
|
una a una. Prima di prendere il lotto la funzione SQL fa due pulizie: riporta a `in_coda` le
|
||||||
avviati per errore non mandano la stessa mail due volte.
|
righe ferme `in_invio` da più di 10 minuti (worker morto a metà) e segna `saltata` quelle
|
||||||
4. Il worker invia con `nodemailer`, poi segna `inviata` o `fallita`.
|
senza destinatario o con le email spente.
|
||||||
5. Riga senza destinatario (slot non ancora collegato a un account, preferenza spenta,
|
4. Ogni invio registra l'esito con `esito_notifica_email`.
|
||||||
email assente): `saltata`, senza errore.
|
|
||||||
|
|
||||||
**Ogni notifica presente in `notifiche_utente` genera una mail**, di qualunque tipo:
|
La preferenza è valutata **al momento dell'invio**, non dell'accodamento: spegnere l'interruttore
|
||||||
`admin`, `evento_promemoria_24h`, `evento_promemoria_3h`, `turno_palloni`,
|
ferma anche le mail già in coda.
|
||||||
`sollecita_presenze`. Il modulo non aggiunge sorgenti né cambia la logica di M17: un tipo
|
|
||||||
nuovo che in futuro finisse in `notifiche_utente` viaggerebbe per email senza altro lavoro.
|
|
||||||
|
|
||||||
**Chi le riceve.** Con la stessa logica delle notifiche in-app: la mail va a chi ha la riga in
|
|
||||||
`notifiche_utente`, admin e allenatori compresi. Non c'è un percorso separato per gli admin: un
|
|
||||||
admin riceve ciò che riceverebbe come giocatore (i destinatari di ogni sorgente restano quelli
|
|
||||||
già definiti in M17 e DD-034).
|
|
||||||
|
|
||||||
Il certificato in scadenza **non genera mail**: non è una notifica ma una card calcolata in
|
|
||||||
Home (DD-035), e questo modulo non la trasforma in una. Se un giorno la si volesse anche per
|
|
||||||
email, sarebbe una sorgente nuova di `notifiche_utente` da decidere a parte.
|
|
||||||
|
|
||||||
## Affidabilità
|
## Affidabilità
|
||||||
|
|
||||||
| Situazione | Comportamento |
|
Il worker classifica l'errore SMTP (`classificaErroreSmtp`):
|
||||||
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Errore temporaneo (rete, 4xx SMTP) | Backoff 1 · 5 · 30 · 120 min, poi `fallita` dopo 5 tentativi. |
|
|
||||||
| Errore permanente (5xx, indirizzo KO) | `fallita` subito, senza altri tentativi. |
|
|
||||||
| Worker fermo o host spento | Le mail restano `in_coda`; partono alla ripartenza. |
|
|
||||||
| Crash tra l'invio e l'aggiornamento | Possibile un doppione: la consegna è _almeno una volta_. Il `Message-ID` è deterministico (derivato da `notifica_id`). |
|
|
||||||
| Righe `in_invio` orfane | All'avvio il worker riporta a `in_coda` quelle ferme da più di 10 minuti. |
|
|
||||||
| Limite giornaliero Gmail raggiunto | Il worker si ferma fino a mezzanotte (fuso Europe/Rome) e riprende; nessuna riga viene scartata. |
|
|
||||||
|
|
||||||
## Worker (`mailer/`)
|
| 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
|
Altre situazioni:
|
||||||
controllo `minimumReleaseAge` di `bunfig.toml` come per il resto del progetto.
|
|
||||||
|
|
||||||
```yaml
|
| Situazione | Comportamento |
|
||||||
# mailer/docker-compose.yml (bozza)
|
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
services:
|
| Worker fermo o host spento | Le mail restano `in_coda` e partono alla ripartenza. |
|
||||||
mailer:
|
| 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. |
|
||||||
build: .
|
| 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. |
|
||||||
restart: unless-stopped
|
| 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. |
|
||||||
env_file: .env
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "node", "healthcheck.js"]
|
|
||||||
interval: 60s
|
|
||||||
retries: 3
|
|
||||||
```
|
|
||||||
|
|
||||||
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 |
|
**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
|
||||||
| `SUPABASE_URL` | URL del progetto |
|
l'ultimo è più vecchio di 5 minuti. Un ciclo fermato dalle credenziali SMTP rifiutate **non**
|
||||||
| `SUPABASE_SERVICE_ROLE_KEY` | chiave service role: salta la RLS, va trattata come una password |
|
aggiorna il file, così dopo qualche minuto `docker ps` mostra `unhealthy` invece di far credere
|
||||||
| `SMTP_USER`, `SMTP_PASS` | account Gmail dedicato e relativa password per app |
|
che tutto funzioni. Un ciclo fermato dal solo tetto giornaliero è normale e resta sano.
|
||||||
| `MAIL_FROM_NAME` | nome visibile, es. «CrAPP» |
|
|
||||||
| `POLL_SECONDI` | intervallo di polling, default 30 |
|
|
||||||
| `APP_URL` | base dei link nelle mail (es. `/evento/<id>`) |
|
|
||||||
|
|
||||||
`healthcheck.js` segnala non sano il container se l'ultimo ciclo di polling riuscito è più
|
|
||||||
vecchio di qualche minuto. Il log dice solo id, tipo e esito: **mai indirizzi o testi**.
|
|
||||||
|
|
||||||
## Contenuto delle mail
|
## Contenuto delle mail
|
||||||
|
|
||||||
Sobrio, testo semplice più una versione HTML minima, leggibile da smartphone: oggetto = titolo
|
Sobrio, in 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
|
della notifica (su una riga sola, massimo 200 caratteri), corpo = corpo della notifica, un
|
||||||
`evento_id` c'è. In fondo una riga che spiega come disattivare le email (Profilo → Opzioni).
|
pulsante «Apri CrAPP» verso `/evento/<id>` quando la notifica riguarda un evento e verso la home
|
||||||
Nessuna immagine remota, nessun tracciamento delle aperture.
|
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
|
||||||
## Privacy e sicurezza
|
sempre escapato nell'HTML. Il `Message-ID` è derivato dall'id della notifica.
|
||||||
|
|
||||||
- Le email personali dei giocatori non lasciano Supabase se non verso Gmail, che è già dove
|
|
||||||
stanno. Il worker legge solo l'indirizzo dei destinatari del lotto, non l'intera rosa.
|
|
||||||
- La service role sta solo sull'host, in un `.env` con permessi `600`; non passa da Vercel.
|
|
||||||
- La password per app si può revocare da Google senza toccare l'account.
|
|
||||||
- Il canale è **opt-out**: acceso di default, spegnibile dal giocatore. Un interruttore
|
|
||||||
unico, come per la push (nessuna preferenza per tipo).
|
|
||||||
|
|
||||||
## Interfaccia
|
## Interfaccia
|
||||||
|
|
||||||
Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore «Email» che scrive la
|
Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore **«Email»** (icona
|
||||||
preferenza per account. Nessuna schermata nuova. Nella tab «Notifiche» della dashboard admin
|
busta) che scrive `preferenze_utente` per l'account, con lo stesso stile dell'interruttore
|
||||||
si può aggiungere in un secondo momento uno stato per giocatore (email attive o no), come già
|
«Notifiche» (`useEmailNotifiche()` in `src/lib/preferenze-utente.ts`). Vale per tutti gli slot
|
||||||
fa la campana per la push.
|
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
|
## Limiti noti
|
||||||
|
|
||||||
- Il canale email dipende da un host di casa acceso: non c'è alta disponibilità.
|
- 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
|
- Un 250 da Gmail vuol dire «accettata», non «letta»: non c'è conferma di lettura e non se ne
|
||||||
vuole una.
|
vuole una.
|
||||||
- Gmail può finire in spam se il testo è promozionale o se l'account nuovo invia subito
|
- Gmail può mandare in spam un account nuovo che invia subito molti messaggi o un testo
|
||||||
molti messaggi: si parte con pochi invii e si tiene il tono di servizio.
|
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
|
- Un account collegato a più slot giocatore (`auth_user_id` ripetuto) riceve una mail per slot,
|
||||||
slot, dato che `notifiche_utente` ha una riga per `giocatore_id`.
|
dato che `notifiche_utente` ha una riga per `giocatore_id`.
|
||||||
- Cambiare l'indirizzo in `giocatori_squadra.email` cambia dove vanno le mail future; le
|
- Cambiare l'indirizzo in `giocatori_squadra.email` cambia dove vanno le mail future; le righe già
|
||||||
righe già `inviata` non si toccano.
|
`inviata` non si toccano.
|
||||||
|
- Nessun test automatico ha inviato una mail a Gmail: la prova con l'account vero fa parte della
|
||||||
## Test previsti
|
messa in funzione.
|
||||||
|
- `notifiche_email_coda` non ha pulizia automatica, come `notifiche_utente` (DD-030): le righe
|
||||||
Come da [AGENTS.md](../../AGENTS.md), le funzioni si scrivono con il loro test.
|
spariscono quando la notifica viene eliminata.
|
||||||
|
|
||||||
- **Unit** (`test/unit/`, su `notifiche-email-core.ts`): calcolo
|
|
||||||
del backoff, classificazione errore temporaneo/permanente, composizione di oggetto e corpo,
|
|
||||||
`Message-ID` deterministico.
|
|
||||||
- **Integrazione** (`test/integration/`, contro il database locale): il trigger accoda ogni
|
|
||||||
nuova notifica; `upsert` rimette in coda; RLS senza policy (un client non legge né scrive
|
|
||||||
la coda); `prendi_notifiche_email` non restituisce due volte la stessa riga con due chiamate
|
|
||||||
concorrenti, salta chi ha la preferenza spenta o non ha un account collegato.
|
|
||||||
- Il transport SMTP si sostituisce con un finto nei test: nessun invio reale in CI.
|
|
||||||
|
|
||||||
## Piano di realizzazione
|
|
||||||
|
|
||||||
1. Migration `m22_…`: coda, preferenza, trigger, funzione RPC, RLS (poi `DATABASE.md`).
|
|
||||||
2. `notifiche-email-core.ts` con i test unitari.
|
|
||||||
3. Worker `mailer/` con `Dockerfile`, compose, healthcheck.
|
|
||||||
4. Interruttore in Profilo → Opzioni.
|
|
||||||
5. Prova end-to-end su `npx supabase start` con un server SMTP di prova (es. Mailpit in
|
|
||||||
Docker), poi con l'account Gmail vero.
|
|
||||||
6. `CHANGELOG.md`, `ROADMAP.md` (spunta), `PROJECT_STATE.md`, `notifiche.md` (rimando qui).
|
|
||||||
|
|
||||||
## Evoluzioni possibili
|
## Evoluzioni possibili
|
||||||
|
|
||||||
- Un relay con dominio proprio (Brevo, Resend) se serve un mittente della squadra o si supera
|
- Un relay con dominio proprio (Brevo, Resend) se serve un mittente della squadra o si supera il
|
||||||
il tetto di Gmail.
|
tetto di Gmail.
|
||||||
- Riepilogo settimanale unico al posto di una mail per notifica.
|
- 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`).
|
||||||
@@ -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
|
× 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.
|
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
|
## Limiti noti
|
||||||
|
|
||||||
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
|
- 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
|
## 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.
|
- Preferenze per canale (palloni, solleciti, smart), se servono davvero alla squadra.
|
||||||
- Eliminare `promemoria_push` con una migrazione.
|
- Eliminare `promemoria_push` con una migrazione.
|
||||||
- Gestire esplicitamente il caso iOS (messaggio se l'app non è installata da Home).
|
- Gestire esplicitamente il caso iOS (messaggio se l'app non è installata da Home).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
README.md
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = []
|
||||||
@@ -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"
|
||||||
@@ -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);
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oggetto su una riga sola (niente a-capo nelle intestazioni), con un limite di lunghezza. */
|
||||||
|
export function pulisciOggetto(titolo: string): string {
|
||||||
|
const pulito = titolo
|
||||||
|
.replace(/[\r\n]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
return (pulito || "Notifica CrAPP").slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link da aprire dalla mail: la scheda dell'evento se c'è, altrimenti la home dell'app. */
|
||||||
|
export function linkNotifica(appUrl: string, idEvento: string | null): string {
|
||||||
|
const base = appUrl.replace(/\/+$/, "");
|
||||||
|
return idEvento ? `${base}/evento/${encodeURIComponent(idEvento)}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Message-ID deterministico: lo stesso invio ripetuto porta lo stesso identificativo. */
|
||||||
|
export function messageId(idNotifica: string, dominio: string): string {
|
||||||
|
return `<${idNotifica}@${dominio}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVVISO_DISATTIVAZIONE = "Non vuoi più ricevere queste email? Profilo → Opzioni → Email.";
|
||||||
|
|
||||||
|
export function componiMail(
|
||||||
|
n: NotificaEmail,
|
||||||
|
opzioni: { appUrl: string; dominioMessageId: string },
|
||||||
|
): MailComposta {
|
||||||
|
const link = linkNotifica(opzioni.appUrl, n.id_evento);
|
||||||
|
const subject = pulisciOggetto(n.oggetto);
|
||||||
|
const testo = [n.testo.trim(), `Apri CrAPP: ${link}`, AVVISO_DISATTIVAZIONE]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
const corpoHtml = n.testo.trim()
|
||||||
|
? `<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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
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 { cn } from "@/lib/utils";
|
||||||
import { Card, PageHeader, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
|
import { Card, PageHeader, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
|
||||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from "@/lib/giocatori-squadra";
|
} from "@/lib/giocatori-squadra";
|
||||||
import { esci } from "@/lib/auth";
|
import { esci } from "@/lib/auth";
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
import { useIsAdmin } from "@/lib/ruoli";
|
||||||
|
import { useEmailNotifiche } from "@/lib/preferenze-utente";
|
||||||
import { Reveal } from "@/components/motion/Reveal";
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
import { version as APP_VERSION } from "../../package.json";
|
import { version as APP_VERSION } from "../../package.json";
|
||||||
|
|
||||||
@@ -255,6 +256,7 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
|
|||||||
const [notifiche, setNotifiche] = useState(false);
|
const [notifiche, setNotifiche] = useState(false);
|
||||||
const [inCorso, setInCorso] = useState(false);
|
const [inCorso, setInCorso] = useState(false);
|
||||||
const [supportate, setSupportate] = useState(true);
|
const [supportate, setSupportate] = useState(true);
|
||||||
|
const email = useEmailNotifiche();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSupportate(pushSupportato());
|
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() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await esci();
|
await esci();
|
||||||
@@ -322,6 +335,31 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
|
|||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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 ? (
|
{admin ? (
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
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
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
|
|||||||
|
|
||||||
## Struttura
|
## Struttura
|
||||||
|
|
||||||
| Cartella | Cosa verifica | Serve rete? |
|
| 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 |
|
| `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 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ì |
|
| `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ì |
|
| `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 | — |
|
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||||
|
|
||||||
## Database locale in Docker
|
## Database locale in Docker
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, /<img src=x onerror=alert\(1\)><br>riga "2"/);
|
||||||
|
assert.match(m.html, /href="https:\/\/crapp\.example"/);
|
||||||
|
assert.equal(escapeHtml(`<&>"'`), "<&>"'");
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("titolo con HTML: l'intestazione della mail è sempre esclusa dal markup", () => {
|
||||||
|
const m = componiMail(notifica({ oggetto: "<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");
|
||||||
@@ -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");
|
||||||
Reference in new issue
Block a user