m23: un registro (promemoria_eventi_generati) ricorda i promemoria a 24 e 3 ore già generati, così eliminare la notifica non li fa rigenerare dal cron né riaccodare la mail a ogni giro. m24: la push dei promemoria, che nascono nel database, la manda il worker mailer/ con lo stesso modulo dell'app (coda notifiche_push_coda, esiti, backoff, iscrizioni scadute eliminate, push scartate oltre 3 ore). Il sondaggio pre-partita scrive anche notifiche_utente (tipo sondaggio_cacche): compare in-app e arriva per email. L'immagine del worker si costruisce dalla radice del repository per includere src/lib/webpush.server.ts. Test: 9 di integrazione per m23, 17 per la coda push, 6 per la route del sondaggio, nuovi casi unitari sul worker; prova end-to-end con un servizio push finto. Documentazione e catalogo delle notifiche aggiornati. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
482 lines
17 KiB
TypeScript
482 lines
17 KiB
TypeScript
/**
|
|
* 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;
|
|
/** Chiavi VAPID per la push dei promemoria (M24); `null` = solo email. */
|
|
vapid: { publicKey: string; privateKey: string; subject: string | null } | null;
|
|
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 vapidPubblica = testo("VAPID_PUBLIC_KEY");
|
|
const vapidPrivata = testo("VAPID_PRIVATE_KEY");
|
|
if (Boolean(vapidPubblica) !== Boolean(vapidPrivata)) {
|
|
errori.push("VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY vanno impostate insieme");
|
|
}
|
|
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,
|
|
vapid:
|
|
vapidPubblica && vapidPrivata
|
|
? {
|
|
publicKey: vapidPubblica,
|
|
privateKey: vapidPrivata,
|
|
subject: testo("VAPID_SUBJECT") || null,
|
|
}
|
|
: null,
|
|
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;
|
|
}
|
|
|
|
// --- Push dei promemoria (M24) ----------------------------------------------------------------
|
|
|
|
/** Un dispositivo iscritto alla push (riga di `push_subscriptions`). */
|
|
export type IscrizionePush = { endpoint: string; p256dh: string; auth: string };
|
|
|
|
/** Riga restituita da `prendi_push_promemoria()`. */
|
|
export type PushDaInviare = {
|
|
id_notifica: string;
|
|
oggetto: string;
|
|
testo: string;
|
|
tentativi_fatti: number;
|
|
iscrizioni: IscrizionePush[];
|
|
};
|
|
|
|
export type EsitoPush = "inviata" | "riprova" | "fallita" | "saltata";
|
|
|
|
export type EsitoStatoPush = "ok" | "scaduta" | "temporaneo" | "permanente";
|
|
|
|
/**
|
|
* Cosa vuol dire la risposta del servizio push per quel dispositivo: 2xx accettata; 404 e 410
|
|
* iscrizione scaduta (va eliminata); 429 e 5xx errore temporaneo; ogni altro 4xx (400, 401, 403,
|
|
* 413) rifiuto permanente, tipicamente chiavi VAPID sbagliate o payload non valido.
|
|
*/
|
|
export function classificaStatoPush(stato: number): EsitoStatoPush {
|
|
if (stato >= 200 && stato < 300) return "ok";
|
|
if (stato === 404 || stato === 410) return "scaduta";
|
|
if (stato === 429 || stato >= 500) return "temporaneo";
|
|
return "permanente";
|
|
}
|
|
|
|
export type DipendenzePush = {
|
|
prendi: (max: number) => Promise<PushDaInviare[]>;
|
|
segna: (id: string, esito: EsitoPush, errore?: string, prossimo?: Date) => Promise<void>;
|
|
invia: (iscrizione: IscrizionePush, titolo: string, testo: string) => Promise<{ stato: number }>;
|
|
elimina: (endpoint: string) => Promise<void>;
|
|
adesso: () => Date;
|
|
log: (messaggio: string) => void;
|
|
};
|
|
|
|
export type RisultatoCicloPush = {
|
|
inviate: number;
|
|
riprovate: number;
|
|
fallite: number;
|
|
saltate: number;
|
|
iscrizioniRimosse: number;
|
|
};
|
|
|
|
/**
|
|
* Un giro delle push dei promemoria: prende un lotto e manda ogni notifica a tutti i dispositivi
|
|
* del giocatore. L'esito è uno solo per notifica: `inviata` se almeno un dispositivo ha accettato;
|
|
* altrimenti `riprova` (con backoff, poi `fallita`) se qualcuno ha dato un errore temporaneo o è
|
|
* fallita la rete; `fallita` se c'è stato solo un rifiuto permanente; `saltata` se le iscrizioni
|
|
* erano tutte scadute. I log riportano solo id e numeri: mai gli endpoint, che identificano il
|
|
* dispositivo.
|
|
*/
|
|
export async function elaboraCicloPush(
|
|
d: DipendenzePush,
|
|
opzioni: { lotto: number },
|
|
): Promise<RisultatoCicloPush> {
|
|
const risultato: RisultatoCicloPush = {
|
|
inviate: 0,
|
|
riprovate: 0,
|
|
fallite: 0,
|
|
saltate: 0,
|
|
iscrizioniRimosse: 0,
|
|
};
|
|
|
|
for (const push of await d.prendi(opzioni.lotto)) {
|
|
let accettate = 0;
|
|
let temporanei = 0;
|
|
let permanenti = 0;
|
|
const codici: string[] = [];
|
|
|
|
for (const iscrizione of push.iscrizioni) {
|
|
let stato: number | null = null;
|
|
try {
|
|
stato = (await d.invia(iscrizione, push.oggetto, push.testo)).stato;
|
|
} catch {
|
|
temporanei += 1;
|
|
codici.push("rete");
|
|
continue;
|
|
}
|
|
const tipo = classificaStatoPush(stato);
|
|
if (tipo === "ok") accettate += 1;
|
|
else {
|
|
codici.push(String(stato));
|
|
if (tipo === "temporaneo") temporanei += 1;
|
|
else if (tipo === "permanente") permanenti += 1;
|
|
else {
|
|
try {
|
|
await d.elimina(iscrizione.endpoint);
|
|
risultato.iscrizioniRimosse += 1;
|
|
} catch {
|
|
d.log(`iscrizione scaduta non eliminata (notifica ${push.id_notifica})`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const motivo = codici.join(",") || undefined;
|
|
if (accettate > 0) {
|
|
await d.segna(push.id_notifica, "inviata");
|
|
risultato.inviate += 1;
|
|
d.log(
|
|
`push inviata ${push.id_notifica} (${accettate}/${push.iscrizioni.length} dispositivi)`,
|
|
);
|
|
} else if (temporanei > 0) {
|
|
const ritardo = ritardoRiprova(push.tentativi_fatti);
|
|
if (ritardo === null) {
|
|
await d.segna(push.id_notifica, "fallita", motivo);
|
|
risultato.fallite += 1;
|
|
d.log(`push fallita ${push.id_notifica} (tentativi finiti, ${motivo})`);
|
|
} else {
|
|
await d.segna(push.id_notifica, "riprova", motivo, dopoMinuti(d.adesso(), ritardo));
|
|
risultato.riprovate += 1;
|
|
d.log(`push da riprovare ${push.id_notifica} tra ${ritardo} min (${motivo})`);
|
|
}
|
|
} else if (permanenti > 0) {
|
|
await d.segna(push.id_notifica, "fallita", motivo);
|
|
risultato.fallite += 1;
|
|
d.log(`push fallita ${push.id_notifica} (${motivo})`);
|
|
} else {
|
|
await d.segna(push.id_notifica, "saltata", motivo);
|
|
risultato.saltate += 1;
|
|
d.log(`push saltata ${push.id_notifica} (nessun dispositivo utile)`);
|
|
}
|
|
}
|
|
return risultato;
|
|
}
|