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>
96 lines
3.2 KiB
JavaScript
96 lines
3.2 KiB
JavaScript
// 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");
|