feat(emails): mailpit + mock-postmark local dev stack (#491)

This commit is contained in:
Antoine Lecompte
2026-08-24 06:44:59 -04:00
committed by GitHub
parent 2fc89b105b
commit 1d92b4b0c1
24 changed files with 453 additions and 4 deletions
@@ -0,0 +1,21 @@
ARG ALPINE_VERSION=3.23
FROM node:25-alpine${ALPINE_VERSION} AS builder
WORKDIR /build
RUN npm install -g pnpm@10.28.1
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY packages/mock-postmark-provider/package.json ./packages/mock-postmark-provider/
RUN pnpm install --frozen-lockfile --filter mock-postmark-provider
COPY packages/mock-postmark-provider ./packages/mock-postmark-provider
RUN pnpm --filter mock-postmark-provider build
RUN pnpm --filter mock-postmark-provider deploy /deploy --prod --legacy
FROM node:25-alpine${ALPINE_VERSION}
WORKDIR /usr/src/app
COPY --from=builder /deploy ./
USER node
ENV NODE_ENV=production
EXPOSE 80
CMD ["node", "dist/index.js"]
@@ -0,0 +1,25 @@
{
"name": "mock-postmark-provider",
"version": "0.30.1",
"description": "In-repo Postmark API mock for dev and e2e tests, delivering into Mailpit",
"private": true,
"license": "UNLICENSED",
"type": "module",
"main": "dist/index.js",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"start:dev": "tsx watch src/index.ts"
},
"dependencies": {
"zod": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:"
}
}
@@ -0,0 +1,8 @@
import { z } from 'zod';
const schema = z.object({
PORT: z.coerce.number().default(80),
MAILPIT_URL: z.url().default('http://localhost:8025'),
});
export const env = schema.parse(process.env);
@@ -0,0 +1,122 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { z } from 'zod';
import { env } from './env.js';
const messageSchema = z.object({
From: z.string(),
To: z.string(),
Subject: z.string(),
HtmlBody: z.string().optional(),
TextBody: z.string().optional(),
Tag: z.string().optional(),
MessageStream: z.string().optional(),
});
type PostmarkMessage = z.infer<typeof messageSchema>;
const parseAddress = (address: string) => {
const match = address.match(/^\s*(.*?)\s*<([^>]+)>\s*$/);
return match ? { Name: match[1], Email: match[2] } : { Name: '', Email: address.trim() };
};
const deliverToMailpit = async (message: PostmarkMessage) => {
const response = await fetch(new URL('/api/v1/send', env.MAILPIT_URL), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
From: parseAddress(message.From),
To: message.To.split(',').map((address) => parseAddress(address)),
Subject: message.Subject,
HTML: message.HtmlBody ?? '',
Text: message.TextBody ?? '',
Tags: message.Tag ? [message.Tag] : [],
Headers: { 'X-Message-Stream': message.MessageStream ?? 'outbound' },
}),
});
if (!response.ok) {
throw new Error(`Mailpit send failed: ${response.status} ${response.statusText} — ${await response.text()}`);
}
const { ID } = (await response.json()) as { ID: string };
return ID;
};
const sendOne = async (message: unknown) => {
const parsed = messageSchema.safeParse(message);
if (!parsed.success) {
return { ErrorCode: 300, Message: `Invalid request: ${parsed.error.message}` };
}
try {
const id = await deliverToMailpit(parsed.data);
return {
To: parsed.data.To,
SubmittedAt: new Date().toISOString(),
MessageID: id,
ErrorCode: 0,
Message: 'OK',
};
} catch (error) {
return { To: parsed.data.To, ErrorCode: 300, Message: `${error}` };
}
};
const readBody = (request: IncomingMessage): Promise<string> =>
new Promise((resolve, reject) => {
let data = '';
request.on('data', (chunk) => (data += chunk));
request.on('end', () => resolve(data));
request.on('error', reject);
});
const respond = (response: ServerResponse, status: number, body: unknown) => {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(body));
};
const server = createServer((request, response) => {
handle(request, response).catch((error) => {
console.error(error);
respond(response, 500, { ErrorCode: 500, Message: `${error}` });
});
});
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
const path = new URL(request.url ?? '/', 'http://localhost').pathname;
if (request.method === 'GET' && path === '/health') {
respond(response, 200, { status: 'ok' });
return;
}
if (request.method !== 'POST' || (path !== '/email' && path !== '/email/batch')) {
respond(response, 404, { ErrorCode: 404, Message: `No such endpoint: ${request.method} ${path}` });
return;
}
if (!request.headers['x-postmark-server-token']) {
respond(response, 401, { ErrorCode: 10, Message: 'No Account or Server API tokens were supplied.' });
return;
}
const body: unknown = JSON.parse(await readBody(request));
if (path === '/email') {
const result = await sendOne(body);
respond(response, result.ErrorCode === 0 ? 200 : 422, result);
return;
}
if (!Array.isArray(body)) {
respond(response, 422, { ErrorCode: 300, Message: 'Batch payload must be an array' });
return;
}
const results = [];
for (const message of body) {
results.push(await sendOne(message));
}
respond(response, 200, results);
}
server.listen(env.PORT, () => {
console.log(`mock-postmark-provider listening on :${env.PORT}, delivering to ${env.MAILPIT_URL}`);
});
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"isolatedModules": true,
"declaration": false,
"removeComments": true,
"target": "ES2023",
"sourceMap": true,
"rootDir": "./src",
"outDir": "./dist",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strict": true
},
"include": ["src"]
}