@groundmail/groundhog 0.0.0-stage → 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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +320 -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
@@ -0,0 +1,128 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { ingestApiSend, toAddress, toAddressList } from '../mime.js';
3
+ import { sendgridSignature } from '../signing.js';
4
+ import { attachment, headerRecord, recipient, str, unixSeconds } from './common.js';
5
+ const EVENT_NAMES = {
6
+ delivered: 'delivered',
7
+ bounced: 'bounce',
8
+ complained: 'spamreport',
9
+ opened: 'open',
10
+ clicked: 'click',
11
+ };
12
+ function error(status, message, field = null, help = null) {
13
+ return { status, body: { errors: [{ message, field, help }] } };
14
+ }
15
+ function substitute(value, subs) {
16
+ if (!value || !subs)
17
+ return value;
18
+ let out = value;
19
+ for (const [key, replacement] of Object.entries(subs))
20
+ out = out.split(key).join(String(replacement));
21
+ return out;
22
+ }
23
+ async function sendgridSend(ctx, hub) {
24
+ if (await hub.guard(ctx, sendgrid))
25
+ return;
26
+ const body = await ctx.json();
27
+ const personalizations = (Array.isArray(body.personalizations) ? body.personalizations : []);
28
+ if (!personalizations.length) {
29
+ return error(400, 'The personalizations field is required and must have at least one personalization.', 'personalizations', 'http://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/errors.html#-Personalizations-Errors');
30
+ }
31
+ const from = toAddress(body.from);
32
+ if (!from)
33
+ return error(400, 'The from object must be provided for every email send. It is an object that requires the email parameter, but may also contain a name parameter.', 'from.email');
34
+ const content = (Array.isArray(body.content) ? body.content : []);
35
+ const templateId = str(body.template_id);
36
+ const baseHtml = content.find((c) => c.type === 'text/html')?.value;
37
+ const baseText = content.find((c) => c.type === 'text/plain')?.value;
38
+ const attachments = Array.isArray(body.attachments)
39
+ ? body.attachments.map((a) => attachment(a.filename, a.content, a.type, a.content_id))
40
+ : undefined;
41
+ const categories = Array.isArray(body.categories) ? body.categories : [];
42
+ const xMessageId = randomBytes(16).toString('base64url').slice(0, 22);
43
+ const caught = [];
44
+ for (const [index, p] of personalizations.entries()) {
45
+ const to = toAddressList(p.to);
46
+ if (!to.length)
47
+ return error(400, 'The to array is required for all personalization objects, and must have at least one email object with a valid email address.', `personalizations.${index}.to`);
48
+ let html = substitute(baseHtml, p.substitutions);
49
+ let text = substitute(baseText, p.substitutions);
50
+ if (!html && !text && templateId) {
51
+ text = `Sent with SendGrid dynamic template ${templateId}. Groundhog can't render stored templates; the data it was sent with:\n\n${JSON.stringify(p.dynamic_template_data ?? {}, null, 2)}`;
52
+ }
53
+ const customArgs = { ...(body.custom_args ?? {}), ...(p.custom_args ?? {}) };
54
+ caught.push(await ingestApiSend({
55
+ from,
56
+ to,
57
+ cc: toAddressList(p.cc),
58
+ bcc: toAddressList(p.bcc),
59
+ replyTo: toAddressList(body.reply_to_list ?? body.reply_to),
60
+ subject: substitute(p.subject ?? str(body.subject), p.substitutions) ?? '',
61
+ html: html ?? null,
62
+ text: text ?? null,
63
+ headers: { ...(headerRecord(body.headers) ?? {}), ...(headerRecord(p.headers) ?? {}) },
64
+ attachments,
65
+ }, {
66
+ via: 'sendgrid',
67
+ providerMessageId: personalizations.length > 1 ? `${xMessageId}.${index}` : xMessageId,
68
+ tags: categories,
69
+ metadata: Object.fromEntries(Object.entries(customArgs).map(([k, v]) => [k, String(v)])),
70
+ }));
71
+ }
72
+ for (const email of caught)
73
+ hub.ingest(email);
74
+ return { status: 202, headers: { 'X-Message-Id': xMessageId } };
75
+ }
76
+ /** SendGrid's v3 Mail Send, at `/sendgrid/v3/mail/send`. */
77
+ export const sendgrid = {
78
+ id: 'sendgrid',
79
+ eventNames: EVENT_NAMES,
80
+ register(router, hub) {
81
+ router.post('/sendgrid/v3/mail/send', (ctx) => sendgridSend(ctx, hub));
82
+ },
83
+ fail(kind) {
84
+ switch (kind) {
85
+ case 'rate_limit':
86
+ return { ...error(429, 'too many requests'), headers: { 'X-RateLimit-Limit': '600', 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': String(unixSeconds() + 1) } };
87
+ case 'invalid_recipient':
88
+ return error(400, 'Does not contain a valid address.', 'personalizations.0.to.0.email', 'http://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/errors.html#message.personalizations.to');
89
+ case 'unauthorized':
90
+ return error(401, 'The provided authorization grant is invalid, expired, or revoked');
91
+ case 'outage':
92
+ return error(503, 'Service Unavailable');
93
+ }
94
+ },
95
+ webhook(event, email, ctx) {
96
+ const timestamp = unixSeconds();
97
+ const eventId = randomBytes(16).toString('base64url').slice(0, 22);
98
+ const base = {
99
+ email: recipient(email),
100
+ timestamp,
101
+ 'smtp-id': `<${email.providerMessageId}@groundhog.local>`,
102
+ event: EVENT_NAMES[event],
103
+ category: email.tags,
104
+ sg_event_id: eventId,
105
+ sg_message_id: `${email.providerMessageId}.groundhog.0`,
106
+ ...email.metadata,
107
+ };
108
+ if (event === 'delivered')
109
+ base.response = '250 2.0.0 OK';
110
+ if (event === 'bounced')
111
+ Object.assign(base, { reason: '550 5.1.1 The email account that you tried to reach does not exist.', status: '5.1.1', type: 'bounce', bounce_classification: 'Invalid Address' });
112
+ if (event === 'opened' || event === 'clicked')
113
+ Object.assign(base, { useragent: 'Groundhog', ip: '127.0.0.1', sg_machine_open: false });
114
+ if (event === 'clicked')
115
+ Object.assign(base, { url: ctx.link, url_offset: { index: 0, type: 'html' } });
116
+ const body = JSON.stringify([base]);
117
+ const ts = String(timestamp);
118
+ return {
119
+ eventId,
120
+ body,
121
+ headers: {
122
+ 'Content-Type': 'application/json',
123
+ 'X-Twilio-Email-Event-Webhook-Timestamp': ts,
124
+ 'X-Twilio-Email-Event-Webhook-Signature': sendgridSignature(ts, body),
125
+ },
126
+ };
127
+ },
128
+ };
@@ -0,0 +1,3 @@
1
+ import type { ProviderModule } from './types.js';
2
+ /** Amazon SES: the v2 JSON API (`/ses/v2/email/outbound-emails`) and the v1 query API (`POST /ses`). */
3
+ export declare const ses: ProviderModule;
@@ -0,0 +1,244 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { ingestApiSend, parseEmail, toAddress, toAddressList, uuid } from '../mime.js';
3
+ import { allRecipients, attachment, decodeContent, headerRecord } from './common.js';
4
+ const EVENT_NAMES = {
5
+ delivered: 'Delivery',
6
+ bounced: 'Bounce',
7
+ complained: 'Complaint',
8
+ opened: 'Open',
9
+ clicked: 'Click',
10
+ };
11
+ function sesMessageId() {
12
+ return `0100${randomBytes(6).toString('hex')}-${uuid()}-000000`;
13
+ }
14
+ function xmlEscape(value) {
15
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
16
+ }
17
+ const V2_ERRORS = {
18
+ rate_limit: { status: 429, type: 'TooManyRequestsException', message: 'Maximum sending rate exceeded.' },
19
+ invalid_recipient: { status: 400, type: 'BadRequestException', message: "Missing final '@domain'" },
20
+ unauthorized: { status: 403, type: 'UnrecognizedClientException', message: 'The security token included in the request is invalid.' },
21
+ outage: { status: 503, type: 'ServiceUnavailable', message: 'Service is unavailable. Please try again later.' },
22
+ };
23
+ const V1_ERRORS = {
24
+ rate_limit: { status: 400, code: 'Throttling', type: 'Sender', message: 'Maximum sending rate exceeded.' },
25
+ invalid_recipient: { status: 400, code: 'InvalidParameterValue', type: 'Sender', message: "Missing final '@domain'" },
26
+ unauthorized: { status: 403, code: 'InvalidClientTokenId', type: 'Sender', message: 'The security token included in the request is invalid.' },
27
+ outage: { status: 503, code: 'ServiceUnavailable', type: 'Receiver', message: 'Service is unavailable. Please try again later.' },
28
+ };
29
+ function v2Error(status, type, message) {
30
+ return { status, headers: { 'x-amzn-ErrorType': type, 'x-amzn-RequestId': uuid() }, body: { message } };
31
+ }
32
+ function v1Error(status, code, type, message) {
33
+ return {
34
+ status,
35
+ headers: { 'Content-Type': 'text/xml' },
36
+ body: `<ErrorResponse xmlns="http://ses.amazonaws.com/doc/2010-12-01/"><Error><Type>${type}</Type><Code>${code}</Code><Message>${xmlEscape(message)}</Message></Error><RequestId>${uuid()}</RequestId></ErrorResponse>`,
37
+ };
38
+ }
39
+ function isV1(ctx) {
40
+ return ctx.method === 'POST' && !ctx.url.pathname.includes('/v2/') && ctx.header('content-type').includes('x-www-form-urlencoded');
41
+ }
42
+ function render(template, data) {
43
+ if (!template)
44
+ return template;
45
+ return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, key) => {
46
+ const value = key.split('.').reduce((obj, part) => (obj && typeof obj === 'object' ? obj[part] : undefined), data);
47
+ return value == null ? '' : String(value);
48
+ });
49
+ }
50
+ async function sendV2(ctx, hub) {
51
+ if (await hub.guard(ctx, ses))
52
+ return;
53
+ const body = await ctx.json();
54
+ const messageId = sesMessageId();
55
+ const tags = (body.EmailTags ?? []).map((tag) => `${tag.Name}:${tag.Value}`);
56
+ const metadata = body.ConfigurationSetName ? { ConfigurationSetName: body.ConfigurationSetName } : {};
57
+ let email;
58
+ if (body.Content?.Raw?.Data) {
59
+ email = await parseEmail(decodeContent(body.Content.Raw.Data), {
60
+ via: 'ses',
61
+ providerMessageId: messageId,
62
+ envelopeTo: toAddressList([...(body.Destination?.ToAddresses ?? []), ...(body.Destination?.CcAddresses ?? []), ...(body.Destination?.BccAddresses ?? [])]),
63
+ tags,
64
+ metadata,
65
+ });
66
+ }
67
+ else {
68
+ const from = toAddress(body.FromEmailAddress);
69
+ const to = toAddressList(body.Destination?.ToAddresses);
70
+ const cc = toAddressList(body.Destination?.CcAddresses);
71
+ const bcc = toAddressList(body.Destination?.BccAddresses);
72
+ if (!from)
73
+ return v2Error(400, 'BadRequestException', 'FromEmailAddress is required.');
74
+ if (!to.length && !cc.length && !bcc.length)
75
+ return v2Error(400, 'BadRequestException', 'Missing required field Destination');
76
+ let subject = body.Content?.Simple?.Subject?.Data ?? '';
77
+ let html = body.Content?.Simple?.Body?.Html?.Data;
78
+ let text = body.Content?.Simple?.Body?.Text?.Data;
79
+ const template = body.Content?.Template;
80
+ if (template) {
81
+ let data = {};
82
+ try {
83
+ data = JSON.parse(template.TemplateData ?? '{}');
84
+ }
85
+ catch {
86
+ // Keep the raw data in the text below.
87
+ }
88
+ if (template.TemplateContent) {
89
+ subject = render(template.TemplateContent.Subject, data) ?? '';
90
+ html = render(template.TemplateContent.Html, data);
91
+ text = render(template.TemplateContent.Text, data);
92
+ }
93
+ else {
94
+ const name = template.TemplateName ?? template.TemplateArn ?? 'unknown';
95
+ subject = `SES template ${name}`;
96
+ text = `Sent with SES template ${name}. Groundhog can't render stored templates; the data it was sent with:\n\n${template.TemplateData ?? '{}'}`;
97
+ }
98
+ }
99
+ email = await ingestApiSend({
100
+ from,
101
+ to,
102
+ cc,
103
+ bcc,
104
+ replyTo: toAddressList(body.ReplyToAddresses),
105
+ subject,
106
+ html: html ?? null,
107
+ text: text ?? null,
108
+ headers: headerRecord(body.Content?.Simple?.Headers),
109
+ attachments: body.Content?.Simple?.Attachments?.map((a) => attachment(a.FileName, a.RawContent, a.ContentType, a.ContentId)),
110
+ }, { via: 'ses', providerMessageId: messageId, tags, metadata });
111
+ }
112
+ hub.ingest(email);
113
+ return { status: 200, headers: { 'x-amzn-RequestId': uuid() }, body: { MessageId: messageId } };
114
+ }
115
+ function members(pairs, prefix) {
116
+ return pairs
117
+ .filter(([key]) => key.startsWith(`${prefix}.member.`))
118
+ .sort(([a], [b]) => Number(a.split('.').pop()) - Number(b.split('.').pop()))
119
+ .map(([, value]) => value);
120
+ }
121
+ async function sendV1(ctx, hub) {
122
+ const pairs = await ctx.formPairs();
123
+ const get = (key) => pairs.find(([k]) => k === key)?.[1];
124
+ const action = get('Action');
125
+ if (action !== 'SendEmail' && action !== 'SendRawEmail') {
126
+ return v1Error(400, 'InvalidAction', 'Sender', `Groundhog only emulates SendEmail and SendRawEmail (got ${action ?? 'nothing'}).`);
127
+ }
128
+ if (await hub.guard(ctx, ses))
129
+ return;
130
+ const messageId = sesMessageId();
131
+ const tags = [];
132
+ for (let i = 1; get(`Tags.member.${i}.Name`); i += 1)
133
+ tags.push(`${get(`Tags.member.${i}.Name`)}:${get(`Tags.member.${i}.Value`) ?? ''}`);
134
+ let email;
135
+ if (action === 'SendRawEmail') {
136
+ const data = get('RawMessage.Data');
137
+ if (!data)
138
+ return v1Error(400, 'InvalidParameterValue', 'Sender', 'RawMessage.Data is required.');
139
+ email = await parseEmail(Buffer.from(data, 'base64'), { via: 'ses', providerMessageId: messageId, envelopeTo: toAddressList(members(pairs, 'Destinations')), tags });
140
+ }
141
+ else {
142
+ const from = toAddress(get('Source'));
143
+ if (!from)
144
+ return v1Error(400, 'InvalidParameterValue', 'Sender', 'Missing required header Source.');
145
+ email = await ingestApiSend({
146
+ from,
147
+ to: toAddressList(members(pairs, 'Destination.ToAddresses')),
148
+ cc: toAddressList(members(pairs, 'Destination.CcAddresses')),
149
+ bcc: toAddressList(members(pairs, 'Destination.BccAddresses')),
150
+ replyTo: toAddressList(members(pairs, 'ReplyToAddresses')),
151
+ subject: get('Message.Subject.Data') ?? '',
152
+ html: get('Message.Body.Html.Data') ?? null,
153
+ text: get('Message.Body.Text.Data') ?? null,
154
+ }, { via: 'ses', providerMessageId: messageId, tags });
155
+ }
156
+ hub.ingest(email);
157
+ const requestId = uuid();
158
+ return {
159
+ status: 200,
160
+ headers: { 'Content-Type': 'text/xml' },
161
+ body: `<${action}Response xmlns="http://ses.amazonaws.com/doc/2010-12-01/"><${action}Result><MessageId>${messageId}</MessageId></${action}Result><ResponseMetadata><RequestId>${requestId}</RequestId></ResponseMetadata></${action}Response>`,
162
+ };
163
+ }
164
+ /** Amazon SES: the v2 JSON API (`/ses/v2/email/outbound-emails`) and the v1 query API (`POST /ses`). */
165
+ export const ses = {
166
+ id: 'ses',
167
+ eventNames: EVENT_NAMES,
168
+ register(router, hub) {
169
+ router.post('/ses/v2/email/outbound-emails', (ctx) => sendV2(ctx, hub));
170
+ router.post('/ses', (ctx) => sendV1(ctx, hub));
171
+ },
172
+ fail(kind, ctx) {
173
+ if (ctx && isV1(ctx)) {
174
+ const e = V1_ERRORS[kind];
175
+ return v1Error(e.status, e.code, e.type, e.message);
176
+ }
177
+ const e = V2_ERRORS[kind];
178
+ return v2Error(e.status, e.type, e.message);
179
+ },
180
+ webhook(event, email, ctx) {
181
+ const now = new Date().toISOString();
182
+ const recipients = allRecipients(email);
183
+ const sesEvent = {
184
+ eventType: EVENT_NAMES[event],
185
+ mail: {
186
+ timestamp: email.createdAt,
187
+ source: email.from?.address ?? '',
188
+ sourceArn: `arn:aws:ses:us-east-1:000000000000:identity/${email.from?.address.split('@')[1] ?? 'localhost'}`,
189
+ sendingAccountId: '000000000000',
190
+ messageId: email.providerMessageId,
191
+ destination: recipients,
192
+ headersTruncated: false,
193
+ headers: email.headers.slice(0, 20).map(([name, value]) => ({ name, value })),
194
+ commonHeaders: {
195
+ from: email.from ? [email.from.name ? `${email.from.name} <${email.from.address}>` : email.from.address] : [],
196
+ to: email.to.map((a) => a.address),
197
+ messageId: email.providerMessageId,
198
+ subject: email.subject,
199
+ },
200
+ tags: Object.fromEntries(email.tags.map((tag) => tag.split(':')).map(([k, ...v]) => [k, [v.join(':')]])),
201
+ },
202
+ };
203
+ const feedbackId = sesMessageId();
204
+ switch (event) {
205
+ case 'delivered':
206
+ sesEvent.delivery = { timestamp: now, processingTimeMillis: 512, recipients, smtpResponse: '250 2.6.0 Message received', reportingMTA: 'groundhog.local' };
207
+ break;
208
+ case 'bounced':
209
+ sesEvent.bounce = {
210
+ bounceType: 'Permanent', bounceSubType: 'General', timestamp: now, feedbackId, reportingMTA: 'dsn; groundhog.local',
211
+ bouncedRecipients: [{ emailAddress: recipients[0], action: 'failed', status: '5.1.1', diagnosticCode: 'smtp; 550 5.1.1 user unknown' }],
212
+ };
213
+ break;
214
+ case 'complained':
215
+ sesEvent.complaint = { complainedRecipients: [{ emailAddress: recipients[0] }], timestamp: now, feedbackId, userAgent: 'Groundhog', complaintFeedbackType: 'abuse', arrivalDate: now };
216
+ break;
217
+ case 'opened':
218
+ sesEvent.open = { ipAddress: '127.0.0.1', timestamp: now, userAgent: 'Groundhog' };
219
+ break;
220
+ case 'clicked':
221
+ sesEvent.click = { ipAddress: '127.0.0.1', timestamp: now, userAgent: 'Groundhog', link: ctx.link, linkTags: {} };
222
+ break;
223
+ }
224
+ const id = uuid();
225
+ const topic = 'arn:aws:sns:us-east-1:000000000000:groundhog-ses-events';
226
+ const notification = {
227
+ Type: 'Notification',
228
+ MessageId: id,
229
+ TopicArn: topic,
230
+ Message: JSON.stringify(sesEvent),
231
+ Timestamp: now,
232
+ SignatureVersion: '1',
233
+ // AWS signs SNS messages with its own certificate, which can't be reproduced locally.
234
+ Signature: randomBytes(256).toString('base64'),
235
+ SigningCertURL: `${ctx.baseUrl}/ses/sns-signing-cert.pem`,
236
+ UnsubscribeURL: `${ctx.baseUrl}/ses/sns-unsubscribe?SubscriptionArn=${encodeURIComponent(topic)}`,
237
+ };
238
+ return {
239
+ eventId: id,
240
+ body: JSON.stringify(notification),
241
+ headers: { 'Content-Type': 'text/plain; charset=UTF-8', 'x-amz-sns-message-type': 'Notification', 'x-amz-sns-message-id': id, 'x-amz-sns-topic-arn': topic },
242
+ };
243
+ },
244
+ };
@@ -0,0 +1,35 @@
1
+ import type { HttpResult, RequestContext, Router } from '../http.js';
2
+ import type { WebhookSecrets } from '../signing.js';
3
+ import type { Email, FaultKind, Provider, StoredEmail, WebhookEvent } from '../types.js';
4
+ export type Hub = {
5
+ /** Add a caught email to the inbox (fires the auto `delivered` webhook when that's on). */
6
+ ingest(email: StoredEmail): StoredEmail;
7
+ get(id: string): StoredEmail | undefined;
8
+ /**
9
+ * Apply failure mode to this send. Returns true when the request was answered
10
+ * with a simulated failure (or dropped, for a timeout) and the route must stop.
11
+ */
12
+ guard(ctx: RequestContext, provider: ProviderModule): Promise<boolean>;
13
+ };
14
+ export type WebhookRequest = {
15
+ /** Event id, shown in the delivery log. */
16
+ eventId: string;
17
+ headers: Record<string, string>;
18
+ body: string;
19
+ };
20
+ export type WebhookContext = {
21
+ secrets: WebhookSecrets;
22
+ /** Link used for `clicked` events. */
23
+ link: string;
24
+ /** Groundhog's own base URL (for SNS cert links and such). */
25
+ baseUrl: string;
26
+ };
27
+ export type ProviderModule = {
28
+ id: Exclude<Provider, 'smtp'> | 'smtp';
29
+ /** The provider's own name for each event, shown on the webhook buttons. */
30
+ eventNames: Record<WebhookEvent, string>;
31
+ register?(router: Router, hub: Hub): void;
32
+ /** The error response this provider returns for a simulated failure. */
33
+ fail(kind: Exclude<FaultKind, 'timeout'>, ctx?: RequestContext): HttpResult;
34
+ webhook(event: WebhookEvent, email: Email, ctx: WebhookContext): WebhookRequest;
35
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { Faults } from './faults.js';
2
+ import { GroundmailAccount } from './groundmail-account.js';
3
+ import { EmailStore } from './store.js';
4
+ import { Webhooks, type WebhookSettings } from './webhooks.js';
5
+ export declare const VERSION: string;
6
+ export type GroundhogOptions = {
7
+ /** SMTP port. Default 1025. 0 picks a free port. */
8
+ smtpPort?: number;
9
+ /** HTTP port (web UI, testing API, provider APIs). Default 3025. 0 picks a free port. */
10
+ httpPort?: number;
11
+ /** `localhost` (default) listens on 127.0.0.1 and ::1. Use `0.0.0.0` in containers. */
12
+ host?: string;
13
+ /** Most emails kept; older ones drop off. Default 1000. */
14
+ maxEmails?: number;
15
+ /** Save the inbox to this JSON file and load it on start. */
16
+ storeFile?: string;
17
+ webhooks?: Partial<WebhookSettings>;
18
+ /** How long a simulated timeout holds a connection. Default 30 000 ms. */
19
+ faultTimeoutMs?: number;
20
+ /** Disable the SMTP listener. */
21
+ smtp?: boolean;
22
+ /** Groundmail API base URL for "Send to my real inbox". */
23
+ groundmailApiUrl?: string;
24
+ openBrowser?: (url: string) => void;
25
+ log?: (line: string) => void;
26
+ };
27
+ export type Groundhog = {
28
+ url: string;
29
+ smtpPort: number;
30
+ httpPort: number;
31
+ host: string;
32
+ store: EmailStore;
33
+ faults: Faults;
34
+ webhooks: Webhooks;
35
+ account: GroundmailAccount;
36
+ close(): Promise<void>;
37
+ };
38
+ export declare function startGroundhog(options?: GroundhogOptions): Promise<Groundhog>;