Add Mailpit client and createMailInbox factory
All checks were successful
CI / skip-ci-check (push) Successful in 4s
CI / secret-scan (push) Successful in 3s
CI / build-and-test (push) Successful in 15s

Homelab Mailpit is the default mail trap; Mailtrap SaaS remains optional.
This commit is contained in:
ilia 2026-07-14 17:14:58 -04:00
parent 2f3991dadc
commit a52fe89372
6 changed files with 314 additions and 1 deletions

View File

@ -1,5 +1,9 @@
# Changelog
## 0.2.1 — 2026-07-14
- **Mailpit** client + `createMailInbox()` (prefer homelab Mailpit, else Mailtrap SaaS)
## 0.2.0 — 2026-07-14
- **Mailtrap** Email Testing client: `MailtrapClient`, `waitForEmail`, `extractLinks` / `firstLinkMatching`

View File

@ -1,6 +1,6 @@
{
"name": "@levkin/playkit",
"version": "0.2.0",
"version": "0.2.1",
"description": "Shared Playwright + API test kit — browser helpers, API client, logging, performance, Grafana/Prometheus metrics",
"license": "MIT",
"type": "module",

View File

@ -47,9 +47,17 @@ export {
export {
MailtrapClient,
loadMailtrapConfig,
MailpitClient,
loadMailpitConfig,
createMailInbox,
readMailHtml,
extractLinks,
firstLinkMatching,
type MailtrapConfig,
type MailtrapMessage,
type WaitForEmailOptions,
type MailpitConfig,
type MailpitMessage,
type WaitForMailpitOptions,
type MailInbox,
} from './mail/index.js';

View File

@ -1,3 +1,56 @@
/**
* Unified mail-inbox helper: prefer Mailpit (homelab), else Mailtrap (SaaS).
*/
import { createLogger, type Logger } from '../logging/logger.js';
import { MailpitClient, loadMailpitConfig } from './mailpit.js';
import { MailtrapClient, loadMailtrapConfig } from './mailtrap.js';
import { extractLinks, firstLinkMatching } from './mailtrap.js';
export type MailInbox = MailpitClient | MailtrapClient;
export function createMailInbox(
env: NodeJS.ProcessEnv = process.env,
logger?: Logger,
): MailInbox | null {
const log = logger ?? createLogger({ name: 'mail' });
const provider = (env.PLAYKIT_MAIL_PROVIDER || env.MAIL_PROVIDER || '').toLowerCase();
if (provider === 'mailtrap') {
return MailtrapClient.fromEnv(env, log);
}
if (provider === 'mailpit' || loadMailpitConfig(env)) {
const pit = MailpitClient.fromEnv(env, log);
if (pit) return pit;
}
if (loadMailtrapConfig(env)) {
return MailtrapClient.fromEnv(env, log);
}
return null;
}
/** Normalize HTML body from either Mailpit or Mailtrap message shapes. */
export async function readMailHtml(
inbox: MailInbox,
msg: { ID?: string; id?: number; HTML?: string; html_path?: string },
): Promise<string> {
if (typeof msg.HTML === 'string' && msg.HTML.length > 0) return msg.HTML;
if (inbox instanceof MailpitClient && msg.ID) {
return inbox.getHtml(msg.ID);
}
if (inbox instanceof MailtrapClient && typeof msg.id === 'number') {
return inbox.getHtml(msg.id, msg.html_path);
}
throw new Error('readMailHtml: unrecognized message shape');
}
export {
MailpitClient,
loadMailpitConfig,
type MailpitConfig,
type MailpitMessage,
type WaitForMailpitOptions,
} from './mailpit.js';
export {
MailtrapClient,
loadMailtrapConfig,

73
src/mail/mailpit.test.ts Normal file
View File

@ -0,0 +1,73 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { MailpitClient, loadMailpitConfig } from './mailpit.js';
describe('loadMailpitConfig', () => {
it('returns null without base url', () => {
expect(loadMailpitConfig({})).toBeNull();
});
it('loads base url and auth', () => {
expect(
loadMailpitConfig({
MAILPIT_BASE_URL: 'http://10.0.10.45:8025',
MAILPIT_USER: 'u',
MAILPIT_PASSWORD: 'p',
}),
).toEqual({
baseUrl: 'http://10.0.10.45:8025',
user: 'u',
password: 'p',
});
});
});
describe('MailpitClient', () => {
afterEach(() => vi.unstubAllGlobals());
it('waits for matching message', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes('/api/v1/messages') && !u.includes('/message/')) {
return {
ok: true,
status: 200,
text: async () =>
JSON.stringify({
messages: [
{
ID: 'abc',
Subject: 'Reset your password',
To: [{ Address: 'e2e@example.com' }],
Date: new Date().toISOString(),
},
],
}),
};
}
return {
ok: true,
status: 200,
text: async () =>
JSON.stringify({
ID: 'abc',
Subject: 'Reset your password',
To: [{ Address: 'e2e@example.com' }],
HTML: '<a href="https://punimtagdev.levkin.ca/reset-password?token=x">Reset</a>',
}),
};
}),
);
const client = new MailpitClient({ baseUrl: 'http://mailpit.test' });
const msg = await client.waitForEmail({
to: 'e2e@example.com',
subject: /reset/i,
timeoutMs: 1000,
pollMs: 50,
});
expect(msg.ID).toBe('abc');
expect(msg.HTML).toContain('reset-password');
});
});

175
src/mail/mailpit.ts Normal file
View File

@ -0,0 +1,175 @@
import { createLogger, type Logger } from '../logging/logger.js';
import { extractLinks, firstLinkMatching } from './mailtrap.js';
export interface MailpitConfig {
baseUrl: string;
/** Basic auth user (optional). */
user?: string;
/** Basic auth password (optional). */
password?: string;
logger?: Logger;
}
export interface MailpitMessageSummary {
ID: string;
MessageID?: string;
From?: { Name?: string; Address?: string };
To?: Array<{ Name?: string; Address?: string }>;
Subject?: string;
Date?: string;
Created?: string;
Snippet?: string;
}
export interface MailpitMessage extends MailpitMessageSummary {
HTML?: string;
Text?: string;
}
export interface WaitForMailpitOptions {
to?: string;
subject?: string | RegExp;
after?: Date;
timeoutMs?: number;
pollMs?: number;
search?: string;
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function matchesSubject(subject: string, want?: string | RegExp): boolean {
if (want == null) return true;
if (typeof want === 'string') return subject.toLowerCase().includes(want.toLowerCase());
return want.test(subject);
}
/**
* Load Mailpit config from env. Returns null if MAILPIT_BASE_URL unset.
*
* Env:
* - PLAYKIT_MAILPIT_BASE_URL / MAILPIT_BASE_URL (e.g. http://10.0.10.45:8025)
* - MAILPIT_USER / MAILPIT_PASSWORD (basic auth)
*/
export function loadMailpitConfig(env: NodeJS.ProcessEnv = process.env): MailpitConfig | null {
const baseUrl = (
env.PLAYKIT_MAILPIT_BASE_URL ||
env.MAILPIT_BASE_URL ||
''
).replace(/\/$/, '');
if (!baseUrl) return null;
return {
baseUrl,
user: env.PLAYKIT_MAILPIT_USER || env.MAILPIT_USER || undefined,
password: env.PLAYKIT_MAILPIT_PASSWORD || env.MAILPIT_PASSWORD || undefined,
};
}
/**
* Homelab Mailpit client (LAN mail trap). Prefer this over SaaS Mailtrap when
* the inbox is self-hosted at automationlab.
*/
export class MailpitClient {
private readonly log: Logger;
private readonly baseUrl: string;
private readonly authHeader?: string;
constructor(config: MailpitConfig) {
this.log = config.logger ?? createLogger({ name: 'MailpitClient' });
this.baseUrl = config.baseUrl.replace(/\/$/, '');
if (config.user && config.password) {
this.authHeader =
'Basic ' + Buffer.from(`${config.user}:${config.password}`).toString('base64');
}
}
static fromEnv(env: NodeJS.ProcessEnv = process.env, logger?: Logger): MailpitClient | null {
const cfg = loadMailpitConfig(env);
if (!cfg) return null;
return new MailpitClient({ ...cfg, logger });
}
private headers(): Record<string, string> {
const h: Record<string, string> = { Accept: 'application/json' };
if (this.authHeader) h.Authorization = this.authHeader;
return h;
}
async listMessages(opts?: { search?: string }): Promise<MailpitMessageSummary[]> {
const url = new URL(`${this.baseUrl}/api/v1/messages`);
if (opts?.search) url.searchParams.set('query', opts.search);
url.searchParams.set('limit', '50');
const res = await fetch(url, { headers: this.headers() });
const text = await res.text();
if (!res.ok) {
throw new Error(`Mailpit list failed (${res.status}): ${text.slice(0, 300)}`);
}
const data = JSON.parse(text) as { messages?: MailpitMessageSummary[] };
return data.messages ?? [];
}
async getMessage(id: string): Promise<MailpitMessage> {
const res = await fetch(`${this.baseUrl}/api/v1/message/${id}`, {
headers: this.headers(),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`Mailpit get message failed (${res.status}): ${text.slice(0, 300)}`);
}
return JSON.parse(text) as MailpitMessage;
}
async getHtml(id: string): Promise<string> {
const msg = await this.getMessage(id);
return msg.HTML || msg.Text || '';
}
async deleteAll(): Promise<void> {
await fetch(`${this.baseUrl}/api/v1/messages`, {
method: 'DELETE',
headers: this.headers(),
});
}
async waitForEmail(opts: WaitForMailpitOptions = {}): Promise<MailpitMessage> {
const timeoutMs = opts.timeoutMs ?? 60_000;
const pollMs = opts.pollMs ?? 2_000;
const started = Date.now();
const search = opts.search || opts.to || undefined;
while (Date.now() - started < timeoutMs) {
const list = await this.listMessages({ search });
for (const summary of list) {
const toAddrs = (summary.To || []).map((t) => (t.Address || '').toLowerCase());
if (opts.to && !toAddrs.some((a) => a.includes(opts.to!.toLowerCase()))) {
continue;
}
if (!matchesSubject(summary.Subject || '', opts.subject)) continue;
if (opts.after) {
const raw = summary.Date || summary.Created;
if (raw) {
const t = new Date(raw);
if (!Number.isNaN(t.getTime()) && t < opts.after) continue;
}
}
const full = await this.getMessage(summary.ID);
this.log.info('mailpit.match', {
id: full.ID,
subject: full.Subject,
to: toAddrs.join(','),
});
return full;
}
await sleep(pollMs);
}
throw new Error(
`Mailpit: no matching email within ${timeoutMs}ms` +
(opts.to ? ` (to~=${opts.to})` : '') +
(opts.subject ? ` (subject~=${String(opts.subject)})` : ''),
);
}
}
export { extractLinks, firstLinkMatching };