From 58eba642f7f7dc1e31aff8222d0a9973ea23c1d0 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 29 Sep 2026 12:22:46 +0200 Subject: [PATCH] Documenta l'installazione e la gestione del worker email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuovo docs/WORKER_EMAIL.md (password per app, installazione, gestione, diagnosi, prova in locale, spostamento su un altro host); il modulo notifiche-email.md resta la sola specifica funzionale. Allineati DD-036, CHANGELOG, PROJECT_STATE, indice e portabilità: migration m22 e worker in produzione dal 29/09/2026. Co-Authored-By: Claude Sonnet 5.5 --- PROJECT_STATE.md | 16 +-- docs/CHANGELOG.md | 7 +- docs/DESIGN_DECISIONS.md | 14 ++- docs/PORTABILITA.md | 19 +-- docs/README.md | 1 + docs/WORKER_EMAIL.md | 213 +++++++++++++++++++++++++++++++ docs/modules/notifiche-email.md | 214 +++++++++++++------------------- 7 files changed, 330 insertions(+), 154 deletions(-) create mode 100644 docs/WORKER_EMAIL.md diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index e00a69e..bc2ea16 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -24,13 +24,12 @@ 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` -- 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) +- 33 migration in `supabase/migrations/`, fino a `m22_notifiche_email` (29/09/2026), tutte + applicate in produzione (verificato con `npx supabase migration list` il 29/09/2026) +- Worker delle notifiche email (`mailer/`) in funzione su un Raspberry Pi con Docker dal + 29/09/2026: invia via Gmail le notifiche di `notifiche_utente`. Installazione e gestione in + [docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md); il codice con l'interruttore «Email» in Profilo è + su `develop`, non ancora su `main` - Sviluppo locale verificato con il nuovo Supabase --- @@ -64,7 +63,8 @@ da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16). `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** + una prova end-to-end con il worker in Docker contro Mailpit, poi **in produzione** dal + 29/09/2026 (`supabase db push`) con il worker in funzione - Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (24/09/2026, DD-034): ruolo allenatore (colonna `giocatori_squadra.tipo`, valore `allenatore` di `app_role`, policy e trigger) — verificate in locale con `npx supabase db reset` e `npm run test:all`, **in diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7b33182..ad8f21f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,9 +20,10 @@ versioni `0.y.z` erano pre-release. 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). + l'interruttore «Email», acceso di default (DD-036). Migration e worker sono in produzione dal + 29/09/2026; l'interruttore compare con la prossima versione dell'app che lo include. Specifica in + [notifiche-email.md](modules/notifiche-email.md), installazione e gestione in + [WORKER_EMAIL.md](WORKER_EMAIL.md). ## [1.1.1] - 2026-09-28 diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 905f5de..33c8170 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -1540,7 +1540,8 @@ invia tramite `smtp.gmail.com` con un account Gmail dedicato e una password per - Canale **opt-out**: acceso di default per tutti, con un interruttore per account in Profilo → Opzioni. La preferenza sta in una tabella nuova, `preferenze_utente`, una riga per account (non per slot); l'assenza di riga vale «acceso». -- Specifica completa in [notifiche-email.md](modules/notifiche-email.md). +- Specifica completa in [notifiche-email.md](modules/notifiche-email.md); installazione e + gestione in [WORKER_EMAIL.md](WORKER_EMAIL.md). **Alternative scartate** @@ -1577,8 +1578,13 @@ invia tramite `smtp.gmail.com` con un account Gmail dedicato e una password per l'indicazione in ogni mail su come disattivarlo. - Una tabella in più, `preferenze_utente`, pensata per accogliere in futuro anche le preferenze per tipo di notifica senza toccare `giocatori_squadra`. +- In produzione dal 29/09/2026 (migration e worker su un Raspberry Pi). Dalla prova reale: la + prima mail, da un account Gmail nuovo, è arrivata ma nella cartella spam, e la password per app + non era disponibile finché l'account aveva le sole passkey come secondo passaggio (dettagli in + [WORKER_EMAIL.md](WORKER_EMAIL.md#account-gmail-e-password-per-app)). **Riesame** -Se serve un mittente col dominio della squadra, se si supera il tetto giornaliero di Gmail, o -se l'host di casa si rivela poco affidabile: passare a un relay con dominio e, in quel caso, -valutare Edge Function più `pg_cron` al posto del container. +Se serve un mittente col dominio della squadra, se le mail finiscono in spam anche dopo qualche +giorno di uso, se si supera il tetto giornaliero di Gmail, o se l'host di casa si rivela poco +affidabile: passare a un relay con dominio e, in quel caso, valutare Edge Function più `pg_cron` +al posto del container. diff --git a/docs/PORTABILITA.md b/docs/PORTABILITA.md index 9b89f3f..eed5945 100644 --- a/docs/PORTABILITA.md +++ b/docs/PORTABILITA.md @@ -5,15 +5,16 @@ senza dipendere da servizi esclusivi di Lovable Cloud. ## Stato attuale -| Componente | Portabile? | Note | -| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ | -| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. | -| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. | -| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. | -| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). | -| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. | -| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. | -| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. | +| Componente | Portabile? | Note | +| ------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. | +| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. | +| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. | +| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). | +| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. | +| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. | +| Worker email (`mailer/`) | Sì | Container su qualsiasi host con Docker; parla solo con Supabase (RPC) e con un server SMTP a scelta ([WORKER_EMAIL.md](WORKER_EMAIL.md)). | +| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. | ## Regole da rispettare nelle prossime modifiche diff --git a/docs/README.md b/docs/README.md index ab11f09..8658b50 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--svi | [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta | | [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio (backup/disaster recovery incluso) | | [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push | +| [WORKER_EMAIL.md](WORKER_EMAIL.md) | Come si installa, si gestisce e si diagnostica il worker che invia le notifiche per email | | [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando | | [modules/](modules/) | Specifica funzionale di ogni modulo, una per file | diff --git a/docs/WORKER_EMAIL.md b/docs/WORKER_EMAIL.md new file mode 100644 index 0000000..ef3d2a3 --- /dev/null +++ b/docs/WORKER_EMAIL.md @@ -0,0 +1,213 @@ +# Worker email: installazione e gestione + +Come si mette in funzione, si controlla e si diagnostica il worker che invia le notifiche per email +(`mailer/`). Cosa fa e come si comporta il canale sta in +[modules/notifiche-email.md](modules/notifiche-email.md); perché è fatto così in +[DD-036](DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa). + +**Stato in produzione:** migration `m22` applicata e worker in funzione dal 29/09/2026, su un +Raspberry Pi con Docker. + +## Cosa serve + +| Requisito | Note | +| ---------------------- | -------------------------------------------------------------------------------------------------- | +| Un host con Docker | Sempre acceso e con la porta 587 in uscita libera. Nessuna porta in entrata, nessun IP pubblico. | +| Account Gmail dedicato | Con verifica in due passaggi e una password per app (vedi sotto). | +| Chiave del progetto | La service role di Supabase, oggi nel `.env` della root del repo come `SUPABASE_SERVICE_ROLE_KEY`. | +| Indirizzo dell'app | L'https pubblico a cui puntano i link nelle mail (`APP_URL`). | + +Il container usa l'immagine `oven/bun:1-alpine`, disponibile anche per ARM (Raspberry Pi). + +## Account Gmail e password per app + +Il worker non usa la password dell'account: Google la rifiuterebbe. Serve una **password per app**. + +1. Meglio un account **dedicato** e non quello personale: i messaggi partono da quell'indirizzo. +2. Attiva la **verifica in due passaggi** (myaccount.google.com → Sicurezza e accesso). +3. Apri direttamente **https://myaccount.google.com/apppasswords**: la voce non compare nei menu. +4. Dai un nome (per esempio «CrAPP mailer») e crea. Google mostra **una volta sola** una password di + 16 caratteri, a gruppi di quattro: copiala. +5. Nel `.env` va scritta **senza spazi** (16 caratteri attaccati): con gli spazi il file, letto come + script di shell, si rompe. Non va mai incollata in chat né nel repository. + +Si può revocare in ogni momento dalla stessa pagina, senza cambiare la password dell'account. + +**Se la pagina dice «L'impostazione che stai cercando non è disponibile per il tuo account»:** + +| Causa | Cosa fare | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Verifica in due passaggi non attiva, o solo passkey come secondo passaggio | Attiva la verifica e aggiungi anche un'**app di autenticazione** (myaccount.google.com/signinoptions/twosv). | +| Protezione avanzata attiva | Disattivala da myaccount.google.com/advanced-protection. | +| Account Workspace con l'impostazione disattivata dall'amministratore | Usa un account `@gmail.com` normale. | + +Se nulla funziona, l'alternativa è un altro account Gmail creato da zero, oppure un relay con un +dominio proprio (vedi DD-036). + +## Installazione + +1. **Migration in produzione.** `npx supabase db push` applica `m22`. È additiva: 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 subito**, nella stessa sessione. Le notifiche + accodate e non ancora inviate partirebbero tutte insieme al primo avvio, anche se ormai vecchie + (il 29/09/2026 sono partiti così due messaggi di prova dell'admin). + +2. **Configura il worker.** In `mailer/`: `cp .env.example .env`, `chmod 600 .env` e compila: + + | 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, in https e senza slash finale | + | `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, sotto i circa 500 di Gmail | + | `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 porta 465) | + + `SMTP_USER` e `SMTP_PASS` vanno impostati insieme o nessuno dei due (senza credenziali solo per un + server di prova locale). Un numero non valido o una variabile obbligatoria mancante fa uscire il + worker con l'elenco degli errori. Il `.env` è ignorato da git. + + **`APP_URL`:** usa un indirizzo stabile, cioè il dominio di produzione o l'alias del branch + (`…-git--….vercel.app`). L'indirizzo di un singolo deploy (`…--….vercel.app`) + cambia a ogni pubblicazione e mostra il codice di quel momento. Se il progetto Vercel ha la + Deployment Protection attiva, chi apre il link di un'anteprima vede la richiesta di accesso a + Vercel. + +3. **Avvia:** `docker compose up -d --build` dentro `mailer/`. Il container ha + `restart: unless-stopped`, quindi riparte da solo dopo un riavvio dell'host. + +4. **Verifica:** `docker compose logs` deve mostrare «SMTP raggiungibile e credenziali accettate»; + dopo circa 30 secondi `docker ps` deve mostrare `healthy`. Poi manda un messaggio a un giocatore + dalla dashboard admin e controlla che la riga in coda passi a `inviata` e la mail arrivi. + +## Gestione + +| Cosa | Comando (dentro `mailer/`) | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Vedere il log in diretta | `docker compose logs -f` | +| Ultime righe del log | `docker compose logs --tail 50` | +| Stato e salute | `docker ps` (colonna STATUS: `healthy` / `unhealthy`) | +| Dopo aver cambiato `.env` | `docker compose up -d --force-recreate` (il `.env` è riletto a ogni avvio; non serve ricostruire l'immagine) | +| Dopo aver cambiato il codice | `git pull`, poi `docker compose up -d --build` | +| Fermarlo | `docker compose down` (le mail restano in coda e partono alla ripartenza) | +| Spegnere subito l'accodamento | `DROP TRIGGER notifiche_utente_accoda_email ON public.notifiche_utente;` da SQL: le notifiche tornano come prima di M22. Per riattivarlo si ricrea il trigger come in `m22`. | + +Il log riporta solo id notifica, tipo ed esito (`inviata`, `da riprovare tra N min`, `fallita`, +`ciclo fermato`), mai indirizzi né testi. + +## Vedere cosa è partito + +- **Posta inviata dell'account Gmail:** le mail complete, come le ha ricevute il destinatario. È il + riscontro più sicuro. +- **SQL Editor di Supabase**, per stato e contenuto: + + ```sql + select c.stato, c.inviata_il, c.tentativi, c.errore, + g.nome, g.cognome, n.tipo, n.titolo, n.corpo + from notifiche_email_coda c + join notifiche_utente n on n.id = c.notifica_id + join giocatori_squadra g on g.id = n.giocatore_id + order by n.creato_il desc + limit 50; + ``` + + Per un riepilogo: `select stato, count(*) from notifiche_email_coda group by stato;`. + Il testo del messaggio sta in `titolo` (oggetto) e `corpo`. + +## Spostare il worker su un altro host + +Il container non conserva nulla: la coda, gli esiti e il conteggio delle ultime 24 ore stanno in +Supabase. Cambiare host non fa perdere né duplicare mail. + +1. **Sul nuovo host** servono Docker con il plugin Compose e la porta 587 in uscita libera. + L'immagine `oven/bun` esiste sia per ARM sia per x86, quindi l'architettura può cambiare. +2. **Porta il codice:** `git clone` del repository (o `git pull` se c'è già) e usa la cartella + `mailer/`. Se la copi a mano, salta `node_modules`: l'immagine si costruisce da sola. +3. **Copia il `.env`** con un canale sicuro (per esempio `scp`), poi `chmod 600 .env`. Non passa da + chat né da git: contiene la service role e la password per app. +4. **Spegni il vecchio:** `docker compose down` sul vecchio host. +5. **Avvia il nuovo:** `docker compose up -d --build` dentro `mailer/`. Devono comparire «SMTP + raggiungibile e credenziali accettate» nel log e, dopo circa 30 secondi, `healthy` in `docker ps`. + +Non serve riconfigurare nulla: `APP_URL`, chiave e password restano quelle del `.env`. Se per un +po' girano entrambi i worker non ci sono mail doppie, perché `prendi_notifiche_email` usa +`FOR UPDATE SKIP LOCKED`; conviene comunque spegnere il vecchio subito, per non confondere i log. +Se il passaggio richiede tempo, le notifiche restano in coda e partono tutte insieme all'avvio del +nuovo worker. + +Al primo accesso da un IP nuovo Gmail può chiedere una conferma di sicurezza sull'account. La +password per app di solito continua a funzionare; se nel log del nuovo host compare `EAUTH`, +controlla la posta dell'account mittente e conferma l'accesso. + +## Diagnosi + +| Sintomo | Causa probabile | Cosa fare | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Il log dice `SMTP non verificato (EAUTH)` | Password per app sbagliata, con spazi, o revocata | Controlla la lunghezza (`awk -F= '/^SMTP_PASS=/{print length($2)}' .env` deve dare 16), rigenera se serve. | +| Container `unhealthy` | Credenziali rifiutate, oppure Supabase non raggiungibile | `docker compose logs --tail 50`. | +| Righe che restano `in_coda` | Worker fermo, oppure tetto giornaliero raggiunto | `docker ps`; il tetto si libera da solo, è una finestra mobile di 24 ore. | +| Righe `saltata` | Slot senza account collegato, senza email, o email spente | Normale: nessun destinatario utile. Verifica `giocatori_squadra.email` e `auth_user_id`. | +| Righe `fallita` | Errore permanente del server di posta | Leggi `errore`: `550` = indirizzo inesistente o rifiutato. | +| La mail arriva ma in spam | Mittente nuovo, nessuna reputazione | «Non è spam» e aggiungere il mittente ai contatti; vedi i limiti noti del modulo. | +| Il pulsante «Apri CrAPP» porta a una pagina vecchia o di accesso | `APP_URL` è l'indirizzo di un singolo deploy o di un'anteprima protetta | Usa un alias stabile e ricrea il container. | +| L'interruttore «Email» non compare in Profilo | L'app pubblicata non contiene ancora il codice del modulo | Pubblica una versione che lo include. | + +## Prova in locale, senza toccare la produzione + +`npx supabase start` avvia lo stack locale (la migration `m22` si applica con `npx supabase db reset`), +con Mailpit su http://127.0.0.1:54324: le mail non escono dalla macchina. Il worker si può +lanciare senza Docker con `cd mailer && set -a && . ./.env && set +a && bun mailer.mjs`. + +1. **`mailer/.env` per la prova.** `SUPABASE_URL` e `SUPABASE_SERVICE_ROLE_KEY` si leggono con + `npx supabase status -o env` (`API_URL`, `SERVICE_ROLE_KEY`). Per provare **con Gmail vero** si + usano le credenziali reali e il worker gira anche senza Docker; il database resta quello locale. + Per provare **con Mailpit** bisogna far girare il worker in un container (vedi in fondo): la + porta SMTP di Mailpit non è esposta sull'host, solo la sua interfaccia web. In quel caso si + omettono `SMTP_USER` e `SMTP_PASS` e si imposta `MAIL_FROM`. `POLL_SECONDI=5` accorcia l'attesa. +2. **Un giocatore con un account collegato.** Le mail vanno solo agli slot con `auth_user_id` e con + `email`. In locale: + + ```bash + . <(npx supabase status -o env | grep -E '^(API_URL|SERVICE_ROLE_KEY)=') + UID_=$(curl -s -X POST "$API_URL/auth/v1/admin/users" \ + -H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H 'content-type: application/json' \ + -d "{\"email\":\"prova-$(date +%s)@example.test\",\"password\":\"prova-12345\",\"email_confirm\":true}" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])') + docker exec supabase_db_ psql -U postgres -c \ + "set session_replication_role = replica; update giocatori_squadra set auth_user_id='$UID_' where id='';" + ``` + + `session_replication_role = replica` serve perché il trigger di `giocatori_squadra` rifiuta la + service key. L'email dello slot deve essere quella che si vuole ricevere; è **unica** nella + tabella (`giocatori_squadra_email_unica`): se l'indirizzo compare già in un altro slot del seed, + si usa quello slot invece di impostarlo su un secondo. + +3. **Una notifica di prova:** + + ```bash + docker exec supabase_db_ psql -U postgres -c \ + "insert into notifiche_utente (giocatore_id, tipo, titolo, corpo) values ('','admin','Prova CrAPP','Messaggio di prova');" + ``` + + Entro un ciclo di polling il log mostra `inviata …` e la mail compare in Mailpit (o nella casella). + +4. **Fine prova:** `npx supabase db reset` riporta il database locale pulito. + +Per provare anche l'immagine Docker, il container si collega alla rete di Supabase +(`--network supabase_network_`) e usa `http://supabase_kong_:8000` come +`SUPABASE_URL` e `supabase_inbucket_` sulla porta 1025 come SMTP. + +L'app in sviluppo (`npm run dev`) richiede un Node più recente del 18: su un host con Node 18 Vite +non parte, e con lui i test di integrazione che avviano il server. I test del database e del worker +non ne hanno bisogno. + +## Sicurezza + +- Il `.env` contiene la service role e la password per app: permessi `600`, mai in git, mai in chat. +- La service role non passa da Vercel: sta solo sull'host del worker. +- Se la password per app o la service role sono state esposte, si revocano/ruotano subito (Google e + dashboard Supabase) e si aggiorna il `.env` con `--force-recreate`. diff --git a/docs/modules/notifiche-email.md b/docs/modules/notifiche-email.md index 6a08620..91f0418 100644 --- a/docs/modules/notifiche-email.md +++ b/docs/modules/notifiche-email.md @@ -1,20 +1,22 @@ # Modulo — Notifiche email -**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)) +**Stato:** implementato e in produzione dal 29/09/2026 (migration `m22`, worker in `mailer/`) **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») +Questo documento descrive **cosa fa** il canale email. Come si installa, si configura e si +diagnostica il worker sta in [WORKER_EMAIL.md](../WORKER_EMAIL.md); le motivazioni delle scelte in +[DD-036](../DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa). + ## Obiettivo -Far arrivare le notifiche di CrAPP anche per email, all'indirizzo Gmail con cui il giocatore -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). +Far arrivare le notifiche di CrAPP anche per email, all'indirizzo Gmail con cui il giocatore 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. ## Architettura @@ -23,31 +25,34 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti ▲ polling in uscita │ RPC con service role │ - Container Docker sul tuo host (mailer) + Container Docker sull'host di casa (mailer) │ SMTP 587 (STARTTLS) ▼ smtp.gmail.com ──▶ casella Gmail del giocatore ``` -- **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 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. +- **Vercel non ospita Docker**, quindi il worker gira su un host proprio e sta **solo in uscita**: + interroga Supabase e parla con Gmail. Nessuna porta aperta sul router, nessun IP 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. +- La chiave con cui il worker si autentica può essere sia una chiave JWT sia una chiave nel formato + nuovo `sb_secret_…`: viene inviata in `apikey` e in `Authorization: Bearer` e Supabase accetta + entrambe (verificato sul progetto di produzione). ## 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. +**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 @@ -61,24 +66,19 @@ Il certificato in scadenza **non genera mail**: non è una notifica ma una card (DD-018). Uno slot senza `auth_user_id` (giocatore non ancora registrato), senza email o il cui account ha spento le email non riceve nulla: la riga in coda passa a `saltata`, senza errore. -## Mittente: Gmail con password per app +## Mittente -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); 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. | +Il worker invia con un **account Gmail dedicato**, tramite `smtp.gmail.com:587`, autenticandosi con +una **password per app**. Come crearla e i problemi noti stanno in +[WORKER_EMAIL.md](../WORKER_EMAIL.md#account-gmail-e-password-per-app). 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. +configurare un dominio. Gmail riscrive l'intestazione `From` con l'indirizzo dell'account: il nome +visibile è «CrAPP», l'indirizzo no. Il tetto è di circa 500 destinatari al giorno per un account +gratuito (valore da verificare); il worker si ferma a `MAIL_LIMITE_GIORNO`. **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 @@ -86,8 +86,8 @@ Lo schema completo, con permessi e note, sta in [DATABASE.md](../DATABASE.md); q funzionale. **`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). +chiave esterna con `ON DELETE CASCADE`: cancellare la notifica, per esempio con lo swipe, toglie +anche la mail non ancora partita). | Colonna | Note | | -------------------- | ----------------------------------------------------------------------- | @@ -98,16 +98,16 @@ toglie anche la mail non ancora partita). | `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 | -È 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. +È 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`** — 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). +**`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). **Funzioni RPC** — tutte `SECURITY DEFINER` e con `EXECUTE` solo alla service role: @@ -121,14 +121,14 @@ e il giocatore può modificare solo in parte (DD-016). 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` + `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. +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`. La preferenza è valutata **al momento dell'invio**, non dell'accodamento: spegnere l'interruttore @@ -158,88 +158,39 @@ Altre situazioni: messaggi che l'account dedicato potesse mandare per altre vie. **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. +riuscito; `healthcheck.mjs` (eseguito da Docker ogni minuto, con 30 secondi di tolleranza +all'avvio) 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, in testo semplice più una versione HTML minima leggibile da smartphone: oggetto = titolo -della notifica (su una riga sola, massimo 200 caratteri), corpo = corpo della notifica, un -pulsante «Apri CrAPP» verso `/evento/` quando la notifica riguarda un evento e verso la home +della notifica (su una riga sola, massimo 200 caratteri), corpo = corpo della notifica, un pulsante +«Apri CrAPP» verso `/evento/` quando la notifica riguarda un evento e verso la home altrimenti. In fondo una riga che spiega come disattivare le email (Profilo → Opzioni → Email). Nessuna immagine remota, nessun tracciamento delle aperture. Il testo scritto dall'admin viene sempre escapato nell'HTML. Il `Message-ID` è derivato dall'id della notifica. ## Interfaccia -Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore **«Email»** (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. +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 service role sta solo sull'host del worker, in un `.env` con permessi `600`; non passa da + Vercel. - La password per app si può revocare da Google senza toccare l'account. - I log del worker riportano solo id notifica, tipo ed esito: **mai indirizzi né testi**, e degli errori SMTP solo i codici. Il log del container è limitato a 3 file da 5 MB. -- Il canale è **opt-out**: acceso di default, spegnibile dal giocatore. Un interruttore unico, - come per la push (nessuna preferenza per tipo). - -## Messa in funzione - -Da fare una volta, sull'host che deve far girare il worker (serve Docker). - -1. **Account Gmail dedicato.** Creane uno nuovo, attiva la verifica in due passaggi e genera una - password per app (vedi la tabella sopra). -2. **Applica la migration in produzione**, solo dopo aver verificato tutto in locale: - `npx supabase db push`. È additiva (due tabelle, un trigger, tre funzioni): non modifica nulla - di esistente e non invia mail finché il worker non parte. Dal momento in cui è applicata, le - nuove notifiche si accodano: **avvia il worker nella stessa sessione**, altrimenti al primo - avvio partirebbero tutte insieme anche mail ormai vecchie (per esempio un promemoria di giorni - prima). -3. **Configura il worker.** In `mailer/`: `cp .env.example .env`, `chmod 600 .env` e compila - `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `SMTP_USER`, `SMTP_PASS` e `APP_URL`. Le altre - variabili hanno un default: - - | Variabile | Default | Contenuto | - | --------------------------------------- | ------------------------------ | ---------------------------------------------------------------- | - | `SUPABASE_URL` | — | URL del progetto | - | `SUPABASE_SERVICE_ROLE_KEY` | — | chiave service role: salta la RLS, va trattata come una password | - | `SMTP_USER`, `SMTP_PASS` | — | account Gmail dedicato e password per app (vanno insieme) | - | `APP_URL` | — | base dei link nelle mail | - | `MAIL_FROM`, `MAIL_FROM_NAME` | `SMTP_USER`, `CrAPP` | indirizzo e nome del mittente | - | `POLL_SECONDI` | 30 | intervallo di polling | - | `MAIL_LIMITE_GIORNO` | 400 | tetto delle ultime 24 ore | - | `MAIL_LOTTO` | 10 | mail prese a ogni giro | - | `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | `smtp.gmail.com`, 587, `false` | server SMTP (`true` solo per la 465) | - -4. **Avvia:** `docker compose up -d --build` dentro `mailer/`. Il container ha - `restart: unless-stopped`, quindi riparte da solo dopo un riavvio dell'host. -5. **Verifica:** `docker compose logs -f` deve mostrare «SMTP raggiungibile e credenziali - accettate»; poi manda un messaggio a un giocatore dalla dashboard admin e controlla che la - riga in coda passi a `inviata` e la mail arrivi. `docker ps` deve mostrare `healthy`. - -**Aggiornare il worker:** `git pull`, poi `docker compose up -d --build` in `mailer/`. - -**Diagnosi rapida:** `SELECT stato, count(*) FROM notifiche_email_coda GROUP BY stato;` mostra a -colpo d'occhio se qualcosa si accumula in `in_coda` (worker fermo o tetto raggiunto) o in `fallita` -(leggere `errore`). - -## Prova in locale, senza Gmail - -`npx supabase start` avvia anche Mailpit (interfaccia su http://127.0.0.1:54324): le mail non -escono dalla macchina. Il worker si punta al database locale e a Mailpit con un `.env` senza -credenziali SMTP (`SMTP_USER`/`SMTP_PASS` assenti, `MAIL_FROM` e `SMTP_HOST` impostati, porta 1025). -Se il worker gira in un container va collegato alla rete Docker di Supabase -(`supabase_network_`) e usa come host `supabase_kong_:8000` per il -database e `supabase_inbucket_` per l'SMTP. Con uno slot collegato a un account e con -l'email valorizzata, una riga inserita in `notifiche_utente` compare in Mailpit entro un ciclo di -polling. +- Il canale è **opt-out**: acceso di default, spegnibile dal giocatore. Un interruttore unico, come + per la push (nessuna preferenza per tipo). ## Test @@ -255,29 +206,32 @@ polling. 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. +- Nessun test spedisce mail vere: il trasporto SMTP è sostituito da un finto nei test unitari. + L'invio reale a Gmail è stato verificato a mano il 29/09/2026 (vedi Limiti noti). ## 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ò 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`. +- Il canale email dipende da un host 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. +- **Le prime mail possono finire in spam.** Verificato il 29/09/2026: la mail di prova, da un + account nuovo e senza reputazione, è arrivata ma nella cartella spam. Non dipende dalla + configurazione (Gmail accetta e consegna) ma dalla reputazione del mittente. Si migliora + segnando i messaggi «Non è spam» e aggiungendo il mittente ai contatti; chi riceve la prima mail + va avvisato. Se resta un problema serve un mittente con dominio proprio (vedi Evoluzioni). +- 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. +- L'interruttore «Email» compare solo con il codice che lo contiene: finché una versione più vecchia + dell'app è in produzione, le mail partono ma non si possono spegnere dall'app. ## 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, se le mail + restano in spam o si supera il tetto di Gmail. - Riepilogo settimanale unico al posto di una mail per notifica. - 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`).