From 86ac5f4fee01b7a785e0b6a3fc11e0e4e65e91d1 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Tue, 11 Aug 2026 11:42:05 +0200 Subject: [PATCH] feat(mail): add SMTP mail-sending module (Gitea #6, GDPR export prep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 10 ++++++++++ package-lock.json | 21 +++++++++++++++++++++ package.json | 2 ++ src/lib/mail.ts | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 src/lib/mail.ts diff --git a/.env.example b/.env.example index ba42f7e..e006e16 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/package-lock.json b/package-lock.json index b398c93..5733fa9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 148d667..ec67ceb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/mail.ts b/src/lib/mail.ts new file mode 100644 index 0000000..03deddd --- /dev/null +++ b/src/lib/mail.ts @@ -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 { + const from = process.env.MAIL_FROM ?? process.env.SMTP_USER; + await getTransporter().sendMail({ from, to, subject, text, html }); +}