Add Mailpit client and createMailInbox factory
Homelab Mailpit is the default mail trap; Mailtrap SaaS remains optional.
This commit is contained in:
parent
2f3991dadc
commit
a52fe89372
@ -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`
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
73
src/mail/mailpit.test.ts
Normal 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
175
src/mail/mailpit.ts
Normal 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 };
|
||||
Loading…
x
Reference in New Issue
Block a user