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