@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/README.md +62 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +470 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
- package/src/constants.ts +14 -0
- package/src/index.ts +3 -0
- package/src/mailbox.ts +12 -0
- package/src/mailhog/receive.ts +101 -0
- package/src/mailpit/receive.ts +119 -0
- package/src/receive.ts +18 -0
- package/src/serialize.ts +259 -0
- package/src/testmail/receive.ts +60 -0
- package/src/types.ts +25 -0
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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
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
|
+
}
|
package/src/serialize.ts
ADDED
|
@@ -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
|
+
}
|