feat: self-hosted postfix
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
30
docker/postfix/Dockerfile
Normal 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"]
|
||||||
90
docker/postfix/entrypoint.sh
Normal file
90
docker/postfix/entrypoint.sh
Normal 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
|
||||||
30
docker/postfix/opendkim.conf
Normal file
30
docker/postfix/opendkim.conf
Normal 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
|
||||||
50
docker/postfix/postfix-main.cf
Normal file
50
docker/postfix/postfix-main.cf
Normal 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
1794
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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
213
utils/mailer.ts
Normal 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(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user