@groundmail/groundhog 0.0.0-stage → 0.1.1

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -2
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +130 -0
  5. package/dist/client.d.ts +55 -0
  6. package/dist/client.js +90 -0
  7. package/dist/extract.d.ts +22 -0
  8. package/dist/extract.js +116 -0
  9. package/dist/faults.d.ts +24 -0
  10. package/dist/faults.js +64 -0
  11. package/dist/groundmail-account.d.ts +50 -0
  12. package/dist/groundmail-account.js +233 -0
  13. package/dist/http.d.ts +58 -0
  14. package/dist/http.js +152 -0
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.js +2 -0
  17. package/dist/mcp.d.ts +11 -0
  18. package/dist/mcp.js +156 -0
  19. package/dist/mime.d.ts +52 -0
  20. package/dist/mime.js +182 -0
  21. package/dist/providers/common.d.ts +14 -0
  22. package/dist/providers/common.js +68 -0
  23. package/dist/providers/groundmail.d.ts +31 -0
  24. package/dist/providers/groundmail.js +169 -0
  25. package/dist/providers/index.d.ts +12 -0
  26. package/dist/providers/index.js +10 -0
  27. package/dist/providers/mailgun.d.ts +3 -0
  28. package/dist/providers/mailgun.js +150 -0
  29. package/dist/providers/postmark.d.ts +3 -0
  30. package/dist/providers/postmark.js +147 -0
  31. package/dist/providers/resend.d.ts +8 -0
  32. package/dist/providers/resend.js +121 -0
  33. package/dist/providers/sendgrid.d.ts +3 -0
  34. package/dist/providers/sendgrid.js +128 -0
  35. package/dist/providers/ses.d.ts +3 -0
  36. package/dist/providers/ses.js +244 -0
  37. package/dist/providers/types.d.ts +35 -0
  38. package/dist/providers/types.js +1 -0
  39. package/dist/server.d.ts +38 -0
  40. package/dist/server.js +450 -0
  41. package/dist/signing.d.ts +25 -0
  42. package/dist/signing.js +47 -0
  43. package/dist/smtp.d.ts +13 -0
  44. package/dist/smtp.js +70 -0
  45. package/dist/store.d.ts +35 -0
  46. package/dist/store.js +171 -0
  47. package/dist/types.d.ts +48 -0
  48. package/dist/types.js +12 -0
  49. package/dist/ui/app.css +214 -0
  50. package/dist/ui/app.js +621 -0
  51. package/dist/ui/favicon.svg +1 -0
  52. package/dist/ui/index.html +108 -0
  53. package/dist/webhooks.d.ts +72 -0
  54. package/dist/webhooks.js +102 -0
  55. package/package.json +88 -4
package/dist/http.js ADDED
@@ -0,0 +1,152 @@
1
+ import busboy from 'busboy';
2
+ export const MAX_BODY_BYTES = 60 * 1024 * 1024;
3
+ export class HttpError extends Error {
4
+ status;
5
+ constructor(status, message) {
6
+ super(message);
7
+ this.status = status;
8
+ }
9
+ }
10
+ export class RequestContext {
11
+ req;
12
+ res;
13
+ url;
14
+ params = {};
15
+ bodyPromise = null;
16
+ constructor(req, res) {
17
+ this.req = req;
18
+ this.res = res;
19
+ this.url = new URL(req.url ?? '/', 'http://localhost');
20
+ }
21
+ get method() {
22
+ return (this.req.method ?? 'GET').toUpperCase();
23
+ }
24
+ header(name) {
25
+ const value = this.req.headers[name.toLowerCase()];
26
+ return Array.isArray(value) ? value.join(', ') : (value ?? '');
27
+ }
28
+ query(name) {
29
+ return this.url.searchParams.get(name) ?? undefined;
30
+ }
31
+ body() {
32
+ if (!this.bodyPromise) {
33
+ this.bodyPromise = new Promise((resolve, reject) => {
34
+ const chunks = [];
35
+ let size = 0;
36
+ this.req.on('data', (chunk) => {
37
+ size += chunk.length;
38
+ if (size > MAX_BODY_BYTES) {
39
+ reject(new HttpError(413, 'Request body is too large'));
40
+ this.req.destroy();
41
+ return;
42
+ }
43
+ chunks.push(chunk);
44
+ });
45
+ this.req.on('end', () => resolve(Buffer.concat(chunks)));
46
+ this.req.on('error', reject);
47
+ });
48
+ }
49
+ return this.bodyPromise;
50
+ }
51
+ async json() {
52
+ const raw = await this.body();
53
+ if (!raw.length)
54
+ return {};
55
+ try {
56
+ return JSON.parse(raw.toString('utf8'));
57
+ }
58
+ catch {
59
+ throw new HttpError(400, 'The request body is not valid JSON');
60
+ }
61
+ }
62
+ /** application/x-www-form-urlencoded, as ordered pairs (keys repeat). */
63
+ async formPairs() {
64
+ const raw = await this.body();
65
+ return [...new URLSearchParams(raw.toString('utf8')).entries()];
66
+ }
67
+ async multipart() {
68
+ const contentType = this.header('content-type');
69
+ if (!contentType.toLowerCase().startsWith('multipart/')) {
70
+ return { fields: await this.formPairs(), files: [] };
71
+ }
72
+ const raw = await this.body();
73
+ return new Promise((resolve, reject) => {
74
+ const form = { fields: [], files: [] };
75
+ const parser = busboy({ headers: { 'content-type': contentType }, limits: { fileSize: MAX_BODY_BYTES, fieldSize: MAX_BODY_BYTES } });
76
+ parser.on('field', (name, value) => form.fields.push([name, value]));
77
+ parser.on('file', (name, stream, info) => {
78
+ const chunks = [];
79
+ stream.on('data', (chunk) => chunks.push(chunk));
80
+ stream.on('end', () => form.files.push({ field: name, filename: info.filename || 'attachment', contentType: info.mimeType || 'application/octet-stream', content: Buffer.concat(chunks) }));
81
+ });
82
+ parser.on('close', () => resolve(form));
83
+ parser.on('error', reject);
84
+ parser.end(raw);
85
+ });
86
+ }
87
+ send(result) {
88
+ if (this.res.headersSent)
89
+ return;
90
+ const headers = { ...(result.headers ?? {}) };
91
+ let payload = '';
92
+ if (Buffer.isBuffer(result.body))
93
+ payload = result.body;
94
+ else if (typeof result.body === 'string')
95
+ payload = result.body;
96
+ else if (result.body !== undefined) {
97
+ payload = JSON.stringify(result.body);
98
+ if (!Object.keys(headers).some((key) => key.toLowerCase() === 'content-type'))
99
+ headers['Content-Type'] = 'application/json; charset=utf-8';
100
+ }
101
+ this.res.writeHead(result.status, headers);
102
+ this.res.end(payload);
103
+ }
104
+ }
105
+ export function compileRoute(path) {
106
+ const pattern = path
107
+ .split('/')
108
+ .map((segment) => {
109
+ if (segment === '*')
110
+ return '(?<rest>.*)';
111
+ if (segment.startsWith(':'))
112
+ return `(?<${segment.slice(1)}>[^/]+)`;
113
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
114
+ })
115
+ .join('/');
116
+ return new RegExp(`^${pattern}/?$`);
117
+ }
118
+ export class Router {
119
+ routes = [];
120
+ add(method, path, handler) {
121
+ this.routes.push({ method: method.toUpperCase(), path, handler, regex: compileRoute(path) });
122
+ return this;
123
+ }
124
+ get(path, handler) {
125
+ return this.add('GET', path, handler);
126
+ }
127
+ post(path, handler) {
128
+ return this.add('POST', path, handler);
129
+ }
130
+ put(path, handler) {
131
+ return this.add('PUT', path, handler);
132
+ }
133
+ delete(path, handler) {
134
+ return this.add('DELETE', path, handler);
135
+ }
136
+ match(method, pathname) {
137
+ let pathMatched = false;
138
+ for (const route of this.routes) {
139
+ const match = route.regex.exec(pathname);
140
+ if (!match)
141
+ continue;
142
+ pathMatched = true;
143
+ if (route.method !== method && !(route.method === 'GET' && method === 'HEAD'))
144
+ continue;
145
+ const params = {};
146
+ for (const [key, value] of Object.entries(match.groups ?? {}))
147
+ params[key] = decodeURIComponent(value ?? '');
148
+ return { route, params };
149
+ }
150
+ return pathMatched ? 'method' : null;
151
+ }
152
+ }
@@ -0,0 +1,3 @@
1
+ export { clearEmails, clearFaults, extractCode, extractLink, extractLinks, fireWebhook, getEmail, listEmails, simulateFault, waitForEmail, type ClientOptions, type EmailQuery, type WaitOptions, } from './client.js';
2
+ export { startGroundhog, VERSION, type Groundhog, type GroundhogOptions } from './server.js';
3
+ export type { Address, AttachmentInfo, Email, FaultKind, Provider, WebhookEvent } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { clearEmails, clearFaults, extractCode, extractLink, extractLinks, fireWebhook, getEmail, listEmails, simulateFault, waitForEmail, } from './client.js';
2
+ export { startGroundhog, VERSION } from './server.js';
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `groundhog mcp`: an MCP server (stdio) so coding agents can read the emails their
3
+ * app sends: finish a sign-up with the code, follow a reset link, test failures.
4
+ * Uses the Groundhog at GROUNDHOG_URL (default http://localhost:3025), and starts
5
+ * one in this process when none is running.
6
+ */
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ export declare function createMcpServer(baseUrl: string): McpServer;
9
+ export declare function runMcp(options?: {
10
+ baseUrl?: string;
11
+ }): Promise<void>;
package/dist/mcp.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `groundhog mcp`: an MCP server (stdio) so coding agents can read the emails their
3
+ * app sends: finish a sign-up with the code, follow a reset link, test failures.
4
+ * Uses the Groundhog at GROUNDHOG_URL (default http://localhost:3025), and starts
5
+ * one in this process when none is running.
6
+ */
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import { z } from 'zod';
10
+ import { getEmail, listEmails, simulateFault, waitForEmail } from './client.js';
11
+ import { pickLink } from './extract.js';
12
+ import { startGroundhog, VERSION } from './server.js';
13
+ function brief(email) {
14
+ return {
15
+ id: email.id,
16
+ subject: email.subject,
17
+ from: email.from?.address ?? null,
18
+ to: email.to.map((a) => a.address),
19
+ via: email.via,
20
+ received_at: email.createdAt,
21
+ code: email.code,
22
+ links: email.links.slice(0, 10),
23
+ };
24
+ }
25
+ function text(value) {
26
+ return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
27
+ }
28
+ function failure(message) {
29
+ return { content: [{ type: 'text', text: message }], isError: true };
30
+ }
31
+ async function reachable(baseUrl) {
32
+ try {
33
+ const response = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(1500) });
34
+ const body = (await response.json());
35
+ return body.name === 'groundhog';
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ async function emailFor(baseUrl, input) {
42
+ if (input.id)
43
+ return getEmail(input.id, { baseUrl });
44
+ return waitForEmail({ baseUrl, to: input.to, subject: input.subject, timeout: Math.round((input.wait_seconds ?? 10) * 1000) });
45
+ }
46
+ export function createMcpServer(baseUrl) {
47
+ const server = new McpServer({ name: 'groundhog', version: VERSION });
48
+ const filters = {
49
+ to: z.string().optional().describe('Recipient address (or @domain) to match'),
50
+ subject: z.string().optional().describe('Text the subject contains'),
51
+ };
52
+ server.registerTool('list_emails', {
53
+ title: 'List caught emails',
54
+ description: 'Newest first. Emails the app sent to Groundhog, the local dev inbox; nothing was delivered.',
55
+ inputSchema: { ...filters, limit: z.number().int().min(1).max(100).optional().describe('Default 20') },
56
+ annotations: { readOnlyHint: true },
57
+ }, async ({ to, subject, limit }) => {
58
+ const emails = await listEmails({ baseUrl, to, subject, limit: limit ?? 20 });
59
+ return text(emails.length ? emails.map(brief) : 'The inbox is empty. Trigger the email in the app first (sign up, reset a password…).');
60
+ });
61
+ server.registerTool('wait_for_email', {
62
+ title: 'Wait for an email',
63
+ description: 'Waits until an email matching `to`/`subject` arrives (an already-caught one counts) and returns it with its code and links.',
64
+ inputSchema: { ...filters, timeout_seconds: z.number().min(1).max(300).optional().describe('Default 30') },
65
+ annotations: { readOnlyHint: true },
66
+ }, async ({ to, subject, timeout_seconds }) => {
67
+ try {
68
+ const email = await waitForEmail({ baseUrl, to, subject, timeout: Math.round((timeout_seconds ?? 30) * 1000) });
69
+ return text({ ...brief(email), text: (email.text ?? '').slice(0, 2000) });
70
+ }
71
+ catch (error) {
72
+ return failure(error.message);
73
+ }
74
+ });
75
+ server.registerTool('get_email', {
76
+ title: 'Read an email',
77
+ description: 'The full text, headers summary, code and links of one email, by id.',
78
+ inputSchema: { id: z.string().describe('Email id from list_emails') },
79
+ annotations: { readOnlyHint: true },
80
+ }, async ({ id }) => {
81
+ try {
82
+ const email = await getEmail(id, { baseUrl });
83
+ return text({ ...brief(email), cc: email.cc.map((a) => a.address), text: email.text, attachments: email.attachments.map((a) => a.filename) });
84
+ }
85
+ catch (error) {
86
+ return failure(error.message);
87
+ }
88
+ });
89
+ server.registerTool('get_code', {
90
+ title: 'Get the one-time code',
91
+ description: 'The verification / sign-in code from the latest matching email (waits briefly if it has not arrived).',
92
+ inputSchema: { ...filters, id: z.string().optional(), wait_seconds: z.number().min(0).max(300).optional().describe('Default 10') },
93
+ annotations: { readOnlyHint: true },
94
+ }, async (input) => {
95
+ try {
96
+ const email = await emailFor(baseUrl, input);
97
+ if (!email.code)
98
+ return failure(`"${email.subject}" (${email.id}) has no code in it. Its links: ${email.links.slice(0, 5).join(' ') || 'none'}`);
99
+ return text(email.code);
100
+ }
101
+ catch (error) {
102
+ return failure(error.message);
103
+ }
104
+ });
105
+ server.registerTool('get_link', {
106
+ title: 'Get a link',
107
+ description: 'A link from the latest matching email: the magic link / verify / reset link by default, or the first one containing `contains`.',
108
+ inputSchema: { ...filters, id: z.string().optional(), contains: z.string().optional().describe('Part of the URL to look for, e.g. "/verify"'), wait_seconds: z.number().min(0).max(300).optional().describe('Default 10') },
109
+ annotations: { readOnlyHint: true },
110
+ }, async (input) => {
111
+ try {
112
+ const email = await emailFor(baseUrl, input);
113
+ const link = pickLink(email.links, input.contains);
114
+ if (!link)
115
+ return failure(`No ${input.contains ? `link containing "${input.contains}"` : 'link'} in "${email.subject}" (${email.id}).`);
116
+ return text(link);
117
+ }
118
+ catch (error) {
119
+ return failure(error.message);
120
+ }
121
+ });
122
+ server.registerTool('simulate_fault', {
123
+ title: 'Make sends fail',
124
+ description: "Makes the app's next sends fail the way a real provider would (every provider API and SMTP), to test error handling. status: 429 rate limit, 422 invalid recipient, 401 bad key, 5xx outage, timeout. next: 0 turns failure mode off.",
125
+ inputSchema: {
126
+ status: z.enum(['429', '422', '401', '5xx', 'timeout']),
127
+ next: z.number().int().min(0).max(1000).optional().describe('How many sends fail. Default 1'),
128
+ },
129
+ }, async ({ status, next }) => {
130
+ try {
131
+ await simulateFault({ baseUrl, status: status === '5xx' || status === 'timeout' ? status : Number(status), next: next ?? 1 });
132
+ return text(next === 0 ? 'Failure mode is off.' : `The next ${next ?? 1} send(s) will fail with ${status}.`);
133
+ }
134
+ catch (error) {
135
+ return failure(error.message);
136
+ }
137
+ });
138
+ return server;
139
+ }
140
+ export async function runMcp(options = {}) {
141
+ let baseUrl = (options.baseUrl ?? process.env.GROUNDHOG_URL ?? 'http://localhost:3025').replace(/\/+$/, '');
142
+ if (!(await reachable(baseUrl))) {
143
+ // No Groundhog running: start one here so the agent still has an inbox.
144
+ try {
145
+ const port = Number(new URL(baseUrl).port) || 3025;
146
+ const local = await startGroundhog({ httpPort: port, smtpPort: Number(process.env.GROUNDHOG_SMTP_PORT) || 1025, log: (line) => process.stderr.write(`[groundhog] ${line}\n`) });
147
+ baseUrl = local.url;
148
+ process.stderr.write(`[groundhog] started an inbox at ${local.url} (SMTP :${local.smtpPort})\n`);
149
+ }
150
+ catch (error) {
151
+ process.stderr.write(`[groundhog] could not reach or start Groundhog at ${baseUrl}: ${error.message}\n`);
152
+ }
153
+ }
154
+ const server = createMcpServer(baseUrl);
155
+ await server.connect(new StdioServerTransport());
156
+ }
package/dist/mime.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { Address, Provider, StoredEmail } from './types.js';
2
+ export declare function newId(prefix?: string): string;
3
+ export type AddressInput = string | {
4
+ email?: string;
5
+ address?: string;
6
+ name?: string | null;
7
+ } | null | undefined;
8
+ /** "Ada <ada@example.com>", "ada@example.com", { email, name } or { address, name } → Address. */
9
+ export declare function toAddress(input: AddressInput): Address | null;
10
+ /** Accepts one address, an array, or a comma-separated string. */
11
+ export declare function toAddressList(input: unknown): Address[];
12
+ export declare function formatAddress(address: Address): string;
13
+ export type AttachmentInput = {
14
+ filename?: string;
15
+ content: Buffer | string;
16
+ contentType?: string;
17
+ cid?: string;
18
+ encoding?: 'base64' | 'utf8';
19
+ };
20
+ /** The parts an HTTP send API hands us. Every provider route maps its own body onto this. */
21
+ export type ComposeInput = {
22
+ from: Address | null;
23
+ to: Address[];
24
+ cc?: Address[];
25
+ bcc?: Address[];
26
+ replyTo?: Address[];
27
+ subject?: string;
28
+ html?: string | null;
29
+ text?: string | null;
30
+ headers?: Record<string, string>;
31
+ attachments?: AttachmentInput[];
32
+ tags?: string[];
33
+ metadata?: Record<string, string>;
34
+ };
35
+ /** Build the RFC 5322 source for an API send, so every email has a real Source tab. */
36
+ export declare function compose(input: ComposeInput, messageId: string): Promise<Buffer>;
37
+ export type IngestMeta = {
38
+ via: Provider;
39
+ providerMessageId?: string;
40
+ /** Envelope recipients (SMTP RCPT TO, or API bcc) that aren't in the headers. */
41
+ envelopeTo?: Address[];
42
+ bcc?: Address[];
43
+ tags?: string[];
44
+ metadata?: Record<string, string>;
45
+ id?: string;
46
+ createdAt?: string;
47
+ };
48
+ /** Parse raw source into the stored shape. */
49
+ export declare function parseEmail(raw: Buffer, meta: IngestMeta): Promise<StoredEmail>;
50
+ /** Compose and parse an API send in one step. */
51
+ export declare function ingestApiSend(input: ComposeInput, meta: IngestMeta): Promise<StoredEmail>;
52
+ export declare function uuid(): string;
package/dist/mime.js ADDED
@@ -0,0 +1,182 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { simpleParser } from 'mailparser';
3
+ import MailComposer from 'nodemailer/lib/mail-composer/index.js';
4
+ import { extractCode, extractLinks } from './extract.js';
5
+ export function newId(prefix = 'gh') {
6
+ return `${prefix}_${randomBytes(9).toString('base64url')}`;
7
+ }
8
+ /** "Ada <ada@example.com>", "ada@example.com", { email, name } or { address, name } → Address. */
9
+ export function toAddress(input) {
10
+ if (!input)
11
+ return null;
12
+ if (typeof input === 'string') {
13
+ const trimmed = input.trim();
14
+ if (!trimmed)
15
+ return null;
16
+ const match = trimmed.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>\s*$/);
17
+ if (match)
18
+ return { name: match[1].trim(), address: match[2].trim() };
19
+ return { name: '', address: trimmed };
20
+ }
21
+ const address = (input.email ?? input.address ?? '').trim();
22
+ if (!address)
23
+ return null;
24
+ return { name: (input.name ?? '').trim(), address };
25
+ }
26
+ /** Accepts one address, an array, or a comma-separated string. */
27
+ export function toAddressList(input) {
28
+ if (input == null || input === '')
29
+ return [];
30
+ if (Array.isArray(input))
31
+ return input.flatMap((item) => toAddressList(item));
32
+ if (typeof input === 'string') {
33
+ return splitAddresses(input)
34
+ .map((part) => toAddress(part))
35
+ .filter((a) => Boolean(a));
36
+ }
37
+ const one = toAddress(input);
38
+ return one ? [one] : [];
39
+ }
40
+ function splitAddresses(value) {
41
+ const out = [];
42
+ let current = '';
43
+ let inQuotes = false;
44
+ let inAngle = false;
45
+ for (const ch of value) {
46
+ if (ch === '"')
47
+ inQuotes = !inQuotes;
48
+ if (ch === '<')
49
+ inAngle = true;
50
+ if (ch === '>')
51
+ inAngle = false;
52
+ if ((ch === ',' || ch === ';') && !inQuotes && !inAngle) {
53
+ if (current.trim())
54
+ out.push(current.trim());
55
+ current = '';
56
+ }
57
+ else
58
+ current += ch;
59
+ }
60
+ if (current.trim())
61
+ out.push(current.trim());
62
+ return out;
63
+ }
64
+ export function formatAddress(address) {
65
+ return address.name ? `"${address.name.replace(/"/g, '')}" <${address.address}>` : address.address;
66
+ }
67
+ /** Build the RFC 5322 source for an API send, so every email has a real Source tab. */
68
+ export async function compose(input, messageId) {
69
+ const mail = {
70
+ from: input.from ? formatAddress(input.from) : undefined,
71
+ to: input.to.map(formatAddress),
72
+ cc: input.cc?.map(formatAddress),
73
+ bcc: input.bcc?.map(formatAddress),
74
+ replyTo: input.replyTo?.map(formatAddress),
75
+ subject: input.subject ?? '',
76
+ html: input.html ?? undefined,
77
+ text: input.text ?? undefined,
78
+ headers: input.headers,
79
+ messageId: `<${messageId}@groundhog.local>`,
80
+ attachments: input.attachments?.map((attachment) => ({
81
+ filename: attachment.filename,
82
+ content: attachment.content,
83
+ contentType: attachment.contentType,
84
+ cid: attachment.cid,
85
+ encoding: typeof attachment.content === 'string' ? (attachment.encoding ?? 'base64') : undefined,
86
+ })),
87
+ };
88
+ const node = new MailComposer(mail).compile();
89
+ node.keepBcc = true;
90
+ return node.build();
91
+ }
92
+ function addressList(value) {
93
+ if (!value)
94
+ return [];
95
+ const objects = Array.isArray(value) ? value : [value];
96
+ const out = [];
97
+ for (const object of objects) {
98
+ for (const entry of object.value) {
99
+ if (entry.address)
100
+ out.push({ name: entry.name ?? '', address: entry.address });
101
+ for (const member of entry.group ?? []) {
102
+ if (member.address)
103
+ out.push({ name: member.name ?? '', address: member.address });
104
+ }
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+ function headerPairs(parsed) {
110
+ return parsed.headerLines.map((line) => {
111
+ const colon = line.line.indexOf(':');
112
+ const name = colon >= 0 ? line.line.slice(0, colon) : line.key;
113
+ const value = colon >= 0 ? line.line.slice(colon + 1).replace(/\r?\n[ \t]+/g, ' ').trim() : '';
114
+ return [name, value];
115
+ });
116
+ }
117
+ /** Parse raw source into the stored shape. */
118
+ export async function parseEmail(raw, meta) {
119
+ const parsed = await simpleParser(raw, { skipImageLinks: true, skipTextToHtml: true });
120
+ const id = meta.id ?? newId();
121
+ const to = addressList(parsed.to);
122
+ const cc = addressList(parsed.cc);
123
+ let bcc = meta.bcc ?? addressList(parsed.bcc);
124
+ if (meta.envelopeTo?.length) {
125
+ const visible = new Set([...to, ...cc, ...bcc].map((a) => a.address.toLowerCase()));
126
+ bcc = [...bcc, ...meta.envelopeTo.filter((a) => !visible.has(a.address.toLowerCase()))];
127
+ }
128
+ const attachmentContent = new Map();
129
+ const attachments = parsed.attachments.map((attachment, index) => {
130
+ const attachmentId = `${index}`;
131
+ attachmentContent.set(attachmentId, attachment.content);
132
+ return {
133
+ id: attachmentId,
134
+ filename: attachment.filename ?? `attachment-${index + 1}`,
135
+ contentType: attachment.contentType,
136
+ size: attachment.size ?? attachment.content.length,
137
+ contentId: attachment.contentId?.replace(/^<|>$/g, ''),
138
+ inline: attachment.contentDisposition === 'inline' || Boolean(attachment.related),
139
+ };
140
+ });
141
+ const html = typeof parsed.html === 'string' ? parsed.html : null;
142
+ const text = parsed.text ?? null;
143
+ const subject = parsed.subject ?? '';
144
+ return {
145
+ id,
146
+ providerMessageId: meta.providerMessageId ?? parsed.messageId?.replace(/^<|>$/g, '') ?? id,
147
+ via: meta.via,
148
+ from: addressList(parsed.from)[0] ?? null,
149
+ to,
150
+ cc,
151
+ bcc,
152
+ replyTo: addressList(parsed.replyTo),
153
+ subject,
154
+ html,
155
+ text,
156
+ headers: headerPairs(parsed),
157
+ attachments,
158
+ tags: meta.tags ?? [],
159
+ metadata: meta.metadata ?? {},
160
+ createdAt: meta.createdAt ?? new Date().toISOString(),
161
+ size: raw.length,
162
+ code: extractCode({ html, text, subject }),
163
+ links: extractLinks({ html, text }),
164
+ raw,
165
+ attachmentContent,
166
+ };
167
+ }
168
+ /** Compose and parse an API send in one step. */
169
+ export async function ingestApiSend(input, meta) {
170
+ const id = meta.id ?? newId();
171
+ const raw = await compose(input, id);
172
+ return parseEmail(raw, {
173
+ ...meta,
174
+ id,
175
+ bcc: input.bcc ?? [],
176
+ tags: meta.tags ?? input.tags,
177
+ metadata: meta.metadata ?? input.metadata,
178
+ });
179
+ }
180
+ export function uuid() {
181
+ return randomUUID();
182
+ }
@@ -0,0 +1,14 @@
1
+ import type { AttachmentInput } from '../mime.js';
2
+ import type { Email } from '../types.js';
3
+ /** Base64 string, a JSON-serialized Buffer (`{ type: 'Buffer', data }`), or a byte array → Buffer. */
4
+ export declare function decodeContent(content: unknown): Buffer;
5
+ export declare function attachment(filename: unknown, content: unknown, contentType?: unknown, cid?: unknown): AttachmentInput;
6
+ export declare function str(value: unknown): string | undefined;
7
+ /** `{ "X-Foo": "bar" }` or `[{ name, value }]` / `[{ Name, Value }]` → record. */
8
+ export declare function headerRecord(input: unknown): Record<string, string> | undefined;
9
+ export declare function hex(bytes: number): string;
10
+ export declare function unixSeconds(date?: Date): number;
11
+ export declare function recipient(email: Email): string;
12
+ export declare function allRecipients(email: Email): string[];
13
+ export declare function fromString(email: Email): string;
14
+ export declare function senderDomain(email: Email): string;
@@ -0,0 +1,68 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ /** Base64 string, a JSON-serialized Buffer (`{ type: 'Buffer', data }`), or a byte array → Buffer. */
3
+ export function decodeContent(content) {
4
+ if (Buffer.isBuffer(content))
5
+ return content;
6
+ if (typeof content === 'string')
7
+ return Buffer.from(content, 'base64');
8
+ if (Array.isArray(content))
9
+ return Buffer.from(content);
10
+ if (content && typeof content === 'object' && Array.isArray(content.data)) {
11
+ return Buffer.from(content.data);
12
+ }
13
+ return Buffer.alloc(0);
14
+ }
15
+ export function attachment(filename, content, contentType, cid) {
16
+ return {
17
+ filename: typeof filename === 'string' && filename ? filename : 'attachment',
18
+ content: decodeContent(content),
19
+ contentType: typeof contentType === 'string' && contentType ? contentType : undefined,
20
+ cid: typeof cid === 'string' && cid ? cid : undefined,
21
+ };
22
+ }
23
+ export function str(value) {
24
+ if (value == null)
25
+ return undefined;
26
+ return typeof value === 'string' ? value : String(value);
27
+ }
28
+ /** `{ "X-Foo": "bar" }` or `[{ name, value }]` / `[{ Name, Value }]` → record. */
29
+ export function headerRecord(input) {
30
+ if (!input)
31
+ return undefined;
32
+ const out = {};
33
+ if (Array.isArray(input)) {
34
+ for (const item of input) {
35
+ const name = str(item.name ?? item.Name);
36
+ const value = str(item.value ?? item.Value);
37
+ if (name && value !== undefined)
38
+ out[name] = value;
39
+ }
40
+ }
41
+ else if (typeof input === 'object') {
42
+ for (const [name, value] of Object.entries(input)) {
43
+ if (value !== undefined && value !== null)
44
+ out[name] = String(value);
45
+ }
46
+ }
47
+ return Object.keys(out).length ? out : undefined;
48
+ }
49
+ export function hex(bytes) {
50
+ return randomBytes(bytes).toString('hex');
51
+ }
52
+ export function unixSeconds(date = new Date()) {
53
+ return Math.floor(date.getTime() / 1000);
54
+ }
55
+ export function recipient(email) {
56
+ return email.to[0]?.address ?? email.cc[0]?.address ?? email.bcc[0]?.address ?? 'unknown@localhost';
57
+ }
58
+ export function allRecipients(email) {
59
+ return [...email.to, ...email.cc, ...email.bcc].map((a) => a.address);
60
+ }
61
+ export function fromString(email) {
62
+ if (!email.from)
63
+ return '';
64
+ return email.from.name ? `${email.from.name} <${email.from.address}>` : email.from.address;
65
+ }
66
+ export function senderDomain(email) {
67
+ return email.from?.address.split('@')[1] ?? 'localhost';
68
+ }
@@ -0,0 +1,31 @@
1
+ import type { HttpResult } from '../http.js';
2
+ import type { Email, StoredEmail } from '../types.js';
3
+ import type { ProviderModule } from './types.js';
4
+ export declare function groundmailSendFromBody(body: Record<string, unknown>, via: 'groundmail' | 'resend'): Promise<StoredEmail | HttpResult>;
5
+ export declare function isResult(value: StoredEmail | HttpResult): value is HttpResult;
6
+ export declare function emailView(email: Email): {
7
+ id: string;
8
+ from: string;
9
+ to: string[];
10
+ cc: string[];
11
+ bcc: string[];
12
+ reply_to: string[];
13
+ subject: string;
14
+ status: string;
15
+ last_event: string;
16
+ bounce_reason: null;
17
+ bounce_type: null;
18
+ held_back: never[];
19
+ test: boolean;
20
+ tags: string[];
21
+ scheduled_at: null;
22
+ cancelled_at: null;
23
+ created_at: string;
24
+ sent_at: string;
25
+ delivered_at: string;
26
+ bounced_at: null;
27
+ opened_at: null;
28
+ clicked_at: null;
29
+ };
30
+ /** Groundmail's own API. Mounted at `/` and `/groundmail`. */
31
+ export declare const groundmail: ProviderModule;