Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
187 lines
6.8 KiB
TypeScript
187 lines
6.8 KiB
TypeScript
/**
|
|
* Invio ricevuta di recesso su supporto durevole (A4 - Art. 54-bis).
|
|
*
|
|
* Il corpo/oggetto sono TEMPLATE editabili dal merchant (HTML + segnaposto), con
|
|
* default in ./emailTemplate. Qui sostituiamo i segnaposto coi valori reali
|
|
* (dinamici da Shopify) e inviamo. La validazione compliance (segnaposto
|
|
* obbligatori) avviene al salvataggio lato admin.
|
|
*
|
|
* Transport via env: SMTP_HOST, SMTP_PORT(=587), SMTP_USER?, SMTP_PASS?,
|
|
* SMTP_SECURE("true"/"false"), MAIL_FROM. DEV: Mailpit localhost:1025.
|
|
*/
|
|
|
|
import nodemailer from "nodemailer";
|
|
import {
|
|
renderReceiptHtml,
|
|
renderSubject,
|
|
type EmailVars,
|
|
type OperationalConfig,
|
|
} from "./emailTemplate";
|
|
|
|
function buildTransport() {
|
|
const host = process.env.SMTP_HOST;
|
|
if (!host) return null;
|
|
const port = Number(process.env.SMTP_PORT ?? 587);
|
|
const user = process.env.SMTP_USER;
|
|
return nodemailer.createTransport({
|
|
host,
|
|
port,
|
|
secure: process.env.SMTP_SECURE === "true",
|
|
auth: user ? { user, pass: process.env.SMTP_PASS ?? "" } : undefined,
|
|
connectionTimeout: 10_000,
|
|
greetingTimeout: 10_000,
|
|
socketTimeout: 10_000,
|
|
});
|
|
}
|
|
|
|
/** Versione testo grezza dell'HTML (fallback per client senza HTML). */
|
|
function htmlToText(html: string): string {
|
|
return html
|
|
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
.replace(/<(?:\/p|\/div|\/tr|\/h1|\/h2|\/li|br\s*\/?)>/gi, "\n")
|
|
.replace(/<[^>]+>/g, "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/·/g, "·")
|
|
.replace(/ /g, " ")
|
|
.replace(/[ \t]+\n/g, "\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
}
|
|
|
|
export type ReceiptResult =
|
|
| { ok: true; messageId: string }
|
|
| { ok: false; error: string };
|
|
|
|
/**
|
|
* Invia la ricevuta. Non lancia mai: cattura e ritorna l'esito.
|
|
* `subjectTemplate`/`bodyTemplate` dai Settings (null = default). `vars` sono i
|
|
* valori reali (shop/ordine/cliente), gia' pronti dal chiamante.
|
|
*/
|
|
export async function sendWithdrawalReceipt(params: {
|
|
to: string;
|
|
vars: EmailVars;
|
|
subject?: string | null;
|
|
intro?: string | null;
|
|
note?: string | null;
|
|
operational?: OperationalConfig | null;
|
|
}): Promise<ReceiptResult> {
|
|
const transport = buildTransport();
|
|
if (!transport) {
|
|
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
|
|
}
|
|
|
|
const subject = renderSubject(params.subject, params.vars);
|
|
const html = renderReceiptHtml(
|
|
params.vars,
|
|
params.intro,
|
|
params.note,
|
|
params.operational,
|
|
);
|
|
const text = htmlToText(html);
|
|
|
|
try {
|
|
const info = await transport.sendMail({
|
|
from: process.env.MAIL_FROM ?? "no-reply@localhost",
|
|
to: params.to,
|
|
subject,
|
|
text,
|
|
html,
|
|
});
|
|
return { ok: true, messageId: info.messageId };
|
|
} catch (e) {
|
|
return {
|
|
ok: false,
|
|
error: e instanceof Error ? e.message : "invio ricevuta fallito",
|
|
};
|
|
}
|
|
}
|
|
|
|
function escM(s: string): string {
|
|
return String(s)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
/**
|
|
* Notifica al merchant di una nuova richiesta di recesso. Non lancia mai.
|
|
* `returnStatus` riflette l'esito dell'integrazione Resi (per suggerire l'azione).
|
|
*/
|
|
export async function sendMerchantNotification(params: {
|
|
to: string;
|
|
orderName: string;
|
|
customerName: string;
|
|
customerEmail: string;
|
|
orderUrl: string;
|
|
transmittedAt: string;
|
|
returnStatus: "created" | "no_returnable" | "error";
|
|
}): Promise<ReceiptResult> {
|
|
const transport = buildTransport();
|
|
if (!transport) {
|
|
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
|
|
}
|
|
|
|
const actionLine =
|
|
params.returnStatus === "created"
|
|
? "E' stato creato un reso nell'ordine: gestiscilo dalla pagina dell'ordine."
|
|
: params.returnStatus === "no_returnable"
|
|
? "L'ordine non risulta evaso: valuta annullamento o rimborso."
|
|
: "Reso non creato automaticamente: verifica manualmente l'ordine.";
|
|
|
|
const orderBtn = /^https?:\/\//i.test(params.orderUrl)
|
|
? `<p style="margin:16px 0 0;"><a href="${escM(params.orderUrl)}" style="display:inline-block;padding:10px 18px;background:#1a1a1a;color:#fff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">Apri l'ordine</a></p>`
|
|
: "";
|
|
|
|
const subject = `Nuovo recesso - Ordine ${params.orderName}`;
|
|
const html = `<!doctype html><html lang="it"><head><meta charset="utf-8"></head>
|
|
<body style="margin:0;padding:24px;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center">
|
|
<table role="presentation" width="520" cellpadding="0" cellspacing="0" style="width:100%;max-width:520px;background:#fff;border:1px solid #e5e5e5;border-radius:12px;">
|
|
<tr><td style="padding:24px 28px;">
|
|
<h1 style="margin:0 0 12px;font-size:19px;">Nuova richiesta di recesso</h1>
|
|
<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#3a3a3a;">Un cliente ha esercitato il diritto di recesso.</p>
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f6f7;border-radius:8px;"><tr><td style="padding:14px 16px;font-size:14px;line-height:1.9;color:#444;">
|
|
<div><span style="color:#777;">Ordine:</span> <strong>${escM(params.orderName)}</strong></div>
|
|
<div><span style="color:#777;">Cliente:</span> <strong>${escM(params.customerName)}</strong></div>
|
|
<div><span style="color:#777;">Email:</span> ${escM(params.customerEmail)}</div>
|
|
<div><span style="color:#777;">Trasmesso:</span> ${escM(params.transmittedAt)}</div>
|
|
</td></tr></table>
|
|
<p style="margin:16px 0 0;font-size:14px;line-height:1.6;color:#3a3a3a;">${actionLine}</p>
|
|
<p style="margin:10px 0 0;font-size:12.5px;line-height:1.6;color:#8a8a8a;">Promemoria: disponi il rimborso entro 14 giorni dalla richiesta (art. 56 Cod. Consumo). Puoi trattenerlo fino alla riconsegna della merce o alla prova di spedizione da parte del cliente.</p>
|
|
${orderBtn}
|
|
</td></tr></table>
|
|
</td></tr></table>
|
|
</body></html>`;
|
|
const text = [
|
|
"Nuova richiesta di recesso",
|
|
`Ordine: ${params.orderName}`,
|
|
`Cliente: ${params.customerName} (${params.customerEmail})`,
|
|
`Trasmesso: ${params.transmittedAt}`,
|
|
actionLine,
|
|
"Promemoria: rimborso entro 14 giorni dalla richiesta (art. 56); puoi trattenere fino alla riconsegna della merce o alla prova di spedizione.",
|
|
/^https?:\/\//i.test(params.orderUrl) ? params.orderUrl : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
try {
|
|
const info = await transport.sendMail({
|
|
from: process.env.MAIL_FROM ?? "no-reply@localhost",
|
|
to: params.to,
|
|
subject,
|
|
text,
|
|
html,
|
|
});
|
|
return { ok: true, messageId: info.messageId };
|
|
} catch (e) {
|
|
return {
|
|
ok: false,
|
|
error: e instanceof Error ? e.message : "invio notifica fallito",
|
|
};
|
|
}
|
|
}
|