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:
2026-07-07 17:28:46 +02:00
parent ad9f76b6be
commit 3bb7a30c1d
7 changed files with 218 additions and 12 deletions

6
.gitignore vendored
View File

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

View 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;

View File

@@ -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,

View File

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

View File

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

View File

@@ -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;

View File

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