SMTP per-shop configurabile dalle Impostazioni (password cifrata AES-256-GCM)
- Settings: smtpHost/Port/User/Pass(cifrata)/Secure/From. Vuoto -> provider default dell'app (env); compilato -> invio dal SMTP del merchant. - crypto.server: encrypt/decrypt AES-256-GCM (chiave APP_ENCRYPTION_KEY). - mailer: SmtpConfig + buildTransport(smtp) per-shop o env; mailFrom override. Sia ricevuta cliente sia notifica merchant usano lo SMTP del merchant. - proxy: costruisce SmtpConfig (decifra pass), passa ai due invii. - admin: nuovo tab 'Email (SMTP)'; password mai ri-mostrata (vuoto = invariata). - Migrazione smtp_settings. APP_ENCRYPTION_KEY: dev in .env, prod = fly secret. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -27,3 +27,9 @@ app/prisma/*.sqlite*
|
|||||||
|
|
||||||
# OS / editor cruft
|
# OS / editor cruft
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Credenziali locali - MAI committare
|
||||||
|
Cred Fly
|
||||||
|
*[Cc]red*
|
||||||
|
*.secret
|
||||||
|
token.txt
|
||||||
|
|||||||
47
app/app/lib/crypto.server.ts
Normal file
47
app/app/lib/crypto.server.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Cifratura simmetrica per segreti a riposo (es. password SMTP per-shop).
|
||||||
|
* AES-256-GCM. Chiave da env APP_ENCRYPTION_KEY (qualsiasi lunghezza: derivata
|
||||||
|
* a 32 byte via SHA-256). In prod = `fly secrets set APP_ENCRYPTION_KEY=...`.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
createHash,
|
||||||
|
randomBytes,
|
||||||
|
} from "node:crypto";
|
||||||
|
|
||||||
|
const PREFIX = "enc:v1:";
|
||||||
|
|
||||||
|
function key(): Buffer {
|
||||||
|
const raw = process.env.APP_ENCRYPTION_KEY;
|
||||||
|
if (!raw || raw.length < 16) {
|
||||||
|
throw new Error("APP_ENCRYPTION_KEY mancante o troppo corta (>=16 char)");
|
||||||
|
}
|
||||||
|
return createHash("sha256").update(raw).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cifra -> "enc:v1:<iv>:<tag>:<data>" (base64). */
|
||||||
|
export function encryptSecret(plain: string): string {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key(), iv);
|
||||||
|
const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${enc.toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decifra; se non e' nel formato cifrato, ritorna il valore invariato. */
|
||||||
|
export function decryptSecret(value: string): string {
|
||||||
|
if (!value.startsWith(PREFIX)) return value;
|
||||||
|
const parts = value.split(":");
|
||||||
|
if (parts.length !== 5) return "";
|
||||||
|
const iv = Buffer.from(parts[2]!, "base64");
|
||||||
|
const tag = Buffer.from(parts[3]!, "base64");
|
||||||
|
const data = Buffer.from(parts[4]!, "base64");
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key(), iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(data), decipher.final()]).toString(
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SECRET_PREFIX = PREFIX;
|
||||||
@@ -18,22 +18,45 @@ import {
|
|||||||
type OperationalConfig,
|
type OperationalConfig,
|
||||||
} from "./emailTemplate";
|
} from "./emailTemplate";
|
||||||
|
|
||||||
function buildTransport() {
|
/** Config SMTP per-shop (password gia' DECIFRATA). Se host assente -> usa env app. */
|
||||||
const host = process.env.SMTP_HOST;
|
export interface SmtpConfig {
|
||||||
|
host?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
user?: string | null;
|
||||||
|
pass?: string | null;
|
||||||
|
secure?: boolean;
|
||||||
|
from?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTransport(smtp?: SmtpConfig | null) {
|
||||||
|
const useShop = !!(smtp && smtp.host && smtp.host.trim());
|
||||||
|
const host = useShop ? smtp!.host!.trim() : process.env.SMTP_HOST;
|
||||||
if (!host) return null;
|
if (!host) return null;
|
||||||
const port = Number(process.env.SMTP_PORT ?? 587);
|
const port = useShop
|
||||||
const user = process.env.SMTP_USER;
|
? Number(smtp!.port ?? 587)
|
||||||
|
: Number(process.env.SMTP_PORT ?? 587);
|
||||||
|
const secure = useShop ? !!smtp!.secure : process.env.SMTP_SECURE === "true";
|
||||||
|
const user = useShop ? smtp!.user : process.env.SMTP_USER;
|
||||||
|
const pass = useShop ? smtp!.pass : process.env.SMTP_PASS;
|
||||||
return nodemailer.createTransport({
|
return nodemailer.createTransport({
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
secure: process.env.SMTP_SECURE === "true",
|
secure,
|
||||||
auth: user ? { user, pass: process.env.SMTP_PASS ?? "" } : undefined,
|
auth: user ? { user, pass: pass ?? "" } : undefined,
|
||||||
connectionTimeout: 10_000,
|
connectionTimeout: 10_000,
|
||||||
greetingTimeout: 10_000,
|
greetingTimeout: 10_000,
|
||||||
socketTimeout: 10_000,
|
socketTimeout: 10_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mailFrom(smtp?: SmtpConfig | null): string {
|
||||||
|
return (
|
||||||
|
(smtp?.from && smtp.from.trim()) ||
|
||||||
|
process.env.MAIL_FROM ||
|
||||||
|
"no-reply@localhost"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Transport = NonNullable<ReturnType<typeof buildTransport>>;
|
type Transport = NonNullable<ReturnType<typeof buildTransport>>;
|
||||||
|
|
||||||
/** Invio con retry (backoff lineare). Riduce le ricevute perse per glitch SMTP. */
|
/** Invio con retry (backoff lineare). Riduce le ricevute perse per glitch SMTP. */
|
||||||
@@ -89,8 +112,9 @@ export async function sendWithdrawalReceipt(params: {
|
|||||||
intro?: string | null;
|
intro?: string | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
operational?: OperationalConfig | null;
|
operational?: OperationalConfig | null;
|
||||||
|
smtp?: SmtpConfig | null;
|
||||||
}): Promise<ReceiptResult> {
|
}): Promise<ReceiptResult> {
|
||||||
const transport = buildTransport();
|
const transport = buildTransport(params.smtp);
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
|
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
|
||||||
}
|
}
|
||||||
@@ -106,7 +130,7 @@ export async function sendWithdrawalReceipt(params: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await trySend(transport, {
|
const info = await trySend(transport, {
|
||||||
from: process.env.MAIL_FROM ?? "no-reply@localhost",
|
from: mailFrom(params.smtp),
|
||||||
to: params.to,
|
to: params.to,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
@@ -141,8 +165,9 @@ export async function sendMerchantNotification(params: {
|
|||||||
orderUrl: string;
|
orderUrl: string;
|
||||||
transmittedAt: string;
|
transmittedAt: string;
|
||||||
returnStatus: "created" | "no_returnable" | "exists" | "error";
|
returnStatus: "created" | "no_returnable" | "exists" | "error";
|
||||||
|
smtp?: SmtpConfig | null;
|
||||||
}): Promise<ReceiptResult> {
|
}): Promise<ReceiptResult> {
|
||||||
const transport = buildTransport();
|
const transport = buildTransport(params.smtp);
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
|
return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" };
|
||||||
}
|
}
|
||||||
@@ -194,7 +219,7 @@ ${orderBtn}
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await trySend(transport, {
|
const info = await trySend(transport, {
|
||||||
from: process.env.MAIL_FROM ?? "no-reply@localhost",
|
from: mailFrom(params.smtp),
|
||||||
to: params.to,
|
to: params.to,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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 {
|
import {
|
||||||
DEFAULT_INTRO,
|
DEFAULT_INTRO,
|
||||||
DEFAULT_NOTE,
|
DEFAULT_NOTE,
|
||||||
@@ -58,6 +59,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
returnAddress: s?.returnAddress ?? "",
|
returnAddress: s?.returnAddress ?? "",
|
||||||
opTextUnfulfilled: s?.opTextUnfulfilled ?? DEFAULT_OP_UNFULFILLED,
|
opTextUnfulfilled: s?.opTextUnfulfilled ?? DEFAULT_OP_UNFULFILLED,
|
||||||
opTextShipped: s?.opTextShipped ?? DEFAULT_OP_SHIPPED,
|
opTextShipped: s?.opTextShipped ?? DEFAULT_OP_SHIPPED,
|
||||||
|
smtpHost: s?.smtpHost ?? "",
|
||||||
|
smtpPort: s?.smtpPort != null ? String(s.smtpPort) : "",
|
||||||
|
smtpUser: s?.smtpUser ?? "",
|
||||||
|
smtpSecure: s?.smtpSecure ?? false,
|
||||||
|
smtpFrom: s?.smtpFrom ?? "",
|
||||||
|
smtpPassSet: !!s?.smtpPass,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,12 +97,24 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
opTextUnfulfilled:
|
opTextUnfulfilled:
|
||||||
opUnf && opUnf !== DEFAULT_OP_UNFULFILLED ? opUnf : null,
|
opUnf && opUnf !== DEFAULT_OP_UNFULFILLED ? opUnf : null,
|
||||||
opTextShipped: opShip && opShip !== DEFAULT_OP_SHIPPED ? opShip : null,
|
opTextShipped: opShip && opShip !== DEFAULT_OP_SHIPPED ? opShip : null,
|
||||||
|
smtpHost: String(f.get("smtpHost") ?? "").trim() || null,
|
||||||
|
smtpPort:
|
||||||
|
Number(f.get("smtpPort")) > 0 ? Math.trunc(Number(f.get("smtpPort"))) : null,
|
||||||
|
smtpUser: String(f.get("smtpUser") ?? "").trim() || null,
|
||||||
|
smtpSecure: f.get("smtpSecure") === "true",
|
||||||
|
smtpFrom: String(f.get("smtpFrom") ?? "").trim() || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Password SMTP: cifrata solo se fornita; vuota = invariata.
|
||||||
|
const newPass = String(f.get("smtpPass") ?? "").trim();
|
||||||
|
const finalData = newPass
|
||||||
|
? { ...data, smtpPass: encryptSecret(newPass) }
|
||||||
|
: data;
|
||||||
|
|
||||||
await db.settings.upsert({
|
await db.settings.upsert({
|
||||||
where: { shop: session.shop },
|
where: { shop: session.shop },
|
||||||
create: { shop: session.shop, ...data },
|
create: { shop: session.shop, ...finalData },
|
||||||
update: data,
|
update: finalData,
|
||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
};
|
};
|
||||||
@@ -143,6 +162,12 @@ export default function SettingsPage() {
|
|||||||
const [returnAddress, setReturnAddress] = useState(d.returnAddress);
|
const [returnAddress, setReturnAddress] = useState(d.returnAddress);
|
||||||
const [opTextUnfulfilled, setOpTextUnfulfilled] = useState(d.opTextUnfulfilled);
|
const [opTextUnfulfilled, setOpTextUnfulfilled] = useState(d.opTextUnfulfilled);
|
||||||
const [opTextShipped, setOpTextShipped] = useState(d.opTextShipped);
|
const [opTextShipped, setOpTextShipped] = useState(d.opTextShipped);
|
||||||
|
const [smtpHost, setSmtpHost] = useState(d.smtpHost);
|
||||||
|
const [smtpPort, setSmtpPort] = useState(d.smtpPort);
|
||||||
|
const [smtpUser, setSmtpUser] = useState(d.smtpUser);
|
||||||
|
const [smtpPass, setSmtpPass] = useState("");
|
||||||
|
const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure);
|
||||||
|
const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom);
|
||||||
const [showSaved, setShowSaved] = useState(false);
|
const [showSaved, setShowSaved] = useState(false);
|
||||||
|
|
||||||
const saving = nav.state === "submitting";
|
const saving = nav.state === "submitting";
|
||||||
@@ -206,6 +231,12 @@ export default function SettingsPage() {
|
|||||||
fd.set("returnAddress", returnAddress);
|
fd.set("returnAddress", returnAddress);
|
||||||
fd.set("opTextUnfulfilled", opTextUnfulfilled);
|
fd.set("opTextUnfulfilled", opTextUnfulfilled);
|
||||||
fd.set("opTextShipped", opTextShipped);
|
fd.set("opTextShipped", opTextShipped);
|
||||||
|
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" });
|
submit(fd, { method: "post" });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -229,6 +260,7 @@ export default function SettingsPage() {
|
|||||||
{ id: "notifiche", content: "Notifiche" },
|
{ id: "notifiche", content: "Notifiche" },
|
||||||
{ id: "regole", content: "Regole recesso" },
|
{ id: "regole", content: "Regole recesso" },
|
||||||
{ id: "reso", content: "Reso e stato ordine" },
|
{ id: "reso", content: "Reso e stato ordine" },
|
||||||
|
{ id: "smtp", content: "Email (SMTP)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -470,6 +502,69 @@ export default function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</BlockStack>
|
</BlockStack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{tab === 4 ? (
|
||||||
|
<Card>
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<BlockStack gap="100">
|
||||||
|
<Text as="h2" variant="headingMd">
|
||||||
|
Email (SMTP)
|
||||||
|
</Text>
|
||||||
|
<Text as="p" tone="subdued">
|
||||||
|
Vuoto = provider di default dell'app. Compila per inviare dal
|
||||||
|
tuo SMTP (email dal tuo dominio). La password è cifrata a
|
||||||
|
riposo.
|
||||||
|
</Text>
|
||||||
|
</BlockStack>
|
||||||
|
<TextField
|
||||||
|
label="Host SMTP"
|
||||||
|
value={smtpHost}
|
||||||
|
onChange={setSmtpHost}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="smtp-relay.brevo.com"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Porta"
|
||||||
|
type="number"
|
||||||
|
value={smtpPort}
|
||||||
|
onChange={setSmtpPort}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="587"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Utente"
|
||||||
|
value={smtpUser}
|
||||||
|
onChange={setSmtpUser}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={smtpPass}
|
||||||
|
onChange={setSmtpPass}
|
||||||
|
autoComplete="off"
|
||||||
|
helpText={
|
||||||
|
d.smtpPassSet
|
||||||
|
? "Impostata. Lascia vuoto per non cambiarla."
|
||||||
|
: "Non impostata."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Connessione sicura diretta (SSL/TLS, porta 465)"
|
||||||
|
checked={smtpSecure}
|
||||||
|
onChange={setSmtpSecure}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Mittente (From)"
|
||||||
|
value={smtpFrom}
|
||||||
|
onChange={setSmtpFrom}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Il tuo negozio <no-reply@tuodominio.it>"
|
||||||
|
/>
|
||||||
|
{saveBtn}
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
</BlockStack>
|
</BlockStack>
|
||||||
</Layout.Section>
|
</Layout.Section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import db from "../db.server";
|
|||||||
import {
|
import {
|
||||||
sendMerchantNotification,
|
sendMerchantNotification,
|
||||||
sendWithdrawalReceipt,
|
sendWithdrawalReceipt,
|
||||||
|
type SmtpConfig,
|
||||||
} from "../lib/mailer.server";
|
} from "../lib/mailer.server";
|
||||||
|
import { decryptSecret } from "../lib/crypto.server";
|
||||||
import {
|
import {
|
||||||
ERROR,
|
ERROR,
|
||||||
EXCLUSION_REASON,
|
EXCLUSION_REASON,
|
||||||
@@ -381,6 +383,22 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
atCustomerExpense: settings?.returnAtCustomerExpense ?? true,
|
atCustomerExpense: settings?.returnAtCustomerExpense ?? true,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
// SMTP per-shop (se configurato): password decifrata; altrimenti default app.
|
||||||
|
let smtp: SmtpConfig | null = null;
|
||||||
|
if (settings?.smtpHost) {
|
||||||
|
try {
|
||||||
|
smtp = {
|
||||||
|
host: settings.smtpHost,
|
||||||
|
port: settings.smtpPort,
|
||||||
|
user: settings.smtpUser,
|
||||||
|
pass: settings.smtpPass ? decryptSecret(settings.smtpPass) : null,
|
||||||
|
secure: settings.smtpSecure,
|
||||||
|
from: settings.smtpFrom,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
console.error("[recesso] SMTP shop non decifrabile: uso default app");
|
||||||
|
}
|
||||||
|
}
|
||||||
const receipt = await sendWithdrawalReceipt({
|
const receipt = await sendWithdrawalReceipt({
|
||||||
to: email,
|
to: email,
|
||||||
vars: {
|
vars: {
|
||||||
@@ -396,6 +414,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
intro: settings?.emailIntro,
|
intro: settings?.emailIntro,
|
||||||
note: settings?.emailNote,
|
note: settings?.emailNote,
|
||||||
operational,
|
operational,
|
||||||
|
smtp,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (receipt.ok) {
|
if (receipt.ok) {
|
||||||
@@ -533,6 +552,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
orderUrl: match.orderUrl,
|
orderUrl: match.orderUrl,
|
||||||
transmittedAt: transmittedLabel,
|
transmittedAt: transmittedLabel,
|
||||||
returnStatus,
|
returnStatus,
|
||||||
|
smtp,
|
||||||
});
|
});
|
||||||
if (!notif.ok) {
|
if (!notif.ok) {
|
||||||
console.error("[recesso] notifica merchant fallita:", notif.error);
|
console.error("[recesso] notifica merchant fallita:", notif.error);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Settings" ADD COLUMN "smtpFrom" TEXT,
|
||||||
|
ADD COLUMN "smtpHost" TEXT,
|
||||||
|
ADD COLUMN "smtpPass" TEXT,
|
||||||
|
ADD COLUMN "smtpPort" INTEGER,
|
||||||
|
ADD COLUMN "smtpSecure" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "smtpUser" TEXT;
|
||||||
@@ -63,6 +63,12 @@ model Settings {
|
|||||||
returnInstructions String? // deprecato: sostituito da opTextShipped
|
returnInstructions String? // deprecato: sostituito da opTextShipped
|
||||||
opTextUnfulfilled String?
|
opTextUnfulfilled String?
|
||||||
opTextShipped String?
|
opTextShipped String?
|
||||||
|
smtpHost String?
|
||||||
|
smtpPort Int?
|
||||||
|
smtpUser String?
|
||||||
|
smtpPass String? // cifrato AES-256-GCM (mai in chiaro)
|
||||||
|
smtpSecure Boolean @default(false)
|
||||||
|
smtpFrom String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user