Add Mailpit email transport
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["bun", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user