@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,169 @@
1
+ import { ingestApiSend, newId, toAddress, toAddressList, uuid } from '../mime.js';
2
+ import { isResendClient, resendBatchRoute, resendGetRoute, resendSendRoute } from './resend.js';
3
+ import { groundmailSignature } from '../signing.js';
4
+ import { attachment, fromString, headerRecord, str, unixSeconds } from './common.js';
5
+ const EVENT_NAMES = {
6
+ delivered: 'email.delivered',
7
+ bounced: 'email.bounced',
8
+ complained: 'email.complained',
9
+ opened: 'email.opened',
10
+ clicked: 'email.clicked',
11
+ };
12
+ function error(status, code, message, hint) {
13
+ return { status, body: { error: { message, code, ...(hint ? { hint } : {}) } } };
14
+ }
15
+ function tagsOf(input) {
16
+ if (!Array.isArray(input))
17
+ return [];
18
+ return input
19
+ .map((tag) => (typeof tag === 'string' ? tag : tag && typeof tag === 'object' ? `${tag.name}:${tag.value}` : ''))
20
+ .filter(Boolean);
21
+ }
22
+ export async function groundmailSendFromBody(body, via) {
23
+ const from = toAddress(body.from);
24
+ const to = toAddressList(body.to);
25
+ if (!from)
26
+ return via === 'resend' ? resendValidation('Missing `from` field.') : error(422, 'validation_error', '`from` is required');
27
+ if (!to.length)
28
+ return via === 'resend' ? resendValidation('Missing `to` field.') : error(422, 'validation_error', '`to` is required');
29
+ const html = str(body.html ?? body.htmlBody ?? body.html_body);
30
+ const text = str(body.text ?? body.textBody ?? body.text_body ?? body.plain_body);
31
+ const attachments = Array.isArray(body.attachments)
32
+ ? body.attachments.map((a) => attachment(a.filename ?? a.name, a.content ?? a.data, a.content_type ?? a.contentType, a.content_id ?? a.contentId ?? a.cid))
33
+ : undefined;
34
+ const metadata = {};
35
+ if (body.metadata && typeof body.metadata === 'object') {
36
+ for (const [key, value] of Object.entries(body.metadata))
37
+ metadata[key] = String(value);
38
+ }
39
+ const id = via === 'resend' ? uuid() : newId('msg');
40
+ return ingestApiSend({
41
+ from,
42
+ to,
43
+ cc: toAddressList(body.cc),
44
+ bcc: toAddressList(body.bcc),
45
+ replyTo: toAddressList(body.reply_to ?? body.replyTo),
46
+ subject: str(body.subject) ?? '',
47
+ html: html ?? null,
48
+ text: text ?? null,
49
+ headers: headerRecord(body.headers),
50
+ attachments,
51
+ tags: tagsOf(body.tags),
52
+ metadata,
53
+ }, { via, providerMessageId: id });
54
+ }
55
+ function resendValidation(message) {
56
+ return { status: 422, body: { statusCode: 422, name: 'validation_error', message } };
57
+ }
58
+ export function isResult(value) {
59
+ return 'status' in value && typeof value.status === 'number' && !('via' in value);
60
+ }
61
+ export function emailView(email) {
62
+ return {
63
+ id: email.providerMessageId,
64
+ from: fromString(email),
65
+ to: email.to.map((a) => a.address),
66
+ cc: email.cc.map((a) => a.address),
67
+ bcc: email.bcc.map((a) => a.address),
68
+ reply_to: email.replyTo.map((a) => a.address),
69
+ subject: email.subject,
70
+ status: 'delivered',
71
+ last_event: 'email.delivered',
72
+ bounce_reason: null,
73
+ bounce_type: null,
74
+ held_back: [],
75
+ test: true,
76
+ tags: email.tags,
77
+ scheduled_at: null,
78
+ cancelled_at: null,
79
+ created_at: email.createdAt,
80
+ sent_at: email.createdAt,
81
+ delivered_at: email.createdAt,
82
+ bounced_at: null,
83
+ opened_at: null,
84
+ clicked_at: null,
85
+ };
86
+ }
87
+ /** Groundmail's own API. Mounted at `/` and `/groundmail`. */
88
+ export const groundmail = {
89
+ id: 'groundmail',
90
+ eventNames: EVENT_NAMES,
91
+ register(router, hub) {
92
+ for (const base of ['', '/groundmail']) {
93
+ router.post(`${base}/emails`, async (ctx) => {
94
+ // RESEND_BASE_URL=http://localhost:3025 (no /resend) lands here too.
95
+ if (!base && isResendClient(ctx))
96
+ return resendSendRoute(ctx, hub);
97
+ if (await hub.guard(ctx, groundmail))
98
+ return;
99
+ const result = await groundmailSendFromBody(await ctx.json(), 'groundmail');
100
+ if (isResult(result))
101
+ return result;
102
+ hub.ingest(result);
103
+ return { status: 200, body: { id: result.providerMessageId, status: 'sent' } };
104
+ });
105
+ router.post(`${base}/emails/batch`, async (ctx) => {
106
+ if (!base && isResendClient(ctx))
107
+ return resendBatchRoute(ctx, hub);
108
+ if (await hub.guard(ctx, groundmail))
109
+ return;
110
+ const body = await ctx.json();
111
+ const emails = Array.isArray(body) ? body : (body.emails ?? []);
112
+ const data = [];
113
+ for (const [index, item] of emails.entries()) {
114
+ const result = await groundmailSendFromBody(item, 'groundmail');
115
+ if (isResult(result)) {
116
+ data.push({ index, error: result.body.error });
117
+ continue;
118
+ }
119
+ hub.ingest(result);
120
+ data.push({ index, id: result.providerMessageId, status: 'sent' });
121
+ }
122
+ return { status: 200, body: { data } };
123
+ });
124
+ router.get(`${base}/emails/:id`, (ctx) => {
125
+ if (!base && isResendClient(ctx))
126
+ return resendGetRoute(ctx, hub);
127
+ const email = hub.get(ctx.params.id);
128
+ if (!email)
129
+ return error(404, 'not_found', 'Email not found');
130
+ return { status: 200, body: { email: emailView(email) } };
131
+ });
132
+ }
133
+ },
134
+ fail(kind) {
135
+ switch (kind) {
136
+ case 'rate_limit':
137
+ return { ...error(429, 'rate_limited', 'Too many requests. Slow down and retry after the Retry-After header.'), headers: { 'Retry-After': '1' } };
138
+ case 'invalid_recipient':
139
+ return error(422, 'invalid_recipient', 'The recipient address is not valid.', 'Check the `to` address.');
140
+ case 'unauthorized':
141
+ return error(401, 'invalid_api_key', 'Invalid API key.', 'Check GROUNDMAIL_API_KEY.');
142
+ case 'outage':
143
+ return error(503, 'service_unavailable', 'Groundmail is temporarily unavailable. Retry with backoff.');
144
+ }
145
+ },
146
+ webhook(event, email, ctx) {
147
+ const timestamp = unixSeconds();
148
+ const payload = {
149
+ type: EVENT_NAMES[event],
150
+ created_at: new Date().toISOString(),
151
+ data: {
152
+ email_id: email.providerMessageId,
153
+ to: email.to.map((a) => a.address),
154
+ from: fromString(email),
155
+ subject: email.subject,
156
+ status: event === 'bounced' ? 'bounced' : event === 'complained' ? 'complained' : 'delivered',
157
+ ...(event === 'bounced' ? { bounce_reason: '550 5.1.1 The email account that you tried to reach does not exist.', bounce_type: 'hard' } : {}),
158
+ ...(event === 'clicked' ? { link: ctx.link } : {}),
159
+ test: true,
160
+ },
161
+ };
162
+ const body = JSON.stringify(payload);
163
+ return {
164
+ eventId: newId('evt'),
165
+ body,
166
+ headers: { 'Content-Type': 'application/json', 'Groundmail-Signature': groundmailSignature(ctx.secrets.groundmail, body, timestamp) },
167
+ };
168
+ },
169
+ };
@@ -0,0 +1,12 @@
1
+ import { groundmail } from './groundmail.js';
2
+ import { mailgun } from './mailgun.js';
3
+ import { postmark } from './postmark.js';
4
+ import { resend } from './resend.js';
5
+ import { sendgrid } from './sendgrid.js';
6
+ import { ses } from './ses.js';
7
+ import type { ProviderModule } from './types.js';
8
+ export type WebhookFormat = 'groundmail' | 'sendgrid' | 'postmark' | 'mailgun' | 'resend' | 'ses';
9
+ export declare const WEBHOOK_FORMATS: WebhookFormat[];
10
+ export declare const HTTP_PROVIDERS: ProviderModule[];
11
+ export declare const PROVIDER_MODULES: Record<WebhookFormat, ProviderModule>;
12
+ export { groundmail, mailgun, postmark, resend, sendgrid, ses };
@@ -0,0 +1,10 @@
1
+ import { groundmail } from './groundmail.js';
2
+ import { mailgun } from './mailgun.js';
3
+ import { postmark } from './postmark.js';
4
+ import { resend } from './resend.js';
5
+ import { sendgrid } from './sendgrid.js';
6
+ import { ses } from './ses.js';
7
+ export const WEBHOOK_FORMATS = ['groundmail', 'sendgrid', 'postmark', 'mailgun', 'resend', 'ses'];
8
+ export const HTTP_PROVIDERS = [groundmail, resend, sendgrid, postmark, mailgun, ses];
9
+ export const PROVIDER_MODULES = { groundmail, sendgrid, postmark, mailgun, resend, ses };
10
+ export { groundmail, mailgun, postmark, resend, sendgrid, ses };
@@ -0,0 +1,3 @@
1
+ import type { ProviderModule } from './types.js';
2
+ /** Mailgun's Messages API, at `/mailgun/v3/<domain>/messages`. */
3
+ export declare const mailgun: ProviderModule;
@@ -0,0 +1,150 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { ingestApiSend, parseEmail, toAddress, toAddressList } from '../mime.js';
3
+ import { mailgunSignature } from '../signing.js';
4
+ import { allRecipients, fromString, recipient } from './common.js';
5
+ const EVENT_NAMES = {
6
+ delivered: 'delivered',
7
+ bounced: 'failed',
8
+ complained: 'complained',
9
+ opened: 'opened',
10
+ clicked: 'clicked',
11
+ };
12
+ function error(status, message) {
13
+ return { status, body: { message } };
14
+ }
15
+ function mailgunId(domain) {
16
+ const stamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14);
17
+ return `${stamp}.${randomBytes(8).toString('hex')}@${domain}`;
18
+ }
19
+ function all(fields, name) {
20
+ return fields.filter(([key]) => key === name || key === `${name}[]`).map(([, value]) => value);
21
+ }
22
+ function one(fields, name) {
23
+ return all(fields, name)[0];
24
+ }
25
+ async function send(ctx, hub) {
26
+ if (await hub.guard(ctx, mailgun))
27
+ return;
28
+ const domain = ctx.params.domain;
29
+ const { fields, files } = await ctx.multipart();
30
+ const from = toAddress(one(fields, 'from'));
31
+ const to = toAddressList(all(fields, 'to'));
32
+ if (!from)
33
+ return error(400, "from parameter is missing");
34
+ if (!to.length)
35
+ return error(400, "to parameter is missing");
36
+ const headers = {};
37
+ const metadata = {};
38
+ for (const [key, value] of fields) {
39
+ if (key.startsWith('h:'))
40
+ headers[key.slice(2)] = value;
41
+ if (key.startsWith('v:'))
42
+ metadata[key.slice(2)] = value;
43
+ }
44
+ const variables = one(fields, 'h:X-Mailgun-Variables');
45
+ if (variables) {
46
+ try {
47
+ for (const [key, value] of Object.entries(JSON.parse(variables)))
48
+ metadata[key] = String(value);
49
+ }
50
+ catch {
51
+ // Not JSON: keep it as a header only.
52
+ }
53
+ }
54
+ let html = one(fields, 'html');
55
+ let text = one(fields, 'text');
56
+ const template = one(fields, 'template');
57
+ if (!html && !text && template) {
58
+ text = `Sent with Mailgun template ${template}. Groundhog can't render stored templates; the variables it was sent with:\n\n${one(fields, 't:variables') ?? '{}'}`;
59
+ }
60
+ const id = mailgunId(domain);
61
+ const email = await ingestApiSend({
62
+ from,
63
+ to,
64
+ cc: toAddressList(all(fields, 'cc')),
65
+ bcc: toAddressList(all(fields, 'bcc')),
66
+ replyTo: toAddressList(one(fields, 'h:Reply-To')),
67
+ subject: one(fields, 'subject') ?? '',
68
+ html: html ?? null,
69
+ text: text ?? null,
70
+ headers,
71
+ attachments: files
72
+ .filter((file) => file.field === 'attachment' || file.field === 'inline')
73
+ .map((file) => ({ filename: file.filename, content: file.content, contentType: file.contentType, cid: file.field === 'inline' ? file.filename : undefined })),
74
+ }, { via: 'mailgun', providerMessageId: id, tags: all(fields, 'o:tag'), metadata });
75
+ hub.ingest(email);
76
+ return { status: 200, body: { id: `<${id}>`, message: 'Queued. Thank you.' } };
77
+ }
78
+ async function sendMime(ctx, hub) {
79
+ if (await hub.guard(ctx, mailgun))
80
+ return;
81
+ const { fields, files } = await ctx.multipart();
82
+ const message = files.find((file) => file.field === 'message')?.content ?? (one(fields, 'message') ? Buffer.from(one(fields, 'message')) : null);
83
+ if (!message)
84
+ return error(400, "'message' parameter is missing");
85
+ const id = mailgunId(ctx.params.domain);
86
+ const email = await parseEmail(message, { via: 'mailgun', providerMessageId: id, envelopeTo: toAddressList(all(fields, 'to')), tags: all(fields, 'o:tag') });
87
+ hub.ingest(email);
88
+ return { status: 200, body: { id: `<${id}>`, message: 'Queued. Thank you.' } };
89
+ }
90
+ /** Mailgun's Messages API, at `/mailgun/v3/<domain>/messages`. */
91
+ export const mailgun = {
92
+ id: 'mailgun',
93
+ eventNames: EVENT_NAMES,
94
+ register(router, hub) {
95
+ router.post('/mailgun/v3/:domain/messages', (ctx) => send(ctx, hub));
96
+ router.post('/mailgun/v3/:domain/messages.mime', (ctx) => sendMime(ctx, hub));
97
+ },
98
+ fail(kind) {
99
+ switch (kind) {
100
+ case 'rate_limit':
101
+ return error(429, 'Too many requests');
102
+ case 'invalid_recipient':
103
+ return error(400, "to parameter is not a valid address. please check documentation");
104
+ case 'unauthorized':
105
+ return { status: 401, headers: { 'Content-Type': 'text/plain' }, body: 'Forbidden' };
106
+ case 'outage':
107
+ return error(503, 'Service Unavailable');
108
+ }
109
+ },
110
+ webhook(event, email, ctx) {
111
+ const timestamp = (Date.now() / 1000).toFixed(6);
112
+ const token = randomBytes(25).toString('hex');
113
+ const sigTimestamp = String(Math.floor(Number(timestamp)));
114
+ const to = recipient(email);
115
+ const eventId = randomBytes(11).toString('base64url');
116
+ const data = {
117
+ id: eventId,
118
+ timestamp: Number(timestamp),
119
+ 'log-level': event === 'bounced' ? 'error' : event === 'complained' ? 'warn' : 'info',
120
+ event: EVENT_NAMES[event],
121
+ recipient: to,
122
+ 'recipient-domain': to.split('@')[1] ?? '',
123
+ tags: email.tags,
124
+ 'user-variables': email.metadata,
125
+ envelope: { sender: email.from?.address ?? '', transport: 'smtp', targets: allRecipients(email).join(',') },
126
+ message: {
127
+ headers: { to: email.to.map((a) => a.address).join(', '), 'message-id': email.providerMessageId, from: fromString(email), subject: email.subject },
128
+ attachments: [],
129
+ size: email.size,
130
+ },
131
+ flags: { 'is-routed': false, 'is-authenticated': true, 'is-system-test': false, 'is-test-mode': false },
132
+ };
133
+ if (event === 'delivered')
134
+ data['delivery-status'] = { code: 250, message: 'OK', description: '', 'attempt-no': 1, tls: true, 'mx-host': 'localhost' };
135
+ if (event === 'bounced') {
136
+ Object.assign(data, { severity: 'permanent', reason: 'bounce' });
137
+ data['delivery-status'] = { code: 550, message: '5.1.1 The email account that you tried to reach does not exist.', description: '', 'attempt-no': 1, 'bounce-type': 'hard' };
138
+ }
139
+ if (event === 'opened' || event === 'clicked') {
140
+ Object.assign(data, { ip: '127.0.0.1', 'client-info': { 'client-type': 'browser', 'client-os': 'Local', 'device-type': 'desktop', 'client-name': 'Groundhog', 'user-agent': 'Groundhog' }, geolocation: { country: 'Unknown', region: 'Unknown', city: 'Unknown' } });
141
+ }
142
+ if (event === 'clicked')
143
+ data.url = ctx.link;
144
+ const body = JSON.stringify({
145
+ signature: { timestamp: sigTimestamp, token, signature: mailgunSignature(ctx.secrets.mailgun, sigTimestamp, token) },
146
+ 'event-data': data,
147
+ });
148
+ return { eventId, body, headers: { 'Content-Type': 'application/json' } };
149
+ },
150
+ };
@@ -0,0 +1,3 @@
1
+ import type { ProviderModule } from './types.js';
2
+ /** Postmark's Email API, at `/postmark`. */
3
+ export declare const postmark: ProviderModule;
@@ -0,0 +1,147 @@
1
+ import { ingestApiSend, toAddress, toAddressList, uuid } from '../mime.js';
2
+ import { attachment, headerRecord, recipient, str } from './common.js';
3
+ const EVENT_NAMES = {
4
+ delivered: 'Delivery',
5
+ bounced: 'Bounce',
6
+ complained: 'SpamComplaint',
7
+ opened: 'Open',
8
+ clicked: 'Click',
9
+ };
10
+ function error(status, code, message) {
11
+ return { status, body: { ErrorCode: code, Message: message } };
12
+ }
13
+ async function toEmail(body, template = false) {
14
+ const from = toAddress(str(body.From));
15
+ const to = toAddressList(body.To);
16
+ if (!from)
17
+ return error(422, 300, "Invalid 'From' address: ''.");
18
+ if (!to.length)
19
+ return error(422, 300, 'Zero recipients specified');
20
+ let html = str(body.HtmlBody);
21
+ let text = str(body.TextBody);
22
+ let subject = str(body.Subject) ?? '';
23
+ if (template) {
24
+ const ref = str(body.TemplateAlias) ?? str(body.TemplateId) ?? 'unknown';
25
+ const model = body.TemplateModel ?? {};
26
+ subject = subject || `Postmark template ${ref}`;
27
+ html = undefined;
28
+ text = `Sent with Postmark template ${ref}. Groundhog can't render stored templates; the model it was sent with:\n\n${JSON.stringify(model, null, 2)}`;
29
+ }
30
+ const metadata = {};
31
+ if (body.Metadata && typeof body.Metadata === 'object') {
32
+ for (const [key, value] of Object.entries(body.Metadata))
33
+ metadata[key] = String(value);
34
+ }
35
+ if (body.MessageStream)
36
+ metadata.MessageStream = String(body.MessageStream);
37
+ return ingestApiSend({
38
+ from,
39
+ to,
40
+ cc: toAddressList(body.Cc),
41
+ bcc: toAddressList(body.Bcc),
42
+ replyTo: toAddressList(body.ReplyTo),
43
+ subject,
44
+ html: html ?? null,
45
+ text: text ?? null,
46
+ headers: headerRecord(body.Headers),
47
+ attachments: Array.isArray(body.Attachments)
48
+ ? body.Attachments.map((a) => attachment(a.Name, a.Content, a.ContentType, a.ContentID))
49
+ : undefined,
50
+ }, { via: 'postmark', providerMessageId: uuid(), tags: body.Tag ? [String(body.Tag)] : [], metadata });
51
+ }
52
+ function isError(value) {
53
+ return !('via' in value);
54
+ }
55
+ function sent(email) {
56
+ return {
57
+ To: email.to.map((a) => a.address).join(', '),
58
+ SubmittedAt: email.createdAt,
59
+ MessageID: email.providerMessageId,
60
+ ErrorCode: 0,
61
+ Message: 'OK',
62
+ };
63
+ }
64
+ async function single(ctx, hub, template) {
65
+ if (await hub.guard(ctx, postmark))
66
+ return;
67
+ const result = await toEmail(await ctx.json(), template);
68
+ if (isError(result))
69
+ return result;
70
+ hub.ingest(result);
71
+ return { status: 200, body: sent(result) };
72
+ }
73
+ async function batch(ctx, hub, template) {
74
+ if (await hub.guard(ctx, postmark))
75
+ return;
76
+ const body = await ctx.json();
77
+ const items = (template ? body.Messages : body);
78
+ const out = [];
79
+ for (const item of items ?? []) {
80
+ const result = await toEmail(item, template);
81
+ if (isError(result)) {
82
+ out.push({ ...result.body, To: str(item.To) ?? '' });
83
+ continue;
84
+ }
85
+ hub.ingest(result);
86
+ out.push(sent(result));
87
+ }
88
+ return { status: 200, body: out };
89
+ }
90
+ /** Postmark's Email API, at `/postmark`. */
91
+ export const postmark = {
92
+ id: 'postmark',
93
+ eventNames: EVENT_NAMES,
94
+ register(router, hub) {
95
+ router.post('/postmark/email', (ctx) => single(ctx, hub, false));
96
+ router.post('/postmark/email/withTemplate', (ctx) => single(ctx, hub, true));
97
+ router.post('/postmark/email/batch', (ctx) => batch(ctx, hub, false));
98
+ router.post('/postmark/email/batchWithTemplates', (ctx) => batch(ctx, hub, true));
99
+ },
100
+ fail(kind) {
101
+ switch (kind) {
102
+ case 'rate_limit':
103
+ return error(429, 429, 'Rate limit exceeded. Please slow down your requests.');
104
+ case 'invalid_recipient':
105
+ return error(422, 300, "Invalid 'To' address: 'not-an-address'.");
106
+ case 'unauthorized':
107
+ return error(401, 10, 'Request does not contain a valid Server token.');
108
+ case 'outage':
109
+ return error(503, 0, 'Service Unavailable');
110
+ }
111
+ },
112
+ webhook(event, email, ctx) {
113
+ const now = new Date().toISOString();
114
+ const stream = email.metadata.MessageStream ?? 'outbound';
115
+ const metadata = Object.fromEntries(Object.entries(email.metadata).filter(([key]) => key !== 'MessageStream'));
116
+ const common = { MessageID: email.providerMessageId, ServerID: 1, MessageStream: stream, Tag: email.tags[0] ?? '', Metadata: metadata };
117
+ const client = { Client: { Name: 'Groundhog', Company: 'Groundmail', Family: 'Groundhog' }, OS: { Name: 'Local', Company: '', Family: 'Local' }, Platform: 'Desktop', UserAgent: 'Groundhog', Geo: {} };
118
+ const id = Math.floor(Math.random() * 9_000_000_000) + 1_000_000_000;
119
+ let payload;
120
+ switch (event) {
121
+ case 'delivered':
122
+ payload = { RecordType: 'Delivery', ...common, Recipient: recipient(email), DeliveredAt: now, Details: 'Simulated by Groundhog' };
123
+ break;
124
+ case 'bounced':
125
+ payload = {
126
+ RecordType: 'Bounce', ID: id, Type: 'HardBounce', TypeCode: 1, Name: 'Hard bounce', ...common,
127
+ Description: 'The server was unable to deliver your message (ex: unknown user, mailbox not found).',
128
+ Details: '550 5.1.1 The email account that you tried to reach does not exist.',
129
+ Email: recipient(email), From: email.from?.address ?? '', BouncedAt: now, DumpAvailable: false, Inactive: true, CanActivate: true, Subject: email.subject, Content: '',
130
+ };
131
+ break;
132
+ case 'complained':
133
+ payload = {
134
+ RecordType: 'SpamComplaint', ID: id, Type: 'SpamComplaint', TypeCode: 512, Name: 'Spam complaint', ...common,
135
+ Description: '', Details: '', Email: recipient(email), From: email.from?.address ?? '', BouncedAt: now, DumpAvailable: false, Inactive: true, CanActivate: false, Subject: email.subject, Content: '',
136
+ };
137
+ break;
138
+ case 'opened':
139
+ payload = { RecordType: 'Open', ...common, FirstOpen: true, ...client, ReadSeconds: 5, ReceivedAt: now, Recipient: recipient(email) };
140
+ break;
141
+ case 'clicked':
142
+ payload = { RecordType: 'Click', ...common, ClickLocation: 'HTML', ...client, OriginalLink: ctx.link, ReceivedAt: now, Recipient: recipient(email) };
143
+ break;
144
+ }
145
+ return { eventId: `${EVENT_NAMES[event]} ${id}`, body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' } };
146
+ },
147
+ };
@@ -0,0 +1,8 @@
1
+ import type { HttpResult, RequestContext } from '../http.js';
2
+ import type { Hub, ProviderModule } from './types.js';
3
+ export declare function isResendClient(ctx: RequestContext): boolean;
4
+ export declare function resendSendRoute(ctx: RequestContext, hub: Hub): Promise<HttpResult | void>;
5
+ export declare function resendBatchRoute(ctx: RequestContext, hub: Hub): Promise<HttpResult | void>;
6
+ export declare function resendGetRoute(ctx: RequestContext, hub: Hub): HttpResult;
7
+ /** The Resend API, at `/resend` (and at `/` when the request comes from the Resend SDK). */
8
+ export declare const resend: ProviderModule;
@@ -0,0 +1,121 @@
1
+ import { newId } from '../mime.js';
2
+ import { svixSignature } from '../signing.js';
3
+ import { fromString, unixSeconds } from './common.js';
4
+ import { groundmailSendFromBody, isResult } from './groundmail.js';
5
+ const EVENT_NAMES = {
6
+ delivered: 'email.delivered',
7
+ bounced: 'email.bounced',
8
+ complained: 'email.complained',
9
+ opened: 'email.opened',
10
+ clicked: 'email.clicked',
11
+ };
12
+ function error(status, name, message) {
13
+ return { status, body: { statusCode: status, name, message } };
14
+ }
15
+ export function isResendClient(ctx) {
16
+ return ctx.header('user-agent').toLowerCase().startsWith('resend');
17
+ }
18
+ export async function resendSendRoute(ctx, hub) {
19
+ if (await hub.guard(ctx, resend))
20
+ return;
21
+ const result = await groundmailSendFromBody(await ctx.json(), 'resend');
22
+ if (isResult(result))
23
+ return result;
24
+ hub.ingest(result);
25
+ return { status: 200, body: { id: result.providerMessageId } };
26
+ }
27
+ export async function resendBatchRoute(ctx, hub) {
28
+ if (await hub.guard(ctx, resend))
29
+ return;
30
+ const body = await ctx.json();
31
+ const items = Array.isArray(body) ? body : [];
32
+ if (!items.length)
33
+ return error(422, 'validation_error', 'The batch must contain at least one email.');
34
+ const caught = [];
35
+ for (const item of items) {
36
+ const result = await groundmailSendFromBody(item, 'resend');
37
+ if (isResult(result))
38
+ return result;
39
+ caught.push(result);
40
+ }
41
+ for (const email of caught)
42
+ hub.ingest(email);
43
+ return { status: 200, body: { data: caught.map((email) => ({ id: email.providerMessageId })) } };
44
+ }
45
+ export function resendGetRoute(ctx, hub) {
46
+ const email = hub.get(ctx.params.id);
47
+ if (!email)
48
+ return error(404, 'not_found', 'Email not found');
49
+ return {
50
+ status: 200,
51
+ body: {
52
+ object: 'email',
53
+ id: email.providerMessageId,
54
+ to: email.to.map((a) => a.address),
55
+ from: fromString(email),
56
+ created_at: email.createdAt,
57
+ subject: email.subject,
58
+ html: email.html,
59
+ text: email.text,
60
+ bcc: email.bcc.map((a) => a.address),
61
+ cc: email.cc.map((a) => a.address),
62
+ reply_to: email.replyTo.map((a) => a.address),
63
+ last_event: 'delivered',
64
+ scheduled_at: null,
65
+ },
66
+ };
67
+ }
68
+ /** The Resend API, at `/resend` (and at `/` when the request comes from the Resend SDK). */
69
+ export const resend = {
70
+ id: 'resend',
71
+ eventNames: EVENT_NAMES,
72
+ register(router, hub) {
73
+ router.post('/resend/emails', (ctx) => resendSendRoute(ctx, hub));
74
+ router.post('/resend/emails/batch', (ctx) => resendBatchRoute(ctx, hub));
75
+ router.get('/resend/emails/:id', (ctx) => resendGetRoute(ctx, hub));
76
+ },
77
+ fail(kind) {
78
+ switch (kind) {
79
+ case 'rate_limit':
80
+ return { ...error(429, 'rate_limit_exceeded', 'Too many requests. You can only make 2 requests per second.'), headers: { 'retry-after': '1' } };
81
+ case 'invalid_recipient':
82
+ return error(422, 'validation_error', 'Invalid `to` field. The email address needs to follow the `email@example.com` or `Name <email@example.com>` format.');
83
+ case 'unauthorized':
84
+ return error(403, 'invalid_api_key', 'API key is invalid');
85
+ case 'outage':
86
+ return error(500, 'application_error', 'An unexpected error occurred, please try again later.');
87
+ }
88
+ },
89
+ webhook(event, email, ctx) {
90
+ const now = new Date();
91
+ const data = {
92
+ created_at: email.createdAt,
93
+ email_id: email.providerMessageId,
94
+ from: fromString(email),
95
+ to: email.to.map((a) => a.address),
96
+ subject: email.subject,
97
+ };
98
+ if (event === 'bounced') {
99
+ data.bounce = { message: 'The recipient’s email address does not exist.', subType: 'General', type: 'Permanent' };
100
+ }
101
+ if (event === 'clicked') {
102
+ data.click = { ipAddress: '127.0.0.1', link: ctx.link, timestamp: now.toISOString(), userAgent: 'Groundhog' };
103
+ }
104
+ if (event === 'opened') {
105
+ data.open = { ipAddress: '127.0.0.1', timestamp: now.toISOString(), userAgent: 'Groundhog' };
106
+ }
107
+ const body = JSON.stringify({ type: EVENT_NAMES[event], created_at: now.toISOString(), data });
108
+ const id = newId('msg');
109
+ const timestamp = unixSeconds(now);
110
+ return {
111
+ eventId: id,
112
+ body,
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ 'svix-id': id,
116
+ 'svix-timestamp': String(timestamp),
117
+ 'svix-signature': svixSignature(ctx.secrets.resend, id, timestamp, body),
118
+ },
119
+ };
120
+ },
121
+ };
@@ -0,0 +1,3 @@
1
+ import type { ProviderModule } from './types.js';
2
+ /** SendGrid's v3 Mail Send, at `/sendgrid/v3/mail/send`. */
3
+ export declare const sendgrid: ProviderModule;