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.
This commit is contained in:
2026-07-10 09:47:52 +02:00
parent c423a8d9a7
commit f5d2c8664f
3 changed files with 167 additions and 8 deletions

View File

@@ -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<ReceiptResult> {
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: "<p>Se leggi questo messaggio, la configurazione SMTP funziona.</p>",
},
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, "&amp;")

View File

@@ -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() {
</Banner>
) : null}
{actionData?.tested ? (
actionData.ok ? (
<Banner tone="success">
Email di prova inviata a {testTo}. Controlla anche lo spam.
</Banner>
) : (
<Banner tone="critical" title="Invio di prova fallito">
<Text as="p">{actionData.error}</Text>
</Banner>
)
) : null}
<Tabs tabs={tabs} selected={tab} onSelect={setTab} />
{tab === 0 ? (
@@ -561,7 +627,38 @@ export default function SettingsPage() {
autoComplete="off"
placeholder="Il tuo negozio <no-reply@tuodominio.it>"
/>
{saveBtn}
{smtpHost && !smtpFrom ? (
<Banner tone="warning">
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.
</Banner>
) : null}
<TextField
label="Destinatario dell'email di prova"
type="email"
value={testTo}
onChange={setTestTo}
autoComplete="off"
placeholder="tu@tuodominio.it"
helpText="La prova usa i valori qui sopra, anche se non ancora salvati."
/>
<ButtonGroup>
<Button
variant="primary"
loading={saving}
onClick={handleSave}
>
Salva
</Button>
<Button loading={saving} onClick={handleTest}>
Invia email di prova
</Button>
</ButtonGroup>
</BlockStack>
</Card>
) : null}

View File

@@ -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) {