feat: self-hosted postfix

This commit is contained in:
2026-01-24 08:43:50 +01:00
parent e46cb018fd
commit eeed88eba4
16 changed files with 2830 additions and 652 deletions

View File

@@ -9,6 +9,11 @@ NEXT_PUBLIC_BRAND_COUNTRY=""
NEXT_PUBLIC_BRAND_EMAIL="" NEXT_PUBLIC_BRAND_EMAIL=""
NEXT_PUBLIC_BRAND_NAME="" NEXT_PUBLIC_BRAND_NAME=""
DATABASE_URL="" DATABASE_URL=""
RESEND_FROM=""
RESEND_KEY=""
SECRET_HASH="" SECRET_HASH=""
EMAIL_HOST="postfix"
EMAIL_PORT="25"
EMAIL_FROM=""
MAIL_DOMAIN=""
MAIL_HOSTNAME=""
DKIM_SELECTOR="mail"

View File

@@ -1,7 +1,7 @@
import { NewsletterTemplate } from '@components/email/Newsletter'; import { NewsletterTemplate } from '@components/email/Newsletter';
import prisma from '@prisma/prisma'; import prisma from '@prisma/prisma';
import { formatApiResponse } from '@utils/formatApiResponse'; import { formatApiResponse } from '@utils/formatApiResponse';
import { sender } from '@utils/resendClient'; import { sender } from '@utils/mailer';
import { import {
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,
STATUS_INTERNAL_SERVER_ERROR, STATUS_INTERNAL_SERVER_ERROR,

View File

@@ -1,7 +1,7 @@
import { ConfirmationTemplate } from '@components/email/Confirmation'; import { ConfirmationTemplate } from '@components/email/Confirmation';
import prisma from '@prisma/prisma'; import prisma from '@prisma/prisma';
import { formatApiResponse } from '@utils/formatApiResponse'; import { formatApiResponse } from '@utils/formatApiResponse';
import { sender } from '@utils/resendClient'; import { sender } from '@utils/mailer';
import { import {
BAD_REQUEST, BAD_REQUEST,
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,

View File

@@ -1,7 +1,7 @@
import { UnsubscribeTemplate } from '@components/email/Unsubscribe'; import { UnsubscribeTemplate } from '@components/email/Unsubscribe';
import prisma from '@prisma/prisma'; import prisma from '@prisma/prisma';
import { formatApiResponse } from '@utils/formatApiResponse'; import { formatApiResponse } from '@utils/formatApiResponse';
import { sender } from '@utils/resendClient'; import { sender } from '@utils/mailer';
import { import {
BAD_REQUEST, BAD_REQUEST,
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,

View File

@@ -1,4 +1,3 @@
version: '3.8'
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
@@ -11,6 +10,37 @@ services:
- '5432:5432' - '5432:5432'
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U newsletter"]
interval: 10s
timeout: 5s
retries: 5
# Postfix mail server with OpenDKIM for self-hosted email
postfix:
build: ./docker/postfix
container_name: newsletter-postfix
restart: always
# dns: Uses VPS default DNS. If MX lookups fail, uncomment and set explicit DNS:
# - 213.186.33.99 # OVH DNS
environment:
- MAIL_DOMAIN=${MAIL_DOMAIN:-example.com}
- MAIL_HOSTNAME=${MAIL_HOSTNAME:-mail.example.com}
- DKIM_SELECTOR=${DKIM_SELECTOR:-mail}
volumes:
# Persist DKIM keys across container rebuilds
- postfix-dkim:/etc/opendkim/keys
# Persist mail queue
- postfix-spool:/var/spool/postfix
# Port 25 not exposed to host - only accessible within Docker network
healthcheck:
test: ["CMD-SHELL", "echo 'QUIT' | nc -w 5 localhost 25 | grep -q '220' || exit 1"]
interval: 30s
timeout: 10s
start_period: 10s
retries: 3
volumes: volumes:
postgres-data: postgres-data:
postfix-dkim:
postfix-spool:

30
docker/postfix/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM debian:bookworm-slim
# install oostfix and OpenDKIM
RUN apt-get update && apt-get install -y \
postfix \
opendkim \
opendkim-tools \
mailutils \
ca-certificates \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
# create OpenDKIM paths
RUN mkdir -p /etc/opendkim/keys && \
chown -R opendkim:opendkim /etc/opendkim && \
chmod 700 /etc/opendkim/keys
# copy config
COPY postfix-main.cf /etc/postfix/main.cf
COPY opendkim.conf /etc/opendkim.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 25
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD echo "QUIT" | nc -w 5 localhost 25 | grep -q "220" || exit 1
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,90 @@
#!/bin/bash
set -e
# environment variables
MAIL_DOMAIN="${MAIL_DOMAIN:-example.com}"
MAIL_HOSTNAME="${MAIL_HOSTNAME:-mail.example.com}"
DKIM_SELECTOR="${DKIM_SELECTOR:-mail}"
echo "Setting up postfix for domain: ${MAIL_DOMAIN}"
echo "Hostname: ${MAIL_HOSTNAME}"
# configure postfix domain
postconf -e "myhostname=${MAIL_HOSTNAME}"
postconf -e "mydomain=${MAIL_DOMAIN}"
postconf -e "myorigin=\$mydomain"
postconf -e "mydestination=\$myhostname, localhost.\$mydomain, localhost"
# create OpenDKIM key folder for domain
DKIM_KEY_DIR="/etc/opendkim/keys/${MAIL_DOMAIN}"
mkdir -p "${DKIM_KEY_DIR}"
# generate DKIM keys if they don't exist
if [ ! -f "${DKIM_KEY_DIR}/${DKIM_SELECTOR}.private" ]; then
echo "Generating DKIM keys for ${MAIL_DOMAIN}..."
opendkim-genkey -b 2048 -d "${MAIL_DOMAIN}" -D "${DKIM_KEY_DIR}" -s "${DKIM_SELECTOR}" -v
chown -R opendkim:opendkim "${DKIM_KEY_DIR}"
chmod 600 "${DKIM_KEY_DIR}/${DKIM_SELECTOR}.private"
echo ""
echo "============================================"
echo "DKIM PUBLIC KEY - ADD THIS TO YOUR DNS:"
echo "============================================"
echo "Record Type: TXT"
echo "Name: ${DKIM_SELECTOR}._domainkey.${MAIL_DOMAIN}"
echo ""
cat "${DKIM_KEY_DIR}/${DKIM_SELECTOR}.txt"
echo ""
echo "============================================"
echo ""
else
echo "Using existing DKIM keys"
fi
# configure OpenDKIM KeyTable
cat > /etc/opendkim/KeyTable << EOF
${DKIM_SELECTOR}._domainkey.${MAIL_DOMAIN} ${MAIL_DOMAIN}:${DKIM_SELECTOR}:${DKIM_KEY_DIR}/${DKIM_SELECTOR}.private
EOF
# configure OpenDKIM SigningTable
cat > /etc/opendkim/SigningTable << EOF
*@${MAIL_DOMAIN} ${DKIM_SELECTOR}._domainkey.${MAIL_DOMAIN}
EOF
# configure OpenDKIM TrustedHosts
cat > /etc/opendkim/TrustedHosts << EOF
127.0.0.1
localhost
${MAIL_DOMAIN}
*.${MAIL_DOMAIN}
172.16.0.0/12
192.168.0.0/16
10.0.0.0/8
EOF
# set permissions
chown -R opendkim:opendkim /etc/opendkim
chmod 600 /etc/opendkim/KeyTable
chmod 600 /etc/opendkim/SigningTable
# create postfix spool folders
mkdir -p /var/spool/postfix/pid
chown root:root /var/spool/postfix
chown root:root /var/spool/postfix/pid
# start OpenDKIM in background
echo "Starting OpenDKIM..."
opendkim -f &
# wait for OpenDKIM to start
sleep 2
# copy DNS config to postfix chroot
mkdir -p /var/spool/postfix/etc
cp /etc/resolv.conf /var/spool/postfix/etc/
cp /etc/services /var/spool/postfix/etc/
cp /etc/hosts /var/spool/postfix/etc/
# start postfix in foreground
echo "Starting Postfix..."
postfix start-fg

View File

@@ -0,0 +1,30 @@
# OpenDKIM configuration for email signing
# log to syslog
Syslog yes
SyslogSuccess yes
LogWhy yes
# required for verification
Canonicalization relaxed/simple
# sign mode (s=sign, v=verify)
Mode sv
# sign subdomains
SubDomains no
# key configuration (will be set by entrypoint)
KeyTable /etc/opendkim/KeyTable
SigningTable refile:/etc/opendkim/SigningTable
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
InternalHosts refile:/etc/opendkim/TrustedHosts
# socket for postfix connection
Socket inet:8891@localhost
# user
UserID opendkim
# permissions
RequireSafeKeys false

View File

@@ -0,0 +1,50 @@
# Postfix main configuration for newsletter sending
# Domain and hostname will be set by entrypoint script
smtpd_banner = $myhostname ESMTP
biff = no
append_dot_mydomain = no
readme_directory = no
# TLS parameters (for outbound connections)
smtp_tls_security_level = may
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
# Network settings
inet_interfaces = all
inet_protocols = ipv4
# Relay settings (don't relay for others)
relayhost =
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 172.16.0.0/12 192.168.0.0/16 10.0.0.0/8
# Message size limit (10MB)
message_size_limit = 10240000
# Queue settings
maximal_queue_lifetime = 5d
bounce_queue_lifetime = 5d
# Security settings
smtpd_helo_required = yes
disable_vrfy_command = yes
smtpd_helo_restrictions =
permit_mynetworks,
reject_invalid_helo_hostname,
reject_non_fqdn_helo_hostname,
permit
smtpd_recipient_restrictions =
permit_mynetworks,
reject_unauth_destination,
permit
# OpenDKIM integration
milter_default_action = accept
milter_protocol = 6
smtpd_milters = inet:localhost:8891
non_smtpd_milters = inet:localhost:8891
# Notify on bounces
notify_classes = bounce, delay, resource, software

1794
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,18 +27,18 @@
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@vercel/analytics": "^1.1.1", "@vercel/analytics": "^1.1.1",
"openai": "^4.77.0",
"axios": "^1.12.0", "axios": "^1.12.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"isomorphic-dompurify": "^2.15.0", "isomorphic-dompurify": "^2.15.0",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"next": "^15.5.9", "next": "^15.5.9",
"nodemailer": "^7.0.12",
"openai": "^4.77.0",
"postcss-nesting": "^12.0.2", "postcss-nesting": "^12.0.2",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.48.2", "react-hook-form": "^7.48.2",
"resend": "^3.1.0",
"tailwind-merge": "^2.1.0", "tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8" "zod": "^3.23.8"
@@ -47,6 +47,7 @@
"@commitlint/cli": "^18.4.3", "@commitlint/cli": "^18.4.3",
"@commitlint/config-conventional": "^18.4.3", "@commitlint/config-conventional": "^18.4.3",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^7.0.5",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/eslint-plugin": "^6.12.0",

View File

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "email_logs" (
"id" SERIAL NOT NULL,
"recipient" TEXT NOT NULL,
"subject" TEXT,
"message_id" TEXT,
"status" TEXT NOT NULL,
"sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"error_message" TEXT,
"bounce_type" TEXT,
"bounce_details" JSONB,
CONSTRAINT "email_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "email_logs_recipient_idx" ON "email_logs"("recipient");
-- CreateIndex
CREATE INDEX "email_logs_status_idx" ON "email_logs"("status");
-- CreateIndex
CREATE INDEX "email_logs_sent_at_idx" ON "email_logs"("sent_at");

View File

@@ -32,3 +32,20 @@ model News {
@@map(name: "news") @@map(name: "news")
} }
model EmailLog {
id Int @id @default(autoincrement())
recipient String
subject String?
messageId String? @map("message_id")
status String // 'sent', 'failed', 'bounced'
sentAt DateTime @default(now()) @map("sent_at")
errorMessage String? @map("error_message")
bounceType String? @map("bounce_type")
bounceDetails Json? @map("bounce_details")
@@index([recipient])
@@index([status])
@@index([sentAt])
@@map(name: "email_logs")
}

213
utils/mailer.ts Normal file
View File

@@ -0,0 +1,213 @@
import nodemailer from 'nodemailer';
import prisma from '@prisma/prisma';
interface EmailTemplate {
subject: string;
template: JSX.Element;
}
interface SendResult {
success: boolean;
messageId?: string;
error?: string;
}
// Create nodemailer transporter for Postfix container
const createTransporter = () => {
// Default to 'postfix' for Docker networking (service name in docker-compose)
// Override with EMAIL_HOST for different setups
const host = process.env.EMAIL_HOST || 'postfix';
const port = parseInt(process.env.EMAIL_PORT || '25', 10);
return nodemailer.createTransport({
host,
port,
secure: false, // true for 465, false for other ports
tls: {
rejectUnauthorized: false
},
// Connection pooling for better performance
pool: true,
maxConnections: 5,
maxMessages: 100
});
};
// Render React component to HTML string using dynamic import
const renderTemplate = async (template: JSX.Element): Promise<string> => {
// Dynamic import to avoid Next.js bundling issues with react-dom/server
const { renderToStaticMarkup } = await import('react-dom/server');
const html = renderToStaticMarkup(template);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f5;">
${html}
</body>
</html>`;
};
// Generate plain text version from HTML (basic extraction)
const htmlToPlainText = (html: string): string => {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim();
};
// Log email send attempt to database
const logEmailSend = async (
recipient: string,
subject: string,
status: 'sent' | 'failed',
messageId?: string,
errorMessage?: string
) => {
try {
await prisma.emailLog.create({
data: {
recipient,
subject,
status,
messageId,
errorMessage
}
});
} catch (error) {
console.error('Failed to log email send:', error);
}
};
// Send a single email
const sendSingleEmail = async (
transporter: nodemailer.Transporter,
recipient: string,
subject: string,
htmlContent: string,
textContent: string
): Promise<SendResult> => {
const fromAddress = process.env.EMAIL_FROM || process.env.RESEND_FROM;
const fromName = process.env.NEXT_PUBLIC_BRAND_NAME || 'HackerNews Newsletter';
try {
const info = await transporter.sendMail({
from: `"${fromName}" <${fromAddress}>`,
to: recipient,
subject,
text: textContent,
html: htmlContent,
headers: {
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`,
'X-Newsletter-ID': `hackernews-${Date.now()}`
}
});
await logEmailSend(recipient, subject, 'sent', info.messageId);
return {
success: true,
messageId: info.messageId
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
await logEmailSend(recipient, subject, 'failed', undefined, errorMessage);
return {
success: false,
error: errorMessage
};
}
};
// Main sender function - maintains same API as Resend version
export async function sender(
recipients: string[],
{ subject, template }: EmailTemplate
): Promise<boolean> {
const fromAddress = process.env.EMAIL_FROM || process.env.RESEND_FROM;
if (!fromAddress) {
throw new Error('EMAIL_FROM or RESEND_FROM environment variable is not set');
}
if (recipients.length === 0) {
console.info(`${subject} email skipped for having zero recipients`);
return true;
}
const transporter = createTransporter();
// Render template to HTML
const htmlContent = await renderTemplate(template);
const textContent = htmlToPlainText(htmlContent);
// Add small delay between sends to avoid overwhelming mail server
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
let successCount = 0;
let failCount = 0;
const errors: Array<{ email: string; error: string }> = [];
for (const recipient of recipients) {
const result = await sendSingleEmail(
transporter,
recipient,
subject,
htmlContent,
textContent
);
if (result.success) {
successCount++;
} else {
failCount++;
errors.push({ email: recipient, error: result.error || 'Unknown error' });
}
// Add 100ms delay between sends
if (recipients.length > 1) {
await delay(100);
}
}
// Close the transporter pool
transporter.close();
if (errors.length > 0) {
console.error('Email send errors:', errors);
}
console.info(
`${subject} email: ${successCount} sent, ${failCount} failed out of ${recipients.length} recipients`
);
// Return true if at least one email was sent successfully
return successCount > 0;
}
// Verify transporter connection (useful for health checks)
export async function verifyMailer(): Promise<boolean> {
const transporter = createTransporter();
try {
await transporter.verify();
console.log('Email server connection verified');
transporter.close();
return true;
} catch (error) {
console.error('Email server connection failed:', error);
transporter.close();
return false;
}
}

View File

@@ -1,65 +0,0 @@
import { Resend } from 'resend';
interface EmailTemplate {
subject: string;
template: JSX.Element;
}
export async function sender(
recipients: string[],
{ subject, template }: EmailTemplate
) {
if (!process.env.RESEND_KEY) {
throw new Error('RESEND_KEY is not set');
}
if (recipients.length === 0) {
console.info(`${subject} email skipped for having zero recipients`);
return true;
}
const resend = new Resend(process.env.RESEND_KEY);
try {
let response;
if (recipients.length == 1) {
response = await resend.emails.send({
from: process.env.RESEND_FROM!,
to: recipients[0],
subject,
react: template,
headers: {
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`
}
});
} else {
response = await resend.batch.send(
recipients.map(recipient => {
return {
from: process.env.RESEND_FROM!,
to: recipient,
subject,
react: template,
headers: {
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`
}
};
})
);
}
const { error } = response;
if (error) {
console.error(error);
return false;
}
console.info(`${subject} email sent to ${recipients.length} recipients`);
return true;
} catch (error) {
console.error(error);
return false;
}
}

1118
yarn.lock

File diff suppressed because it is too large Load Diff