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:
admin committed 2026-08-11 11:42:05 +02:00
1 parent 328e54c68e
commit 86ac5f4fee
4 files changed
+81

No files matched your search

+10
View File
@@ -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"
+21
View File
@@ -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",
+2
View File
@@ -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",
+48
View File
@@ -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 });
}