@letsrunit/mailbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@letsrunit/mailbox",
3
+ "version": "0.1.0",
4
+ "description": "Test email mailbox integration for letsrunit",
5
+ "keywords": [
6
+ "testing",
7
+ "email",
8
+ "mailbox",
9
+ "letsrunit"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/letsrunit/letsrunit.git",
15
+ "directory": "packages/mailbox"
16
+ },
17
+ "bugs": "https://github.com/letsrunit/letsrunit/issues",
18
+ "homepage": "https://github.com/letsrunit/letsrunit#readme",
19
+ "private": false,
20
+ "type": "module",
21
+ "main": "./dist/index.js",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "../../node_modules/.bin/tsup",
32
+ "test": "vitest run",
33
+ "test:cov": "vitest run --coverage",
34
+ "typecheck": "tsc--noEmit"
35
+ },
36
+ "packageManager": "yarn@4.10.3",
37
+ "dependencies": {
38
+ "@letsrunit/utils": "workspace:*",
39
+ "graphql": "^16.12.0",
40
+ "graphql-request": "^7.4.0"
41
+ },
42
+ "devDependencies": {
43
+ "vitest": "^4.0.17"
44
+ },
45
+ "module": "./dist/index.js",
46
+ "types": "./dist/index.d.ts",
47
+ "exports": {
48
+ ".": {
49
+ "types": "./dist/index.d.ts",
50
+ "import": "./dist/index.js"
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,14 @@
1
+ const testValue = (value: string) => process.env.NODE_ENV === 'test' ? value : null;
2
+
3
+ export const TESTMAIL_DOMAIN = 'inbox.testmail.app';
4
+ export const TESTMAIL_API_KEY = process.env.TESTMAIL_API_KEY || testValue('test_key');
5
+ export const TESTMAIL_NAMESPACE = process.env.TESTMAIL_NAMESPACE || testValue('test_ns');
6
+ export const TESTMAIL_GRAPHQL_URL = 'https://api.testmail.app/api/graphql';
7
+
8
+ export const MAILHOG_BASE_URL = process.env.MAILHOG_BASE_URL || 'http://localhost:8025';
9
+
10
+ export const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL || 'http://localhost:8025';
11
+
12
+ export const MAILBOX_SERVICE = process.env.MAILBOX_SERVICE || 'mailpit';
13
+ export const MAILBOX_DOMAIN = process.env.MAILBOX_DOMAIN
14
+ || (MAILBOX_SERVICE === 'testmail' ? TESTMAIL_DOMAIN : 'example.com');
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './mailbox';
2
+ export * from './receive';
3
+ export * from './serialize';
package/src/mailbox.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { UUID } from '@letsrunit/utils';
2
+ import { clean, uuidToTag } from '@letsrunit/utils';
3
+ import { MAILBOX_DOMAIN, TESTMAIL_DOMAIN, TESTMAIL_NAMESPACE } from './constants';
4
+
5
+ export function getMailbox(seed: UUID, name?: string, domain?: string) {
6
+ domain ??= MAILBOX_DOMAIN;
7
+
8
+ const ns = domain === TESTMAIL_DOMAIN ? TESTMAIL_NAMESPACE : null;
9
+ const local = clean([ns, uuidToTag(seed), name]).join('.');
10
+
11
+ return `${local}@${domain}`;
12
+ }
@@ -0,0 +1,101 @@
1
+ import { sleep } from '@letsrunit/utils';
2
+ import { MAILHOG_BASE_URL } from '../constants';
3
+ import type { Email, ReceiveOptions } from '../types';
4
+
5
+ async function fetchOnce(emailAddress: string, signal: AbortSignal): Promise<any[]> {
6
+ const url = `${MAILHOG_BASE_URL.replace(/\/$/, '')}/api/v2/search?kind=to&query=${encodeURIComponent(emailAddress)}`;
7
+ const res = await fetch(url, { signal });
8
+ if (!res.ok) {
9
+ const text = await res.text();
10
+ throw new Error(`Failed to fetch response from mailhog: ${res.status} ${text}`);
11
+ }
12
+ const body = await res.json();
13
+ return body?.items ?? body?.Items ?? [];
14
+ }
15
+
16
+ function getHeaderValue(headers: Record<string, string[] | string> | undefined, name: string): string | undefined {
17
+ if (!headers) return undefined;
18
+ const v = headers[name] ?? headers[name.toLowerCase() as keyof typeof headers];
19
+ if (!v) return undefined;
20
+ return Array.isArray(v) ? v[0] : v;
21
+ }
22
+
23
+ function mapItemsToEmails(items: any[]): Email[] {
24
+ return items.map((item) => {
25
+ // timestamp
26
+ const created = item.Created || item.created || item.Time;
27
+ const timestamp = typeof created === 'number' ? created : Date.parse(created);
28
+
29
+ // headers
30
+ const headers: Record<string, string[] | string> | undefined = item.Content?.Headers || item.Content?.headers;
31
+
32
+ // bodies from parts
33
+ const parts: any[] = item.MIME?.Parts || item.MIME?.parts || [];
34
+ const htmlPart = parts.find((p) => (p.ContentType || p.contentType || '').startsWith('text/html'));
35
+ const textPart = parts.find((p) => (p.ContentType || p.contentType || '').startsWith('text/plain'));
36
+
37
+ const html = htmlPart?.Body || htmlPart?.body || undefined;
38
+ const text = (textPart?.Body || textPart?.body || item.Content?.Body || item.Content?.body || '').toString();
39
+
40
+ // basic fields
41
+ const subject = getHeaderValue(headers, 'Subject') || '';
42
+ const from = getHeaderValue(headers, 'From') || '';
43
+ const to = getHeaderValue(headers, 'To') || '';
44
+ const cc = getHeaderValue(headers, 'Cc') || undefined;
45
+
46
+ // attachments
47
+ const attachments = parts
48
+ .filter((p) => {
49
+ const ct = (p.ContentType || p.contentType || '').toString();
50
+ const filename = p.FileName || p.Filename || p.filename;
51
+ if (filename) return true;
52
+ return ct && !ct.startsWith('text/');
53
+ })
54
+ .map((p) => ({
55
+ filename: p.FileName || p.Filename || p.filename || 'attachment',
56
+ contentType: p.ContentType || p.contentType || 'application/octet-stream',
57
+ }));
58
+
59
+ const email: Email = {
60
+ timestamp,
61
+ from,
62
+ to,
63
+ cc,
64
+ subject,
65
+ html,
66
+ text,
67
+ attachments: attachments.length ? attachments : undefined,
68
+ };
69
+
70
+ return email;
71
+ });
72
+ }
73
+
74
+ export async function receiveMail(emailAddress: string, options: ReceiveOptions = {}): Promise<Email[]> {
75
+ const deadline = Date.now() + (options.timeout || (options.wait ? 120_000 : 5_000));
76
+ const pollInterval = 1_000;
77
+ const signal: AbortSignal = options.signal ?? AbortSignal.timeout(Math.max(0, deadline - Date.now()));
78
+
79
+ while (!signal.aborted) {
80
+ try {
81
+ const items = await fetchOnce(emailAddress, signal);
82
+ let emails = mapItemsToEmails(items);
83
+
84
+ if (options.after) emails = emails.filter((e) => e.timestamp > options.after!);
85
+ if (options.subject) emails = emails.filter((e) => e.subject.includes(options.subject!));
86
+ if (options.limit && options.limit > 0) emails = emails.slice(0, options.limit);
87
+
88
+ if (emails.length > 0) {
89
+ return emails;
90
+ }
91
+ } catch (e) {
92
+ // bubble up fetch errors except when aborted due to signal; then just end loop
93
+ if (!signal.aborted) throw e;
94
+ }
95
+
96
+ if (!options.wait) break;
97
+ await sleep(pollInterval, { signal });
98
+ }
99
+
100
+ return [];
101
+ }
@@ -0,0 +1,119 @@
1
+ import { sleep } from '@letsrunit/utils';
2
+ import { MAILPIT_BASE_URL } from '../constants';
3
+ import type { Email, ReceiveOptions } from '../types';
4
+
5
+ function buildSearchQuery(emailAddress: string, options: ReceiveOptions): string {
6
+ const terms: string[] = [`to:${emailAddress}`];
7
+ if (options.subject) {
8
+ const escaped = options.subject.replace(/"/g, '\\"');
9
+ terms.push(`subject:"${escaped}"`);
10
+ }
11
+ if (options.after) {
12
+ const iso = new Date(options.after).toISOString();
13
+ terms.push(`after:${iso}`);
14
+ }
15
+ return terms.join(' ');
16
+ }
17
+
18
+ async function search(emailAddress: string, options: ReceiveOptions, signal: AbortSignal): Promise<any[]> {
19
+ const base = MAILPIT_BASE_URL.replace(/\/$/, '');
20
+ const query = buildSearchQuery(emailAddress, options);
21
+ const limitParam = options.limit && options.limit > 0 ? `&limit=${encodeURIComponent(String(options.limit))}` : '';
22
+ const url = `${base}/api/v1/search?query=${encodeURIComponent(query)}${limitParam}`;
23
+ const res = await fetch(url, { signal });
24
+ if (!res.ok) {
25
+ const text = await res.text();
26
+ throw new Error(`Failed to fetch response from mailpit: ${res.status} ${text}`);
27
+ }
28
+ const body = await res.json();
29
+ return body?.messages ?? [];
30
+ }
31
+
32
+ async function fetchFullMessage(
33
+ id: string,
34
+ signal: AbortSignal,
35
+ ): Promise<{
36
+ Html?: string;
37
+ Text?: string;
38
+ Attachments?: any[];
39
+ Created?: string | number;
40
+ Subject?: string;
41
+ From?: any;
42
+ To?: any[];
43
+ Cc?: any[];
44
+ } | null> {
45
+ const base = MAILPIT_BASE_URL.replace(/\/$/, '');
46
+ const url = `${base}/api/v1/message/${encodeURIComponent(id)}`;
47
+ const res = await fetch(url, { signal });
48
+ if (!res.ok) return null;
49
+ try {
50
+ return await res.json();
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function pickAddress(obj: any): string {
57
+ if (!obj) return '';
58
+ if (typeof obj === 'string') return obj;
59
+ const name = obj.Name;
60
+ const addr = obj.Address || '';
61
+ return name ? `${name} <${addr}>` : addr;
62
+ }
63
+
64
+ function joinAddresses(list?: any[]): string {
65
+ if (!Array.isArray(list) || list.length === 0) return '';
66
+ return list.map(pickAddress).filter(Boolean).join(', ');
67
+ }
68
+
69
+ function mapMessageToEmail(m: any): Email {
70
+ const attachments = Array.isArray(m.Attachments)
71
+ ? m.Attachments.map((a: any) => ({
72
+ filename: a.FileName,
73
+ contentType: a.ContentType,
74
+ }))
75
+ : undefined;
76
+
77
+ return {
78
+ timestamp: Date.parse(m.Created ?? m.Date),
79
+ from: pickAddress(m.From),
80
+ to: joinAddresses(m.To),
81
+ cc: m.Cc && joinAddresses(m.Cc),
82
+ subject: m.Subject,
83
+ html: m.HTML,
84
+ text: m.Text,
85
+ attachments,
86
+ };
87
+ }
88
+
89
+ async function fetchFullEmails(messages: any[], signal: AbortSignal): Promise<Email[]> {
90
+ const ids: string[] = messages.map((m: any) => m.ID).filter(Boolean);
91
+ const details = await Promise.all(ids.map((id) => fetchFullMessage(id, signal)));
92
+ return details.filter(Boolean).map((d) => mapMessageToEmail(d));
93
+ }
94
+
95
+ export async function receiveMail(emailAddress: string, options: ReceiveOptions = {}): Promise<Email[]> {
96
+ const pollInterval = 1_000;
97
+ const timeout = options.timeout || (options.wait ? 60_000 : 5_000);
98
+ const signal: AbortSignal = options.signal ?? AbortSignal.timeout(timeout);
99
+
100
+ while (!signal.aborted) {
101
+ try {
102
+ const messages = await search(emailAddress, options, signal);
103
+ const emails = options.full
104
+ ? await fetchFullEmails(messages, signal)
105
+ : messages.map((m) => mapMessageToEmail(m));
106
+
107
+ if (emails.length > 0) {
108
+ return emails;
109
+ }
110
+ } catch (e) {
111
+ if (!signal.aborted) throw e;
112
+ }
113
+
114
+ if (!options.wait) break;
115
+ await sleep(pollInterval, { signal });
116
+ }
117
+
118
+ return [];
119
+ }
package/src/receive.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { MAILBOX_SERVICE } from './constants';
2
+ import { receiveMail as mailhogReceive } from './mailhog/receive';
3
+ import { receiveMail as mailpitReceive } from './mailpit/receive';
4
+ import { receiveMail as testmailReceive } from './testmail/receive';
5
+ import type { Email, ReceiveOptions } from './types';
6
+
7
+ export async function receiveMail(emailAddress: string, options: ReceiveOptions = {}): Promise<Email[]> {
8
+ switch (MAILBOX_SERVICE) {
9
+ case 'testmail':
10
+ return await testmailReceive(emailAddress, options);
11
+ case 'mailhog':
12
+ return await mailhogReceive(emailAddress, options);
13
+ case 'mailpit':
14
+ return await mailpitReceive(emailAddress, options);
15
+ default:
16
+ throw new Error(`Unsupported mailbox service ${MAILBOX_SERVICE}`);
17
+ }
18
+ }
@@ -0,0 +1,259 @@
1
+ import type { Email } from './types';
2
+
3
+ export function toEml(email: Email): string {
4
+ const lines: string[] = [];
5
+ const crlf = (s: string) => s.replace(/\n/g, '\r\n');
6
+
7
+ const date = new Date(email.timestamp);
8
+ // Use RFC 2822 format via toUTCString
9
+ lines.push(`Date: ${date.toUTCString()}`);
10
+ lines.push(`From: ${email.from}`);
11
+ lines.push(`To: ${email.to}`);
12
+ if (email.cc) lines.push(`Cc: ${email.cc}`);
13
+ lines.push(`Subject: ${email.subject}`);
14
+ lines.push('MIME-Version: 1.0');
15
+
16
+ const hasText = typeof email.text === 'string' && email.text.length > 0;
17
+ const hasHtml = typeof email.html === 'string' && email.html.length > 0;
18
+ const hasAttachments = Array.isArray(email.attachments) && email.attachments.length > 0;
19
+
20
+ const boundary = `===============lr_${Math.random().toString(36).slice(2)}_${Date.now()}==`;
21
+ const altBoundary = `===============lr_alt_${Math.random().toString(36).slice(2)}_${Date.now()}==`;
22
+
23
+ function pushTextPart() {
24
+ lines.push(`Content-Type: text/plain; charset=utf-8`);
25
+ lines.push('Content-Transfer-Encoding: 7bit');
26
+ lines.push('');
27
+ lines.push(crlf(hasText ? email.text! : ''));
28
+ }
29
+
30
+ function pushHtmlPart() {
31
+ lines.push(`Content-Type: text/html; charset=utf-8`);
32
+ lines.push('Content-Transfer-Encoding: 7bit');
33
+ lines.push('');
34
+ lines.push(crlf(hasHtml ? email.html! : ''));
35
+ }
36
+
37
+ // Build structure
38
+ if (hasAttachments) {
39
+ // multipart/mixed enclosing either single/alternative plus attachments
40
+ lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
41
+ lines.push('');
42
+ lines.push(`--${boundary}`);
43
+ if (hasText && hasHtml) {
44
+ // nested multipart/alternative
45
+ lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
46
+ lines.push('');
47
+ // text part
48
+ lines.push(`--${altBoundary}`);
49
+ pushTextPart();
50
+ // html part
51
+ lines.push(`--${altBoundary}`);
52
+ pushHtmlPart();
53
+ // end alternative
54
+ lines.push(`--${altBoundary}--`);
55
+ } else if (hasText) {
56
+ pushTextPart();
57
+ } else {
58
+ pushHtmlPart();
59
+ }
60
+
61
+ // attachments (metadata only; empty bodies)
62
+ for (const a of email.attachments || []) {
63
+ lines.push(`--${boundary}`);
64
+ const disp = `attachment; filename="${a.filename}"`;
65
+ lines.push(`Content-Type: ${a.contentType}; name="${a.filename}"`);
66
+ lines.push(`Content-Disposition: ${disp}`);
67
+ lines.push('Content-Transfer-Encoding: base64');
68
+ lines.push('');
69
+ // No content stored in type, emit empty body to keep structure valid
70
+ lines.push('');
71
+ }
72
+ lines.push(`--${boundary}--`);
73
+ } else if (hasText && hasHtml) {
74
+ // multipart/alternative
75
+ lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
76
+ lines.push('');
77
+ lines.push(`--${altBoundary}`);
78
+ pushTextPart();
79
+ lines.push(`--${altBoundary}`);
80
+ pushHtmlPart();
81
+ lines.push(`--${altBoundary}--`);
82
+ } else if (hasText) {
83
+ pushTextPart();
84
+ } else {
85
+ pushHtmlPart();
86
+ }
87
+
88
+ // Ensure CRLF endings
89
+ return lines.join('\r\n');
90
+ }
91
+
92
+ export function fromEml(contents: string): Email {
93
+ // Normalize line endings to \n for parsing
94
+ const raw = contents.replace(/\r\n/g, '\n');
95
+ const [rawHeader, ...rest] = raw.split(/\n\n/);
96
+ const headerLines = rawHeader.split('\n');
97
+ // Handle folded headers (lines starting with space or tab)
98
+ const unfolded: string[] = [];
99
+ for (const line of headerLines) {
100
+ if (/^[ \t]/.test(line) && unfolded.length > 0) {
101
+ unfolded[unfolded.length - 1] += line.replace(/^\s+/, ' ');
102
+ } else {
103
+ unfolded.push(line);
104
+ }
105
+ }
106
+ const headers = new Map<string, string>();
107
+ for (const l of unfolded) {
108
+ const idx = l.indexOf(':');
109
+ if (idx === -1) continue;
110
+ const key = l.slice(0, idx).trim().toLowerCase();
111
+ const val = l.slice(idx + 1).trim();
112
+ headers.set(key, val);
113
+ }
114
+ const body = rest.join('\n\n');
115
+
116
+ const email: Email = {
117
+ timestamp: Date.parse(headers.get('date') || new Date().toUTCString()),
118
+ from: headers.get('from') || '',
119
+ to: headers.get('to') || '',
120
+ cc: headers.get('cc') || undefined,
121
+ subject: headers.get('subject') || '',
122
+ } as Email;
123
+
124
+ // Parse body depending on content-type
125
+ const ct = (headers.get('content-type') || '').toLowerCase();
126
+
127
+ function parseParts(contentType: string, data: string): { text?: string; html?: string; attachments?: { filename: string; contentType: string }[] } {
128
+ const result: { text?: string; html?: string; attachments?: { filename: string; contentType: string }[] } = { attachments: [] };
129
+ if (contentType.startsWith('multipart/')) {
130
+ const m = contentType.match(/boundary="?([^";]+)"?/);
131
+ const boundary = m ? m[1] : '';
132
+ if (!boundary) return result;
133
+ // Line-oriented multipart parsing
134
+ const lines = data.split('\n');
135
+ let i = 0;
136
+ while (i < lines.length) {
137
+ const line = lines[i];
138
+ if (line === `--${boundary}`) {
139
+ // Parse headers
140
+ i++;
141
+ const pHeaders = new Map<string, string>();
142
+ const headAccum: string[] = [];
143
+ for (; i < lines.length; i++) {
144
+ const l = lines[i];
145
+ if (l === '') break;
146
+ headAccum.push(l);
147
+ }
148
+ // skip empty line
149
+ if (i < lines.length && lines[i] === '') i++;
150
+ // unfold and map headers
151
+ const unfolded: string[] = [];
152
+ for (const h of headAccum) {
153
+ if ((h.startsWith(' ') || h.startsWith('\t')) && unfolded.length) {
154
+ unfolded[unfolded.length - 1] += h.replace(/^\s+/, ' ');
155
+ } else {
156
+ unfolded.push(h);
157
+ }
158
+ }
159
+ for (const h of unfolded) {
160
+ const idx = h.indexOf(':');
161
+ if (idx !== -1) pHeaders.set(h.slice(0, idx).trim().toLowerCase(), h.slice(idx + 1).trim());
162
+ }
163
+ // Collect body until next boundary marker
164
+ const bodyLines: string[] = [];
165
+ for (; i < lines.length; i++) {
166
+ const l = lines[i];
167
+ if (l === `--${boundary}` || l === `--${boundary}--`) break;
168
+ bodyLines.push(l);
169
+ }
170
+ const pBody = bodyLines.join('\n');
171
+
172
+ const pct = (pHeaders.get('content-type') || '').toLowerCase();
173
+ const disp = (pHeaders.get('content-disposition') || '').toLowerCase();
174
+ if (pct.startsWith('multipart/')) {
175
+ const nested = parseParts(pct, pBody);
176
+ if (nested.text && !result.text) result.text = nested.text;
177
+ if (nested.html && !result.html) result.html = nested.html;
178
+ if (nested.attachments && nested.attachments.length) result.attachments!.push(...nested.attachments);
179
+ } else if (pct.startsWith('text/plain')) {
180
+ result.text = pBody;
181
+ } else if (pct.startsWith('text/html')) {
182
+ result.html = pBody;
183
+ } else if (disp.startsWith('attachment') || disp.includes('filename=')) {
184
+ let filename = '';
185
+ const fnm = disp.match(/filename="?([^";]+)"?/);
186
+ if (fnm) filename = fnm[1];
187
+ if (!filename) {
188
+ const nm = pct.match(/name="?([^";]+)"?/);
189
+ if (nm) filename = nm[1];
190
+ }
191
+ const contentTypeHeader = pHeaders.get('content-type') || 'application/octet-stream';
192
+ result.attachments!.push({ filename, contentType: contentTypeHeader.split(';')[0] });
193
+ }
194
+
195
+ // If current line is closing boundary, advance past it and stop
196
+ if (i < lines.length && lines[i] === `--${boundary}--`) {
197
+ break;
198
+ }
199
+ // continue to next line (which is next boundary or end)
200
+ } else if (line === `--${boundary}--`) {
201
+ break;
202
+ } else {
203
+ i++;
204
+ }
205
+ }
206
+ // Fallback pass: ensure we didn't miss any attachment headers
207
+ const segs = (`\n${data}`).split(`\n--${boundary}`);
208
+ const have = new Set(result.attachments!.map((a) => `${a.filename}|${a.contentType}`));
209
+ for (let seg of segs) {
210
+ seg = seg.replace(/^\n/, '');
211
+ if (!seg || seg.startsWith('--')) continue;
212
+ const [head] = seg.split(/\n\n/);
213
+ const disp = (head.match(/(^|\n)content-disposition:\s*([^\n]+)/i)?.[2] || '').toLowerCase();
214
+ if (!disp.includes('attachment')) continue;
215
+ let filename = '';
216
+ const fnm = disp.match(/filename="?([^";]+)"?/);
217
+ if (fnm) filename = fnm[1];
218
+ const ct = (head.match(/(^|\n)content-type:\s*([^\n]+)/i)?.[2] || 'application/octet-stream').split(';')[0];
219
+ const key = `${filename}|${ct}`;
220
+ if (!have.has(key)) {
221
+ have.add(key);
222
+ result.attachments!.push({ filename, contentType: ct });
223
+ }
224
+ }
225
+ // Order attachments by first appearance in the original data
226
+ if (result.attachments && result.attachments.length > 1) {
227
+ const positions = new Map<string, number>();
228
+ for (const a of result.attachments) {
229
+ const key = `${a.filename}|${a.contentType}`;
230
+ if (!positions.has(key)) {
231
+ const re = new RegExp(`filename=\"?${a.filename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\"?`, 'i');
232
+ const idx = data.search(re);
233
+ positions.set(key, idx === -1 ? Number.MAX_SAFE_INTEGER : idx);
234
+ }
235
+ }
236
+ result.attachments.sort((a, b) => {
237
+ const ka = `${a.filename}|${a.contentType}`;
238
+ const kb = `${b.filename}|${b.contentType}`;
239
+ return (positions.get(ka) ?? 0) - (positions.get(kb) ?? 0);
240
+ });
241
+ }
242
+ return result;
243
+ }
244
+ // single part
245
+ if (contentType.startsWith('text/plain')) {
246
+ result.text = data;
247
+ } else if (contentType.startsWith('text/html')) {
248
+ result.html = data;
249
+ }
250
+ return result;
251
+ }
252
+
253
+ const parsed = parseParts(ct, body);
254
+ if (parsed.text !== undefined) email.text = parsed.text;
255
+ if (parsed.html !== undefined) email.html = parsed.html;
256
+ if (parsed.attachments && parsed.attachments.length) email.attachments = parsed.attachments;
257
+
258
+ return email;
259
+ }
@@ -0,0 +1,60 @@
1
+ import { clean } from '@letsrunit/utils';
2
+ import { GraphQLClient } from 'graphql-request';
3
+ import { TESTMAIL_API_KEY, TESTMAIL_GRAPHQL_URL } from '../constants';
4
+ import type { Email, ReceiveOptions } from '../types';
5
+
6
+ export async function receiveMail(emailAddress: string, options: ReceiveOptions = {}): Promise<Email[]> {
7
+ if (!TESTMAIL_API_KEY) throw new Error('TESTMAIL_API_KEY environment var not set');
8
+
9
+ const match = emailAddress.match(/^(?<namespace>[^.@]+)\.(?<tag>[^@]+)@/);
10
+ if (!match) throw new Error('Email address is not a valid testmail address');
11
+
12
+ const namespace = match.groups!.namespace;
13
+ const tag = match.groups!.tag;
14
+
15
+ const signal = options.signal ?? AbortSignal.timeout(options.timeout || (options.wait ? 120_000 : 5000));
16
+
17
+ const client = new GraphQLClient(TESTMAIL_GRAPHQL_URL, {
18
+ headers: { apikey: TESTMAIL_API_KEY },
19
+ fetch: (input, init = {}) => {
20
+ return fetch(input as RequestInfo, { ...init, signal });
21
+ },
22
+ });
23
+
24
+ const fields = ['timestamp', 'from', 'to', 'cc', 'subject'];
25
+ if (options.full) fields.push('html', 'text', 'attachments { filename contentType }');
26
+
27
+ const query = `
28
+ query Inbox($namespace: String!, $tag: String!, $timestampFrom: Long, $subject: String, $limit: Int) {
29
+ inbox(
30
+ namespace: $namespace
31
+ tag: $tag
32
+ ${options.wait ? 'livequery: true' : ''}
33
+ ${options.after ? 'timestamp_from: $timestampFrom' : ''}
34
+ ${options.subject ? `advanced_filters: [{ field: subject, match: exact, action: include, value: $subject }]` : ''}
35
+ ${options.limit ? 'limit: $limit' : ''}
36
+ advanced_sorts: [{ field: timestamp, order: desc }]
37
+ ) {
38
+ emails {
39
+ ${fields.join('\n ')}
40
+ }
41
+ }
42
+ }
43
+ `;
44
+
45
+ const variables: Record<string, any> = clean({
46
+ namespace,
47
+ tag,
48
+ timestampFrom: options.after,
49
+ subject: options.subject,
50
+ limit: options.limit,
51
+ });
52
+
53
+ try {
54
+ const data: any = await client.request(query, variables);
55
+ return data?.inbox?.emails || [];
56
+ } catch (err: any) {
57
+ const message = err?.response?.errors?.[0]?.message || err?.message || 'Unknown error';
58
+ throw new Error(`Failed to fetch response from testmail: ${message}`);
59
+ }
60
+ }
package/src/types.ts ADDED
@@ -0,0 +1,25 @@
1
+ export interface ReceiveOptions {
2
+ wait?: boolean;
3
+ timeout?: number;
4
+ signal?: AbortSignal;
5
+ after?: number;
6
+ subject?: string;
7
+ limit?: number;
8
+ full?: boolean;
9
+ }
10
+
11
+ export interface Email {
12
+ timestamp: number;
13
+ from: string;
14
+ to: string;
15
+ cc?: string;
16
+ subject: string;
17
+ html?: string;
18
+ text?: string;
19
+ attachments?: Attachment[];
20
+ }
21
+
22
+ export interface Attachment {
23
+ filename: string;
24
+ contentType: string;
25
+ }