Add Mailtrap client for email e2e assertions
Ship MailtrapClient.waitForEmail + link helpers so consumers can prove forgot-password/verify mail without relying on real inboxes.
This commit is contained in:
parent
2b8c7d86f3
commit
2f3991dadc
@ -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=
|
||||
|
||||
@ -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
|
||||
|
||||
21
README.md
21
README.md
@ -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 app’s 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
|
||||
|
||||
|
||||
17
ROADMAP.md
17
ROADMAP.md
@ -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
|
||||
|
||||
@ -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": [
|
||||
|
||||
10
src/index.ts
10
src/index.ts
@ -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
9
src/mail/index.ts
Normal 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
90
src/mail/mailtrap.test.ts
Normal 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
251
src/mail/mailtrap.ts
Normal 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})` : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user