Add Mailtrap client for email e2e assertions
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

Ship MailtrapClient.waitForEmail + link helpers so consumers can prove
forgot-password/verify mail without relying on real inboxes.
This commit is contained in:
ilia 2026-07-14 16:22:26 -04:00
parent 2b8c7d86f3
commit 2f3991dadc
10 changed files with 407 additions and 9 deletions

View File

@ -7,3 +7,8 @@ PLAYKIT_LOG_LEVEL=info
PLAYKIT_FORBID_PRIVATE_HOSTS=true
# PLAYKIT_METRICS_ENABLED=true
# PLAYKIT_PUSHGATEWAY_URL=http://10.0.10.24:9091
# Mailtrap Email Testing (Sandbox) — emails only appear if app SMTP → sandbox.smtp.mailtrap.io
# PLAYKIT_MAILTRAP_API_TOKEN=
# PLAYKIT_MAILTRAP_ACCOUNT_ID=
# PLAYKIT_MAILTRAP_INBOX_ID=

View File

@ -1,5 +1,10 @@
# Changelog
## 0.2.0 — 2026-07-14
- **Mailtrap** Email Testing client: `MailtrapClient`, `waitForEmail`, `extractLinks` / `firstLinkMatching`
- Export path `@levkin/playkit/mail`
## 0.1.1 — 2026-07-14
- Add `prepare` script so git installs build `dist/` for consumers

View File

@ -72,6 +72,27 @@ test('sign-out stays on public host', async ({ page, playkitConfig, timings }) =
| `createLogger` / `redactSecrets` | Structured JSON logs |
| `TimingCollector` / `pushPrometheusMetrics` | Action timings → Prometheus Pushgateway → Grafana |
| `createPlaykitRuntime` | One-shot config + logger + API + timings |
| `MailtrapClient` / `waitForEmail` | Assert password-reset / verify emails in Mailtrap sandbox |
## Mailtrap (email testing)
```ts
import { MailtrapClient, firstLinkMatching, assertPublicHost } from '@levkin/playkit';
const mail = MailtrapClient.fromEnv();
if (!mail) throw new Error('set MAILTRAP_API_TOKEN + MAILTRAP_INBOX_ID');
const after = new Date();
// … trigger forgot-password in the app …
const msg = await mail.waitForEmail({ to: 'e2e@example.com', subject: /reset/i, after });
const html = await mail.getHtml(msg.id, msg.html_path);
const link = firstLinkMatching(html, /reset-password/);
assertPublicHost(link!);
```
**Important:** Mailtrap only sees mail if the apps SMTP points at the sandbox
(`sandbox.smtp.mailtrap.io` + inbox credentials). Sending via Gmail to a real
address will not appear in the sandbox. See ansible `docs/hardening/SECRETS.md`.
## Develop this repo

View File

@ -2,7 +2,7 @@
Living plan for making `@levkin/playkit` more useful across Levkin repos.
## Now (v0.1) — shipped in this repo
## Now (v0.2) — Mailtrap + prior kit
- [x] Browser helpers with retries (`click`, `fill`, `safeGoto`, visibility waits)
- [x] `BasePage` for Page Objects
@ -14,10 +14,11 @@ Living plan for making `@levkin/playkit` more useful across Levkin repos.
- [x] Unit tests (vitest) + Gitea Actions CI
- [x] Starter Grafana dashboard JSON
- [x] Consumer docs + API/UI examples
- [x] **Mailtrap / Email Testing adapter**`waitForEmail`, link extraction (Kolby #56 class)
## Next (v0.2) — high value
## Next (v0.3) — high value
- [ ] **Mailosaur / Mailpit adapter** — assert “email sent” without depending on JRCC Outlook or Spamhaus-blocked IPs
- [ ] **Mailpit adapter** — same `waitForEmail` interface for local/homelab catch-all (no SaaS)
- [ ] **Auth storage state helpers** — save/load Playwright `storageState` for admin vs viewer roles
- [ ] **Trace-on-failure preset** — one-liner Playwright config merge (`trace: 'retain-on-failure'`, screenshot, video)
- [ ] **Schema assertions** — optional Zod (or AJV) helpers on `ApiClient` responses
@ -26,7 +27,7 @@ Living plan for making `@levkin/playkit` more useful across Levkin repos.
- [ ] **Deploy-smoke CLI**`playkit smoke --project punimtag` post-deploy gate (health + public host + login)
- [ ] **Retry policy presets** — flaky-network vs strict-CI profiles
## Later (v0.3+) — professional polish
## Later (v0.4+) — professional polish
- [ ] **Web Vitals** (LCP/CLS/INP) collection via Playwright CDP + metrics labels
- [ ] **A11y** — axe-core wrapper as optional peer
@ -58,11 +59,11 @@ Living plan for making `@levkin/playkit` more useful across Levkin repos.
| Kuma + playkit correlation IDs | Tie synthetic monitor blips to e2e runs |
| Diffable timing baselines in git | Spot regressions without Grafana |
## Punimtag as first consumer (follow-up, not this repo)
## Punimtag as first consumer
1. Add `e2e/` depending on `@levkin/playkit@v0.1.0`
2. Specs: public host after sign-out; health API; optional forgot-password with mail trap
3. CI job on PR + scheduled DEV smoke
1. [x] `e2e/` depending on `@levkin/playkit`
2. [x] Specs: public host after sign-out; health API; forgot-password via Mailtrap
3. [ ] CI secrets populated + scheduled DEV smoke
4. Deploy rule: PR → green CI → merge → deploy script (no silent `pct exec` “done”)
## Observability

View File

@ -1,6 +1,6 @@
{
"name": "@levkin/playkit",
"version": "0.1.1",
"version": "0.2.0",
"description": "Shared Playwright + API test kit — browser helpers, API client, logging, performance, Grafana/Prometheus metrics",
"license": "MIT",
"type": "module",
@ -22,6 +22,11 @@
"types": "./dist/api/index.d.ts",
"import": "./dist/api/index.js",
"require": "./dist/api/index.cjs"
},
"./mail": {
"types": "./dist/mail/index.d.ts",
"import": "./dist/mail/index.js",
"require": "./dist/mail/index.cjs"
}
},
"files": [

View File

@ -43,3 +43,13 @@ export {
createPlaykitFixtures,
type PlaykitFixtures,
} from './fixtures/index.js';
export {
MailtrapClient,
loadMailtrapConfig,
extractLinks,
firstLinkMatching,
type MailtrapConfig,
type MailtrapMessage,
type WaitForEmailOptions,
} from './mail/index.js';

9
src/mail/index.ts Normal file
View File

@ -0,0 +1,9 @@
export {
MailtrapClient,
loadMailtrapConfig,
extractLinks,
firstLinkMatching,
type MailtrapConfig,
type MailtrapMessage,
type WaitForEmailOptions,
} from './mailtrap.js';

90
src/mail/mailtrap.test.ts Normal file
View File

@ -0,0 +1,90 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import {
MailtrapClient,
extractLinks,
firstLinkMatching,
loadMailtrapConfig,
} from './mailtrap.js';
describe('extractLinks', () => {
it('pulls href and bare urls', () => {
const html =
'<a href="https://punimtagdev.levkin.ca/reset-password?token=abc">Reset</a> see https://example.com/x';
const links = extractLinks(html);
expect(links).toContain('https://punimtagdev.levkin.ca/reset-password?token=abc');
expect(links).toContain('https://example.com/x');
expect(firstLinkMatching(html, /reset-password/)).toContain('token=abc');
});
});
describe('loadMailtrapConfig', () => {
it('returns null when incomplete', () => {
expect(loadMailtrapConfig({})).toBeNull();
expect(loadMailtrapConfig({ MAILTRAP_API_TOKEN: 't' })).toBeNull();
});
it('loads from env aliases', () => {
const cfg = loadMailtrapConfig({
MAILTRAP_API_TOKEN: 'tok',
MAILTRAP_INBOX_ID: '99',
MAILTRAP_ACCOUNT_ID: '1',
});
expect(cfg).toEqual({
apiToken: 'tok',
inboxId: '99',
accountId: '1',
baseUrl: 'https://mailtrap.io',
});
});
});
describe('MailtrapClient', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('lists and waits for a matching message', async () => {
const messages = [
{
id: 7,
subject: 'Reset your password',
to_email: 'e2e@example.com',
created_at: new Date().toISOString(),
html_path: '/api/accounts/1/inboxes/99/messages/7/body.html',
},
];
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
if (String(url).includes('/messages') && !String(url).includes('body')) {
return {
ok: true,
status: 200,
text: async () => JSON.stringify(messages),
};
}
return {
ok: true,
status: 200,
text: async () =>
'<a href="https://punimtagdev.levkin.ca/reset-password?token=xyz">Reset</a>',
};
}),
);
const client = new MailtrapClient({
apiToken: 'tok',
accountId: '1',
inboxId: '99',
});
const msg = await client.waitForEmail({
to: 'e2e@example.com',
subject: /reset/i,
timeoutMs: 2_000,
pollMs: 50,
});
expect(msg.id).toBe(7);
const html = await client.getHtml(msg.id, msg.html_path);
expect(firstLinkMatching(html, /reset-password/)).toContain('token=xyz');
});
});

251
src/mail/mailtrap.ts Normal file
View File

@ -0,0 +1,251 @@
import { createLogger, type Logger } from '../logging/logger.js';
export interface MailtrapConfig {
apiToken: string;
/** Classic Email Testing account id (Settings → API). */
accountId?: string;
/** Inbox / sandbox id (Sandboxes UI). */
inboxId: string;
baseUrl?: string;
logger?: Logger;
}
export interface MailtrapMessage {
id: number;
subject: string;
from_email?: string;
to_email?: string;
created_at?: string;
sent_at?: string;
html_path?: string;
txt_path?: string;
raw_path?: string;
[key: string]: unknown;
}
export interface WaitForEmailOptions {
/** Match recipient (substring, case-insensitive). */
to?: string;
/** Match subject (string substring or RegExp). */
subject?: string | RegExp;
/** Only messages at/after this time (client-side filter). */
after?: Date;
/** Search query passed to Mailtrap (`subject`, `to_email`, `to_name`). */
search?: string;
timeoutMs?: number;
pollMs?: number;
}
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);
}
function messageTime(msg: MailtrapMessage): Date | null {
const raw = msg.sent_at || msg.created_at;
if (!raw || typeof raw !== 'string') return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
}
/**
* Extract http(s) links from HTML or plain text.
*/
export function extractLinks(htmlOrText: string): string[] {
const hrefs = [...htmlOrText.matchAll(/href\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
const bare = [...htmlOrText.matchAll(/https?:\/\/[^\s<>"')\]]+/gi)].map((m) =>
m[0].replace(/[.,;]+$/, ''),
);
return [...new Set([...hrefs, ...bare])];
}
export function firstLinkMatching(
htmlOrText: string,
pattern: string | RegExp,
): string | undefined {
const re = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern;
return extractLinks(htmlOrText).find((u) => re.test(u));
}
/**
* Load Mailtrap config from env. Returns null if not configured
* (so consumers can `test.skip` cleanly).
*
* Env (any alias works):
* - PLAYKIT_MAILTRAP_API_TOKEN / MAILTRAP_API_TOKEN
* - PLAYKIT_MAILTRAP_INBOX_ID / MAILTRAP_INBOX_ID / MAILTRAP_SANDBOX_ID
* - PLAYKIT_MAILTRAP_ACCOUNT_ID / MAILTRAP_ACCOUNT_ID (optional for /api/sandboxes path)
*/
export function loadMailtrapConfig(
env: NodeJS.ProcessEnv = process.env,
): MailtrapConfig | null {
const apiToken =
env.PLAYKIT_MAILTRAP_API_TOKEN || env.MAILTRAP_API_TOKEN || env.MAILTRAP_API_KEY || '';
const inboxId =
env.PLAYKIT_MAILTRAP_INBOX_ID ||
env.MAILTRAP_INBOX_ID ||
env.MAILTRAP_SANDBOX_ID ||
'';
const accountId =
env.PLAYKIT_MAILTRAP_ACCOUNT_ID || env.MAILTRAP_ACCOUNT_ID || undefined;
if (!apiToken || !inboxId) return null;
return {
apiToken,
inboxId,
accountId: accountId || undefined,
baseUrl: (env.PLAYKIT_MAILTRAP_BASE_URL || env.MAILTRAP_BASE_URL || 'https://mailtrap.io').replace(
/\/$/,
'',
),
};
}
/**
* Mailtrap Email Testing (Sandbox) client.
*
* Emails only appear here if the app SMTP points at the sandbox
* (`sandbox.smtp.mailtrap.io` + inbox user/pass) not if you send via Gmail
* to a real address.
*/
export class MailtrapClient {
private readonly log: Logger;
private readonly baseUrl: string;
private readonly token: string;
private readonly inboxId: string;
private readonly accountId?: string;
constructor(config: MailtrapConfig) {
this.log = config.logger ?? createLogger({ name: 'MailtrapClient' });
this.baseUrl = (config.baseUrl || 'https://mailtrap.io').replace(/\/$/, '');
this.token = config.apiToken;
this.inboxId = config.inboxId;
this.accountId = config.accountId;
}
static fromEnv(env: NodeJS.ProcessEnv = process.env, logger?: Logger): MailtrapClient | null {
const cfg = loadMailtrapConfig(env);
if (!cfg) return null;
return new MailtrapClient({ ...cfg, logger });
}
private headers(): Record<string, string> {
return {
Accept: 'application/json',
'Api-Token': this.token,
Authorization: `Bearer ${this.token}`,
};
}
private messagesCollectionUrl(): string {
if (this.accountId) {
return `${this.baseUrl}/api/accounts/${this.accountId}/inboxes/${this.inboxId}/messages`;
}
return `${this.baseUrl}/api/sandboxes/${this.inboxId}/messages`;
}
private messageUrl(messageId: number, suffix = ''): string {
if (this.accountId) {
return `${this.baseUrl}/api/accounts/${this.accountId}/inboxes/${this.inboxId}/messages/${messageId}${suffix}`;
}
return `${this.baseUrl}/api/sandboxes/${this.inboxId}/messages/${messageId}${suffix}`;
}
async listMessages(opts?: { search?: string }): Promise<MailtrapMessage[]> {
const url = new URL(this.messagesCollectionUrl());
if (opts?.search) url.searchParams.set('search', opts.search);
this.log.info('mailtrap.list', { url: url.toString().replace(this.token, '[REDACTED]') });
const res = await fetch(url, { headers: this.headers() });
const text = await res.text();
if (!res.ok) {
throw new Error(`Mailtrap list messages failed (${res.status}): ${text.slice(0, 300)}`);
}
const data = text ? (JSON.parse(text) as MailtrapMessage[]) : [];
return Array.isArray(data) ? data : [];
}
async getHtml(messageId: number, htmlPath?: string): Promise<string> {
const path = htmlPath?.startsWith('http')
? htmlPath
: htmlPath
? `${this.baseUrl}${htmlPath.startsWith('/') ? '' : '/'}${htmlPath}`
: this.messageUrl(messageId, '/body.html');
const res = await fetch(path, { headers: this.headers() });
const text = await res.text();
if (!res.ok) {
throw new Error(`Mailtrap get HTML failed (${res.status}): ${text.slice(0, 300)}`);
}
return text;
}
async getText(messageId: number, txtPath?: string): Promise<string> {
const path = txtPath?.startsWith('http')
? txtPath
: txtPath
? `${this.baseUrl}${txtPath.startsWith('/') ? '' : '/'}${txtPath}`
: this.messageUrl(messageId, '/body.txt');
const res = await fetch(path, { headers: this.headers() });
const text = await res.text();
if (!res.ok) {
throw new Error(`Mailtrap get text failed (${res.status}): ${text.slice(0, 300)}`);
}
return text;
}
async deleteMessage(messageId: number): Promise<void> {
const res = await fetch(this.messageUrl(messageId), {
method: 'DELETE',
headers: this.headers(),
});
if (!res.ok && res.status !== 404) {
const text = await res.text();
throw new Error(`Mailtrap delete failed (${res.status}): ${text.slice(0, 300)}`);
}
}
/**
* Poll until a matching message appears.
*/
async waitForEmail(opts: WaitForEmailOptions = {}): Promise<MailtrapMessage> {
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 messages = await this.listMessages({ search });
const match = messages.find((m) => {
if (opts.to && !(m.to_email || '').toLowerCase().includes(opts.to.toLowerCase())) {
return false;
}
if (!matchesSubject(m.subject || '', opts.subject)) return false;
if (opts.after) {
const t = messageTime(m);
if (t && t < opts.after) return false;
}
return true;
});
if (match) {
this.log.info('mailtrap.match', {
id: match.id,
subject: match.subject,
to: match.to_email,
});
return match;
}
await sleep(pollMs);
}
throw new Error(
`Mailtrap: no matching email within ${timeoutMs}ms` +
(opts.to ? ` (to~=${opts.to})` : '') +
(opts.subject ? ` (subject~=${opts.subject})` : ''),
);
}
}

View File

@ -5,6 +5,7 @@ export default defineConfig({
index: 'src/index.ts',
'browser/index': 'src/browser/index.ts',
'api/index': 'src/api/index.ts',
'mail/index': 'src/mail/index.ts',
},
format: ['esm', 'cjs'],
dts: true,