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
|
||||
.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,
|
||||
} 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<ReturnType<typeof buildTransport>>;
|
||||
|
||||
/** 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<ReceiptResult> {
|
||||
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<ReceiptResult> {
|
||||
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,
|
||||
|
||||
@@ -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() {
|
||||
</Card>
|
||||
</BlockStack>
|
||||
) : 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>
|
||||
</Layout.Section>
|
||||
</Layout>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user