Add Mailpit client and createMailInbox factory
Homelab Mailpit is the default mail trap; Mailtrap SaaS remains optional.
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.1 — 2026-07-14
|
||||||
|
|
||||||
|
- **Mailpit** client + `createMailInbox()` (prefer homelab Mailpit, else Mailtrap SaaS)
|
||||||
|
|
||||||
## 0.2.0 — 2026-07-14
|
## 0.2.0 — 2026-07-14
|
||||||
|
|
||||||
- **Mailtrap** Email Testing client: `MailtrapClient`, `waitForEmail`, `extractLinks` / `firstLinkMatching`
|
- **Mailtrap** Email Testing client: `MailtrapClient`, `waitForEmail`, `extractLinks` / `firstLinkMatching`
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@levkin/playkit",
|
"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",
|
"description": "Shared Playwright + API test kit — browser helpers, API client, logging, performance, Grafana/Prometheus metrics",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -47,9 +47,17 @@ export {
|
|||||||
export {
|
export {
|
||||||
MailtrapClient,
|
MailtrapClient,
|
||||||
loadMailtrapConfig,
|
loadMailtrapConfig,
|
||||||
|
MailpitClient,
|
||||||
|
loadMailpitConfig,
|
||||||
|
createMailInbox,
|
||||||
|
readMailHtml,
|
||||||
extractLinks,
|
extractLinks,
|
||||||
firstLinkMatching,
|
firstLinkMatching,
|
||||||
type MailtrapConfig,
|
type MailtrapConfig,
|
||||||
type MailtrapMessage,
|
type MailtrapMessage,
|
||||||
type WaitForEmailOptions,
|
type WaitForEmailOptions,
|
||||||
|
type MailpitConfig,
|
||||||
|
type MailpitMessage,
|
||||||
|
type WaitForMailpitOptions,
|
||||||
|
type MailInbox,
|
||||||
} from './mail/index.js';
|
} 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 {
|
export {
|
||||||
MailtrapClient,
|
MailtrapClient,
|
||||||
loadMailtrapConfig,
|
loadMailtrapConfig,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user