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:
@@ -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, "&")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user