From 3bb7a30c1d63dd6e1a9bc0e4b7c8acd5f33fe614 Mon Sep 17 00:00:00 2001 From: tommaso Date: Tue, 7 Jul 2026 17:28:46 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1 --- .gitignore | 6 ++ app/app/lib/crypto.server.ts | 47 +++++++++ app/app/lib/mailer.server.ts | 45 +++++++-- app/app/routes/app.settings.tsx | 99 ++++++++++++++++++- app/app/routes/proxy.tsx | 20 ++++ .../migration.sql | 7 ++ app/prisma/schema.prisma | 6 ++ 7 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 app/app/lib/crypto.server.ts create mode 100644 app/prisma/migrations/20260707152343_smtp_settings/migration.sql diff --git a/.gitignore b/.gitignore index 2a41c2b..cde1bbe 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,9 @@ app/prisma/*.sqlite* # OS / editor cruft .DS_Store + +# Credenziali locali - MAI committare +Cred Fly +*[Cc]red* +*.secret +token.txt diff --git a/app/app/lib/crypto.server.ts b/app/app/lib/crypto.server.ts new file mode 100644 index 0000000..ee18ee7 --- /dev/null +++ b/app/app/lib/crypto.server.ts @@ -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:::" (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; diff --git a/app/app/lib/mailer.server.ts b/app/app/lib/mailer.server.ts index 0260f5d..cb34b3d 100644 --- a/app/app/lib/mailer.server.ts +++ b/app/app/lib/mailer.server.ts @@ -18,22 +18,45 @@ import { type OperationalConfig, } from "./emailTemplate"; -function buildTransport() { - const host = process.env.SMTP_HOST; +/** Config SMTP per-shop (password gia' DECIFRATA). Se host assente -> usa env app. */ +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; - const port = Number(process.env.SMTP_PORT ?? 587); - const user = process.env.SMTP_USER; + const port = useShop + ? 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({ host, port, - secure: process.env.SMTP_SECURE === "true", - auth: user ? { user, pass: process.env.SMTP_PASS ?? "" } : undefined, + secure, + auth: user ? { user, pass: pass ?? "" } : undefined, connectionTimeout: 10_000, greetingTimeout: 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>; /** Invio con retry (backoff lineare). Riduce le ricevute perse per glitch SMTP. */ @@ -89,8 +112,9 @@ export async function sendWithdrawalReceipt(params: { intro?: string | null; note?: string | null; operational?: OperationalConfig | null; + smtp?: SmtpConfig | null; }): Promise { - const transport = buildTransport(); + const transport = buildTransport(params.smtp); if (!transport) { return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" }; } @@ -106,7 +130,7 @@ export async function sendWithdrawalReceipt(params: { try { const info = await trySend(transport, { - from: process.env.MAIL_FROM ?? "no-reply@localhost", + from: mailFrom(params.smtp), to: params.to, subject, text, @@ -141,8 +165,9 @@ export async function sendMerchantNotification(params: { orderUrl: string; transmittedAt: string; returnStatus: "created" | "no_returnable" | "exists" | "error"; + smtp?: SmtpConfig | null; }): Promise { - const transport = buildTransport(); + const transport = buildTransport(params.smtp); if (!transport) { return { ok: false, error: "SMTP non configurato (SMTP_HOST mancante)" }; } @@ -194,7 +219,7 @@ ${orderBtn} try { const info = await trySend(transport, { - from: process.env.MAIL_FROM ?? "no-reply@localhost", + from: mailFrom(params.smtp), to: params.to, subject, text, diff --git a/app/app/routes/app.settings.tsx b/app/app/routes/app.settings.tsx index 2fb37df..a90b67b 100644 --- a/app/app/routes/app.settings.tsx +++ b/app/app/routes/app.settings.tsx @@ -25,6 +25,7 @@ import { TitleBar } from "@shopify/app-bridge-react"; import { authenticate } from "../shopify.server"; import db from "../db.server"; +import { encryptSecret } from "../lib/crypto.server"; import { DEFAULT_INTRO, DEFAULT_NOTE, @@ -58,6 +59,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { returnAddress: s?.returnAddress ?? "", opTextUnfulfilled: s?.opTextUnfulfilled ?? DEFAULT_OP_UNFULFILLED, 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: opUnf && opUnf !== DEFAULT_OP_UNFULFILLED ? opUnf : 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({ where: { shop: session.shop }, - create: { shop: session.shop, ...data }, - update: data, + create: { shop: session.shop, ...finalData }, + update: finalData, }); return { ok: true }; }; @@ -143,6 +162,12 @@ export default function SettingsPage() { const [returnAddress, setReturnAddress] = useState(d.returnAddress); const [opTextUnfulfilled, setOpTextUnfulfilled] = useState(d.opTextUnfulfilled); 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 saving = nav.state === "submitting"; @@ -206,6 +231,12 @@ export default function SettingsPage() { fd.set("returnAddress", returnAddress); fd.set("opTextUnfulfilled", opTextUnfulfilled); 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" }); }; @@ -229,6 +260,7 @@ export default function SettingsPage() { { id: "notifiche", content: "Notifiche" }, { id: "regole", content: "Regole recesso" }, { id: "reso", content: "Reso e stato ordine" }, + { id: "smtp", content: "Email (SMTP)" }, ]; return ( @@ -470,6 +502,69 @@ export default function SettingsPage() { ) : null} + + {tab === 4 ? ( + + + + + Email (SMTP) + + + Vuoto = provider di default dell'app. Compila per inviare dal + tuo SMTP (email dal tuo dominio). La password รจ cifrata a + riposo. + + + + + + + + + {saveBtn} + + + ) : null} diff --git a/app/app/routes/proxy.tsx b/app/app/routes/proxy.tsx index b1ad130..30d0027 100644 --- a/app/app/routes/proxy.tsx +++ b/app/app/routes/proxy.tsx @@ -20,7 +20,9 @@ import db from "../db.server"; import { sendMerchantNotification, sendWithdrawalReceipt, + type SmtpConfig, } from "../lib/mailer.server"; +import { decryptSecret } from "../lib/crypto.server"; import { ERROR, EXCLUSION_REASON, @@ -381,6 +383,22 @@ export const action = async ({ request }: ActionFunctionArgs) => { atCustomerExpense: settings?.returnAtCustomerExpense ?? true, } : 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({ to: email, vars: { @@ -396,6 +414,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { intro: settings?.emailIntro, note: settings?.emailNote, operational, + smtp, }); try { if (receipt.ok) { @@ -533,6 +552,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { orderUrl: match.orderUrl, transmittedAt: transmittedLabel, returnStatus, + smtp, }); if (!notif.ok) { console.error("[recesso] notifica merchant fallita:", notif.error); diff --git a/app/prisma/migrations/20260707152343_smtp_settings/migration.sql b/app/prisma/migrations/20260707152343_smtp_settings/migration.sql new file mode 100644 index 0000000..4a61e9e --- /dev/null +++ b/app/prisma/migrations/20260707152343_smtp_settings/migration.sql @@ -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; diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index fed53c3..21ec71c 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -63,6 +63,12 @@ model Settings { returnInstructions String? // deprecato: sostituito da opTextShipped opTextUnfulfilled 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()) updatedAt DateTime @updatedAt