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 {
|
function escM(s: string): string {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import { TitleBar } from "@shopify/app-bridge-react";
|
|||||||
|
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import db from "../db.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 {
|
import {
|
||||||
DEFAULT_INTRO,
|
DEFAULT_INTRO,
|
||||||
DEFAULT_NOTE,
|
DEFAULT_NOTE,
|
||||||
@@ -71,6 +72,43 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
const { session } = await authenticate.admin(request);
|
const { session } = await authenticate.admin(request);
|
||||||
const f = await request.formData();
|
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 subject = String(f.get("subject") ?? "").trim();
|
||||||
const intro = String(f.get("intro") ?? "").trim();
|
const intro = String(f.get("intro") ?? "").trim();
|
||||||
const note = String(f.get("note") ?? "").trim();
|
const note = String(f.get("note") ?? "").trim();
|
||||||
@@ -116,7 +154,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
create: { shop: session.shop, ...finalData },
|
create: { shop: session.shop, ...finalData },
|
||||||
update: finalData,
|
update: finalData,
|
||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true, tested: false, error: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
function opFrame(html: string, height = 130) {
|
function opFrame(html: string, height = 130) {
|
||||||
@@ -168,12 +206,13 @@ export default function SettingsPage() {
|
|||||||
const [smtpPass, setSmtpPass] = useState("");
|
const [smtpPass, setSmtpPass] = useState("");
|
||||||
const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure);
|
const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure);
|
||||||
const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom);
|
const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom);
|
||||||
|
const [testTo, setTestTo] = useState(d.notifyEmail);
|
||||||
const [showSaved, setShowSaved] = useState(false);
|
const [showSaved, setShowSaved] = useState(false);
|
||||||
|
|
||||||
const saving = nav.state === "submitting";
|
const saving = nav.state === "submitting";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (actionData?.ok) setShowSaved(true);
|
if (actionData?.ok && !actionData.tested) setShowSaved(true);
|
||||||
}, [actionData]);
|
}, [actionData]);
|
||||||
|
|
||||||
const previewSubject = useMemo(
|
const previewSubject = useMemo(
|
||||||
@@ -213,9 +252,24 @@ export default function SettingsPage() {
|
|||||||
[opTextShipped, opTextUnfulfilled, returnAddress, returnAtCustomerExpense],
|
[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 = () => {
|
const handleSave = () => {
|
||||||
setShowSaved(false);
|
setShowSaved(false);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
fd.set("intent", "save");
|
||||||
fd.set("subject", subject);
|
fd.set("subject", subject);
|
||||||
fd.set("intro", intro);
|
fd.set("intro", intro);
|
||||||
fd.set("note", note);
|
fd.set("note", note);
|
||||||
@@ -275,6 +329,18 @@ export default function SettingsPage() {
|
|||||||
</Banner>
|
</Banner>
|
||||||
) : null}
|
) : 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} />
|
<Tabs tabs={tabs} selected={tab} onSelect={setTab} />
|
||||||
|
|
||||||
{tab === 0 ? (
|
{tab === 0 ? (
|
||||||
@@ -561,7 +627,38 @@ export default function SettingsPage() {
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder="Il tuo negozio <no-reply@tuodominio.it>"
|
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>
|
</BlockStack>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ import {
|
|||||||
} from "../lib/recesso.server";
|
} from "../lib/recesso.server";
|
||||||
import type { MatchedOrder } 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
|
// A6: verifica finestra + esclusioni (rispetta i toggle nei Settings). Ritorna
|
||||||
// il messaggio d'errore se il recesso va bloccato, altrimenti null.
|
// il messaggio d'errore se il recesso va bloccato, altrimenti null.
|
||||||
async function checkCompliance(
|
async function checkCompliance(
|
||||||
@@ -431,8 +436,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
data: {
|
data: {
|
||||||
shop,
|
shop,
|
||||||
event: "receipt_failed",
|
event: "receipt_failed",
|
||||||
// no PII in audit: l'errore SMTP puo' contenere l'email.
|
// errore SMTP con email mascherate (diagnosticabile, senza PII).
|
||||||
detail: "invio ricevuta fallito",
|
detail: redactErr(receipt.error),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Retry in-request nel mailer (trySend) + messaggio di successo onesto
|
// Retry in-request nel mailer (trySend) + messaggio di successo onesto
|
||||||
@@ -561,8 +566,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
data: {
|
data: {
|
||||||
shop,
|
shop,
|
||||||
event: notif.ok ? "merchant_notified" : "merchant_notify_failed",
|
event: notif.ok ? "merchant_notified" : "merchant_notify_failed",
|
||||||
// no PII in audit: l'errore SMTP puo' contenere l'email.
|
// errore SMTP con email mascherate (diagnosticabile, senza PII).
|
||||||
detail: notif.ok ? match.orderName : "notifica merchant fallita",
|
detail: notif.ok ? match.orderName : redactErr(notif.error),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user