mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat(emails): mailpit + mock-postmark local dev stack (#491)
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user