From f5d2c8664f2be49f89d87c3231c3e8be4ebde2d6 Mon Sep 17 00:00:00 2001 From: tommaso Date: Fri, 10 Jul 2026 09:47:52 +0200 Subject: [PATCH] Impostazioni: invio email di prova + diagnostica SMTP - sendTestEmail(): transport.verify() prima dell'invio (errori auth/connessione espliciti), 1 solo tentativo, ritorna l'errore SMTP grezzo. - Tab 'Email (SMTP)': campo destinatario + bottone 'Invia email di prova' (usa i valori del form anche se non salvati; password digitata oppure quella salvata decifrata). Banner con l'errore esatto. - Guardia: se Host SMTP e' impostato ma il Mittente (From) e' vuoto, l'invio fallisce con messaggio chiaro (i provider rifiutano mittenti non verificati) + banner di avviso nel tab. - Audit: receipt_failed / merchant_notify_failed ora salvano l'errore SMTP con le email mascherate (diagnosticabile, senza PII) invece di una stringa generica. --- app/app/lib/mailer.server.ts | 57 +++++++++++++++++ app/app/routes/app.settings.tsx | 105 ++++++++++++++++++++++++++++++-- app/app/routes/proxy.tsx | 13 ++-- 3 files changed, 167 insertions(+), 8 deletions(-) diff --git a/app/app/lib/mailer.server.ts b/app/app/lib/mailer.server.ts index cb34b3d..55533bd 100644 --- a/app/app/lib/mailer.server.ts +++ b/app/app/lib/mailer.server.ts @@ -145,6 +145,63 @@ export async function sendWithdrawalReceipt(params: { } } +/** + * Invio di prova dalle Impostazioni. Fa prima `verify()` (errori di connessione/ + * autenticazione molto piu' chiari), poi un solo tentativo di invio. + * Ritorna l'errore SMTP GREZZO: serve a diagnosticare. + */ +export async function sendTestEmail(params: { + smtp?: SmtpConfig | null; + to: string; +}): Promise { + const transport = buildTransport(params.smtp); + if (!transport) { + return { + ok: false, + error: + "SMTP non configurato: compila 'Host SMTP' (oppure imposta il provider di default dell'app).", + }; + } + + const from = mailFrom(params.smtp); + if (/no-reply@localhost/.test(from)) { + return { + ok: false, + error: + "Mittente non impostato: compila 'Mittente (From)'. La maggior parte dei provider (Brevo incluso) rifiuta un mittente non verificato.", + }; + } + + try { + await transport.verify(); + } catch (e) { + return { + ok: false, + error: `Connessione/autenticazione SMTP fallita: ${e instanceof Error ? e.message : String(e)}`, + }; + } + + try { + const info = await trySend( + transport, + { + from, + to: params.to, + subject: "Email di prova - App Recesso", + text: "Se leggi questo messaggio, la configurazione SMTP funziona.", + html: "

Se leggi questo messaggio, la configurazione SMTP funziona.

", + }, + 1, + ); + return { ok: true, messageId: info.messageId }; + } catch (e) { + return { + ok: false, + error: e instanceof Error ? e.message : "invio di prova fallito", + }; + } +} + function escM(s: string): string { return String(s) .replace(/&/g, "&") diff --git a/app/app/routes/app.settings.tsx b/app/app/routes/app.settings.tsx index a90b67b..38e96c4 100644 --- a/app/app/routes/app.settings.tsx +++ b/app/app/routes/app.settings.tsx @@ -25,7 +25,8 @@ import { TitleBar } from "@shopify/app-bridge-react"; import { authenticate } from "../shopify.server"; import db from "../db.server"; -import { encryptSecret } from "../lib/crypto.server"; +import { decryptSecret, encryptSecret } from "../lib/crypto.server"; +import { sendTestEmail } from "../lib/mailer.server"; import { DEFAULT_INTRO, DEFAULT_NOTE, @@ -71,6 +72,43 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { export const action = async ({ request }: ActionFunctionArgs) => { const { session } = await authenticate.admin(request); const f = await request.formData(); + + // Invio di prova: NON salva, usa i valori correnti del form (password digitata + // oppure quella gia' salvata, decifrata). Ritorna l'errore SMTP grezzo. + if (String(f.get("intent") ?? "save") === "test") { + const to = String(f.get("testTo") ?? "").trim(); + if (!to) { + return { + ok: false, + tested: true, + error: "Inserisci un destinatario per la prova.", + }; + } + const host = String(f.get("smtpHost") ?? "").trim(); + let pass: string | null = String(f.get("smtpPass") ?? "").trim() || null; + if (!pass) { + const saved = await db.settings.findUnique({ + where: { shop: session.shop }, + }); + pass = saved?.smtpPass ? decryptSecret(saved.smtpPass) : null; + } + const smtp = host + ? { + host, + port: + Number(f.get("smtpPort")) > 0 + ? Math.trunc(Number(f.get("smtpPort"))) + : null, + user: String(f.get("smtpUser") ?? "").trim() || null, + pass, + secure: f.get("smtpSecure") === "true", + from: String(f.get("smtpFrom") ?? "").trim() || null, + } + : null; + const r = await sendTestEmail({ smtp, to }); + return { ok: r.ok, tested: true, error: r.ok ? null : r.error }; + } + const subject = String(f.get("subject") ?? "").trim(); const intro = String(f.get("intro") ?? "").trim(); const note = String(f.get("note") ?? "").trim(); @@ -116,7 +154,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { create: { shop: session.shop, ...finalData }, update: finalData, }); - return { ok: true }; + return { ok: true, tested: false, error: null }; }; function opFrame(html: string, height = 130) { @@ -168,12 +206,13 @@ export default function SettingsPage() { const [smtpPass, setSmtpPass] = useState(""); const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure); const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom); + const [testTo, setTestTo] = useState(d.notifyEmail); const [showSaved, setShowSaved] = useState(false); const saving = nav.state === "submitting"; useEffect(() => { - if (actionData?.ok) setShowSaved(true); + if (actionData?.ok && !actionData.tested) setShowSaved(true); }, [actionData]); const previewSubject = useMemo( @@ -213,9 +252,24 @@ export default function SettingsPage() { [opTextShipped, opTextUnfulfilled, returnAddress, returnAtCustomerExpense], ); + const handleTest = () => { + setShowSaved(false); + const fd = new FormData(); + fd.set("intent", "test"); + fd.set("testTo", testTo); + fd.set("smtpHost", smtpHost); + fd.set("smtpPort", smtpPort); + fd.set("smtpUser", smtpUser); + fd.set("smtpPass", smtpPass); + fd.set("smtpSecure", String(smtpSecure)); + fd.set("smtpFrom", smtpFrom); + submit(fd, { method: "post" }); + }; + const handleSave = () => { setShowSaved(false); const fd = new FormData(); + fd.set("intent", "save"); fd.set("subject", subject); fd.set("intro", intro); fd.set("note", note); @@ -275,6 +329,18 @@ export default function SettingsPage() { ) : null} + {actionData?.tested ? ( + actionData.ok ? ( + + Email di prova inviata a {testTo}. Controlla anche lo spam. + + ) : ( + + {actionData.error} + + ) + ) : null} + {tab === 0 ? ( @@ -561,7 +627,38 @@ export default function SettingsPage() { autoComplete="off" placeholder="Il tuo negozio " /> - {saveBtn} + + {smtpHost && !smtpFrom ? ( + + Host SMTP impostato ma mittente vuoto. Brevo (come quasi + tutti i provider) rifiuta un mittente non verificato: + compila "Mittente (From)" con un indirizzo verificato nel + tuo account. + + ) : null} + + + + + + + ) : null} diff --git a/app/app/routes/proxy.tsx b/app/app/routes/proxy.tsx index 30d0027..b03276f 100644 --- a/app/app/routes/proxy.tsx +++ b/app/app/routes/proxy.tsx @@ -54,6 +54,11 @@ import { } from "../lib/recesso.server"; import type { MatchedOrder } from "../lib/recesso.server"; +/** Errore diagnosticabile ma senza PII: maschera gli indirizzi email. */ +function redactErr(msg: string): string { + return msg.replace(/[\w.+-]+@[\w.-]+\.\w+/g, "[email]").slice(0, 180); +} + // A6: verifica finestra + esclusioni (rispetta i toggle nei Settings). Ritorna // il messaggio d'errore se il recesso va bloccato, altrimenti null. async function checkCompliance( @@ -431,8 +436,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { data: { shop, event: "receipt_failed", - // no PII in audit: l'errore SMTP puo' contenere l'email. - detail: "invio ricevuta fallito", + // errore SMTP con email mascherate (diagnosticabile, senza PII). + detail: redactErr(receipt.error), }, }); // Retry in-request nel mailer (trySend) + messaggio di successo onesto @@ -561,8 +566,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { data: { shop, event: notif.ok ? "merchant_notified" : "merchant_notify_failed", - // no PII in audit: l'errore SMTP puo' contenere l'email. - detail: notif.ok ? match.orderName : "notifica merchant fallita", + // errore SMTP con email mascherate (diagnosticabile, senza PII). + detail: notif.ok ? match.orderName : redactErr(notif.error), }, }); } catch (e) {