feat(mail): add SMTP mail-sending module (Gitea #6, GDPR export prep)
Thin nodemailer wrapper reading SMTP config from env vars. Uses a provider-hosted mailbox (Strato, same as the domain host) rather than a self-hosted relay from the NAS's residential/dynamic IP, which would get spam-flagged by most receiving servers regardless of DKIM/SPF. Foundation for the GDPR Art. 15 data-export feature (Plan B: expiring download link + email notification) — no caller wired up yet. Verified end-to-end with a real test send before committing.
This commit is contained in:
1 parent
328e54c68e
commit
86ac5f4fee
4 files changed
+81
No files matched your search
@@ -74,3 +74,13 @@ NEXT_PUBLIC_SENTRY_DSN="https://xxxxxxxxxxxxxxxxxxxx@o0.ingest.sentry.io/0"
|
||||
SENTRY_ORG="your-sentry-org"
|
||||
SENTRY_PROJECT="your-sentry-project"
|
||||
SENTRY_AUTH_TOKEN=""
|
||||
|
||||
# ── SMTP (Team mailbox) ──────────────────────────────────────────
|
||||
# Use a mailbox hosted by an established provider (e.g. your domain host's
|
||||
# mail hosting) — NOT a self-hosted relay from a home/dynamic IP, which gets
|
||||
# spam-flagged by most receiving servers regardless of DKIM/SPF.
|
||||
SMTP_HOST="smtp.example.com"
|
||||
SMTP_PORT="465"
|
||||
SMTP_USER="team@example.com"
|
||||
SMTP_PASSWORD=""
|
||||
MAIL_FROM="team@example.com"
|
||||
Generated
+21
@@ -30,6 +30,7 @@
|
||||
"next": "16.2.7",
|
||||
"next-intl": "^4.13.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^9.0.5",
|
||||
"pg": "^8.21.0",
|
||||
"prisma": "^7.8.0",
|
||||
"react": "19.2.4",
|
||||
@@ -45,6 +46,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -5898,6 +5900,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
@@ -12503,6 +12515,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"next": "16.2.7",
|
||||
"next-intl": "^4.13.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^9.0.5",
|
||||
"pg": "^8.21.0",
|
||||
"prisma": "^7.8.0",
|
||||
"react": "19.2.4",
|
||||
@@ -49,6 +50,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import "server-only";
|
||||
import nodemailer, { type Transporter } from "nodemailer";
|
||||
|
||||
/**
|
||||
* SMTP mail sender — same-provider mailbox as the domain host (Strato),
|
||||
* not self-hosted. A self-hosted mail relay from the NAS's residential/
|
||||
* dynamic IP would be flagged as spam by most receiving servers regardless
|
||||
* of DKIM/SPF, so this deliberately goes through the domain provider's
|
||||
* mail infrastructure instead (see Gitea #6 discussion).
|
||||
*/
|
||||
|
||||
let transporter: Transporter | null = null;
|
||||
|
||||
function getTransporter(): Transporter {
|
||||
if (transporter) return transporter;
|
||||
|
||||
const host = process.env.SMTP_HOST;
|
||||
const port = process.env.SMTP_PORT;
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASSWORD;
|
||||
|
||||
if (!host || !port || !user || !pass) {
|
||||
throw new Error(
|
||||
"SMTP not configured — SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASSWORD must all be set."
|
||||
);
|
||||
}
|
||||
|
||||
transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: Number(port),
|
||||
secure: Number(port) === 465, // 465 = implicit TLS, 587 = STARTTLS
|
||||
auth: { user, pass },
|
||||
});
|
||||
|
||||
return transporter;
|
||||
}
|
||||
|
||||
interface SendMailInput {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
}
|
||||
|
||||
export async function sendMail({ to, subject, text, html }: SendMailInput): Promise<void> {
|
||||
const from = process.env.MAIL_FROM ?? process.env.SMTP_USER;
|
||||
await getTransporter().sendMail({ from, to, subject, text, html });
|
||||
}
|
||||
Reference in new issue
Block a user