Add Mailpit email transport

This commit is contained in:
2026-08-17 16:35:14 -04:00
parent 67ab6b78bd
commit 1853eaa963
23 changed files with 567 additions and 148 deletions
+16
View File
@@ -0,0 +1,16 @@
# @beenvoice/email
Server-only email transport shared by Beenvoice delivery paths.
- `EMAIL_PROVIDER=mailpit` uses local SMTP and is rejected in production.
- `EMAIL_PROVIDER=smtp` uses any configured SMTP-compatible provider.
- `EMAIL_PROVIDER=resend` uses the Resend HTTP API and supports idempotency keys.
Local development defaults to Mailpit at `127.0.0.1:1028`; its web UI is at
`http://localhost:8028`. Start it with the web development dependencies and send
a representative message with a PDF attachment:
```bash
bun run --filter @beenvoice/web docker:up
bun run email:preview
```
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@beenvoice/email",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"nodemailer": "^9.0.3",
"resend": "4.8.0"
},
"devDependencies": {
"@types/bun": "1.3.14",
"@types/node": "20.19.39",
"@types/nodemailer": "^8.0.1",
"typescript": "5.9.3"
}
}
+130
View File
@@ -0,0 +1,130 @@
import nodemailer from "nodemailer";
import { Resend } from "resend";
export type EmailProvider = "mailpit" | "smtp" | "resend";
export interface EmailAttachment {
filename: string;
content: Buffer;
contentId?: string;
contentDisposition?: "attachment" | "inline";
}
export interface SendEmailInput {
from?: string;
to: string | string[];
cc?: string[];
bcc?: string[];
subject: string;
html: string;
text: string;
headers?: Record<string, string>;
attachments?: EmailAttachment[];
idempotencyKey?: string;
resendApiKey?: string;
}
export interface EmailReadinessOptions {
provider?: EmailProvider;
resendApiKey?: string;
from?: string;
}
function selectedProvider(override?: EmailProvider): EmailProvider {
return (
override ??
(process.env.EMAIL_PROVIDER as EmailProvider | undefined) ??
"resend"
);
}
export function getEmailReadiness(options: EmailReadinessOptions = {}) {
const provider = selectedProvider(options.provider);
const missing: string[] = [];
if (!options.from && !process.env.EMAIL_FROM && !process.env.RESEND_FROM) {
missing.push("EMAIL_FROM");
}
if (provider === "resend") {
if (!options.resendApiKey && !process.env.RESEND_API_KEY) {
missing.push("RESEND_API_KEY");
}
} else if (provider === "mailpit" || provider === "smtp") {
if (provider === "mailpit" && process.env.NODE_ENV === "production") {
missing.push("EMAIL_PROVIDER (mailpit is development-only)");
}
if (!process.env.SMTP_HOST) missing.push("SMTP_HOST");
if (!process.env.SMTP_PORT) missing.push("SMTP_PORT");
} else {
missing.push("EMAIL_PROVIDER");
}
return { provider, configured: missing.length === 0, missing };
}
export async function sendEmail(input: SendEmailInput) {
const readiness = getEmailReadiness({
from: input.from,
resendApiKey: input.resendApiKey,
});
if (!readiness.configured) {
throw new Error(
`Email delivery is not configured: ${readiness.missing.join(", ")}`,
);
}
const from = input.from ?? process.env.EMAIL_FROM ?? process.env.RESEND_FROM;
if (!from) throw new Error("Email delivery is not configured: EMAIL_FROM");
if (readiness.provider === "mailpit" || readiness.provider === "smtp") {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: process.env.SMTP_SECURE === "true",
});
const result = await transporter.sendMail({
from,
to: input.to,
cc: input.cc,
bcc: input.bcc,
subject: input.subject,
html: input.html,
text: input.text,
headers: {
...input.headers,
...(input.idempotencyKey
? { "X-Entity-Ref-ID": input.idempotencyKey }
: {}),
},
attachments: input.attachments?.map((attachment) => ({
filename: attachment.filename,
content: attachment.content,
cid: attachment.contentId,
contentDisposition: attachment.contentDisposition ?? "attachment",
})),
});
return { id: result.messageId, provider: readiness.provider };
}
const resend = new Resend(input.resendApiKey ?? process.env.RESEND_API_KEY);
const { data, error } = await resend.emails.send(
{
from,
to: input.to,
cc: input.cc,
bcc: input.bcc,
subject: input.subject,
html: input.html,
text: input.text,
headers: input.headers,
attachments: input.attachments?.map((attachment) => ({
filename: attachment.filename,
content: attachment.content,
contentId: attachment.contentId,
})),
},
input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined,
);
if (error) throw new Error(error.message);
if (!data?.id) throw new Error("Email provider returned no message ID");
return { id: data.id, provider: readiness.provider };
}
+59
View File
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, test } from "bun:test";
import { getEmailReadiness } from "../src";
const originalEnv = {
EMAIL_FROM: process.env.EMAIL_FROM,
EMAIL_PROVIDER: process.env.EMAIL_PROVIDER,
NODE_ENV: process.env.NODE_ENV,
RESEND_API_KEY: process.env.RESEND_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
};
afterEach(() => {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
describe("email provider readiness", () => {
test("accepts a configured Mailpit transport in development", () => {
process.env.NODE_ENV = "development";
process.env.EMAIL_PROVIDER = "mailpit";
process.env.EMAIL_FROM = "beenvoice <noreply@beenvoice.test>";
process.env.SMTP_HOST = "127.0.0.1";
process.env.SMTP_PORT = "1028";
expect(getEmailReadiness()).toEqual({
provider: "mailpit",
configured: true,
missing: [],
});
});
test("rejects Mailpit in production", () => {
process.env.NODE_ENV = "production";
process.env.EMAIL_PROVIDER = "mailpit";
process.env.EMAIL_FROM = "beenvoice <noreply@beenvoice.test>";
process.env.SMTP_HOST = "mailpit";
process.env.SMTP_PORT = "1025";
expect(getEmailReadiness().configured).toBe(false);
expect(getEmailReadiness().missing).toContain(
"EMAIL_PROVIDER (mailpit is development-only)",
);
});
test("accepts a per-business Resend key", () => {
process.env.NODE_ENV = "production";
process.env.EMAIL_PROVIDER = "resend";
process.env.EMAIL_FROM = "beenvoice <noreply@beenvoice.app>";
delete process.env.RESEND_API_KEY;
expect(getEmailReadiness({ resendApiKey: "re_business" }).configured).toBe(
true,
);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["bun", "node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}