@strav/mail 1.0.0-alpha.25

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 ADDED
@@ -0,0 +1,112 @@
1
+ # @strav/mail
2
+
3
+ Outbound communication for Strav 1.0. Mail layer covers sync send + queued delivery + three production HTTP transports:
4
+
5
+ - `Message` + `MailRecipient` + `MailAddress` + `MessageAttachment` — the plain-data envelope.
6
+ - `Transport` interface — what every backend implements (`send`, optional `close`).
7
+ - `ArrayTransport` — in-memory recorder for tests.
8
+ - `LogTransport` — writes `mail.sent` records to a `Logger` channel; local-dev default.
9
+ - `ResendTransport` + `SendGridTransport` + `MailgunTransport` + `AlibabaDmTransport` — production HTTP transports. Pure-fetch, no SDK deps, no `nodemailer`. Alibaba Cloud DirectMail covers China + SEA deliverability.
10
+ - `MailTransportError` — typed `StravError` raised by transports on send failure; carries `provider` / `status` / `retryable` / `providerError` in `context`.
11
+ - `PostmarkInboundParser` + `MailgunInboundParser` — normalize provider webhooks to `ParsedInboundMail`. Mailgun verifies HMAC-SHA256 + timestamp; Postmark relies on HTTP-level auth (Basic / IP allow-list).
12
+ - `isAutoGeneratedMessage` — mail-loop guard. Honour it before auto-responding.
13
+ - `MailInboundError` — raised when an inbound webhook payload is malformed.
14
+ - `MailManager` — multi-transport orchestration with default-`from` substitution + Mailable-aware `send` overload + lazy/cached transport build.
15
+ - `MailProvider` — wires `config.mail` into the container.
16
+ - `Mailable<TPayload>` — typed `Job` subclass; override `build(payload)`, dispatch via `queue.dispatch(YourMailable, payload)` for async delivery with retries / dead-letter.
17
+
18
+ > **Status:** 1.0.0-alpha — outbound mail layer + Resend + SendGrid + Mailgun + Alibaba DirectMail transports shipped, plus Postmark + Mailgun inbound webhook parsers. Multi-channel fan-out lives in `@strav/notification`. No SMTP transport — see `docs/mail/README.md` for the rationale.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ bun add @strav/mail
24
+ ```
25
+
26
+ Peer: `@strav/kernel`.
27
+
28
+ ## Minimal example
29
+
30
+ ```ts
31
+ // config/mail.ts
32
+ import type { MailConfig } from '@strav/mail'
33
+
34
+ export default {
35
+ default: 'array', // or 'log' in dev, 'smtp' once it ships
36
+ from: { email: 'noreply@acme.com', name: 'Acme' },
37
+ transports: {
38
+ array: { driver: 'array' },
39
+ log: { driver: 'log', channel: 'mail' },
40
+ },
41
+ } satisfies MailConfig
42
+ ```
43
+
44
+ ```ts
45
+ // in a controller or service
46
+ @inject()
47
+ class SignupController {
48
+ constructor(private readonly mail: MailManager) {}
49
+
50
+ async send(email: string): Promise<void> {
51
+ await this.mail.send({
52
+ to: email,
53
+ subject: 'Welcome',
54
+ html: '<h1>Welcome</h1>',
55
+ text: 'Welcome',
56
+ })
57
+ }
58
+ }
59
+ ```
60
+
61
+ ## Test integration
62
+
63
+ ```ts
64
+ await mail.send({ to: 'a@x', subject: 'hi', text: 'h' })
65
+ expect((mail.via() as ArrayTransport).messages[0]?.subject).toBe('hi')
66
+ ```
67
+
68
+ `ArrayTransport.messages` is a frozen view of every send since the last `clear()`.
69
+
70
+ ## Mailable + queue
71
+
72
+ ```ts
73
+ import { Mailable, type Message } from '@strav/mail'
74
+
75
+ class WelcomeEmail extends Mailable<{ name: string }> {
76
+ static override readonly jobName = 'mail.welcome'
77
+ build(payload: { name: string }): Message {
78
+ return { to: `${payload.name}@x`, subject: 'Welcome', text: `Hi ${payload.name}` }
79
+ }
80
+ }
81
+
82
+ // Register with JobRegistry (same as any other Job).
83
+ registry.register(WelcomeEmail)
84
+
85
+ // Dispatch:
86
+ await queue.dispatch(WelcomeEmail, { name: 'Alice' }) // async, retried
87
+ await mail.send(WelcomeEmail, { name: 'Alice' }) // sync, inline
88
+ ```
89
+
90
+ Mailables participate in the full `@strav/queue` lifecycle (retries, backoff, `strav_failed_jobs` dead-letter).
91
+
92
+ ## Inbound webhooks
93
+
94
+ ```ts
95
+ import { MailgunInboundParser, PostmarkInboundParser } from '@strav/mail'
96
+
97
+ const postmark = new PostmarkInboundParser()
98
+ const mailgun = new MailgunInboundParser({ webhookSigningKey: env.MAILGUN_SIGNING_KEY })
99
+
100
+ // In your HTTP handler — pass the raw body + lowercased headers:
101
+ const mail = await mailgun.parse({ body: rawBody, headers: req.headers })
102
+ if (mail.isAutoGenerated) return // mail-loop guard — must honor.
103
+ await onIncoming(mail)
104
+ ```
105
+
106
+ The parsed shape (`ParsedInboundMail`) is identical across providers: `from`/`to`/`cc`/`bcc`, `subject`/`text`/`html`, RFC-5322 `messageId` / `inReplyTo` / `references`, decoded `attachments` as `Buffer`, and `isAutoGenerated` derived from `Auto-Submitted` / `Precedence` / `X-Auto-Response-Suppress`.
107
+
108
+ ## What's NOT here yet
109
+ - Notifications + channel drivers (in `@strav/notification`).
110
+ - Broadcast pub/sub + SSE handler.
111
+
112
+ Full reference: [`docs/mail/api.md`](../../docs/mail/api.md).
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@strav/mail",
3
+ "version": "1.0.0-alpha.25",
4
+ "description": "Strav signal layer — mail (core + array/log transports + Mailable + queue-dispatch); notifications + SSE + broadcast follow in later slices",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "bun": ">=1.3.14"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@strav/kernel": "1.0.0-alpha.25",
23
+ "@strav/queue": "1.0.0-alpha.25"
24
+ },
25
+ "peerDependencies": {
26
+ "@types/bun": ">=1.3.14"
27
+ },
28
+ "devDependencies": null
29
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Detect auto-generated messages the application must not auto-respond to.
3
+ *
4
+ * Without this check, an auto-reply to an auto-reply creates an infinite mail
5
+ * loop. RFC 3834 (Auto-Submitted) and common practice (Precedence,
6
+ * X-Auto-Response-Suppress) are the universally-honored signals.
7
+ */
8
+ export function isAutoGeneratedMessage(headers: Record<string, string>): boolean {
9
+ const autoSubmitted = headers['auto-submitted']?.toLowerCase().trim()
10
+ if (autoSubmitted && autoSubmitted !== 'no') return true
11
+
12
+ const precedence = headers['precedence']?.toLowerCase().trim()
13
+ if (precedence === 'bulk' || precedence === 'junk' || precedence === 'list') return true
14
+
15
+ if (headers['x-auto-response-suppress']) return true
16
+
17
+ return false
18
+ }
@@ -0,0 +1,218 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto'
2
+ import { AuthError, ConfigError } from '@strav/kernel'
3
+ import { MailInboundError } from '../inbound_error.ts'
4
+ import { isAutoGeneratedMessage } from './loop_guard.ts'
5
+ import type {
6
+ InboundWebhookInput,
7
+ InboundWebhookParser,
8
+ MailgunInboundConfig,
9
+ ParsedInboundAddress,
10
+ ParsedInboundAttachment,
11
+ ParsedInboundMail,
12
+ } from './types.ts'
13
+
14
+ type DecodedForm = Awaited<ReturnType<Response['formData']>>
15
+
16
+ /**
17
+ * Parse a Mailgun Routes inbound webhook payload.
18
+ *
19
+ * Mailgun POSTs `multipart/form-data` to the route URL. Authentication uses
20
+ * HMAC-SHA256 over `(timestamp + token)` with the dashboard's webhook signing
21
+ * key. Signature verification is mandatory here — call it through an HTTP
22
+ * route that passes the raw body and `content-type` header unchanged.
23
+ *
24
+ * Throws `AuthError` on signature mismatch or stale timestamp,
25
+ * `MailInboundError` on malformed payloads,
26
+ * `ConfigError` when constructed without a signing key.
27
+ *
28
+ * @see https://documentation.mailgun.com/docs/mailgun/user-manual/receive-forward-store/
29
+ */
30
+ export class MailgunInboundParser implements InboundWebhookParser {
31
+ private readonly signingKey: string
32
+ private readonly maxAgeSeconds: number
33
+
34
+ constructor(config: MailgunInboundConfig) {
35
+ if (!config.webhookSigningKey) {
36
+ throw new ConfigError('MailgunInboundParser requires webhookSigningKey')
37
+ }
38
+ this.signingKey = config.webhookSigningKey
39
+ this.maxAgeSeconds = config.maxAgeSeconds ?? 300
40
+ }
41
+
42
+ async parse(input: InboundWebhookInput): Promise<ParsedInboundMail> {
43
+ const contentType = input.headers['content-type'] ?? ''
44
+ if (!contentType.toLowerCase().includes('multipart/')) {
45
+ throw new MailInboundError(
46
+ `Mailgun inbound webhook: expected multipart/form-data, got: ${contentType || '(none)'}`,
47
+ { context: { provider: 'mailgun', contentType } },
48
+ )
49
+ }
50
+
51
+ const form = await this.decodeMultipart(input.body, contentType)
52
+
53
+ const signature = getField(form, 'signature')
54
+ const token = getField(form, 'token')
55
+ const timestamp = getField(form, 'timestamp')
56
+ if (!signature || !token || !timestamp) {
57
+ throw new AuthError('Mailgun webhook missing signature/token/timestamp')
58
+ }
59
+ this.verifyTimestamp(timestamp)
60
+ this.verifySignature(timestamp, token, signature)
61
+
62
+ const headers = parseMessageHeaders(getField(form, 'message-headers'))
63
+
64
+ const from = parseAddress(getField(form, 'from')) ?? {
65
+ address: getField(form, 'sender') ?? '',
66
+ }
67
+ const to = parseAddressList(getField(form, 'recipient'))
68
+ const cc = parseAddressList(headers['cc'])
69
+ const replyTo = parseAddress(headers['reply-to'])
70
+
71
+ return {
72
+ from,
73
+ to,
74
+ cc,
75
+ bcc: [],
76
+ replyTo,
77
+ subject: getField(form, 'subject') ?? '',
78
+ text: getField(form, 'body-plain') || undefined,
79
+ html: getField(form, 'body-html') || undefined,
80
+ date: headers['date'] ? new Date(headers['date']) : undefined,
81
+ headers,
82
+ attachments: await extractAttachments(form),
83
+ messageId: stripAngles(headers['message-id']),
84
+ inReplyTo: stripAngles(headers['in-reply-to']),
85
+ references: parseReferences(headers['references']),
86
+ isAutoGenerated: isAutoGeneratedMessage(headers),
87
+ }
88
+ }
89
+
90
+ private async decodeMultipart(body: string | Buffer, contentType: string): Promise<DecodedForm> {
91
+ try {
92
+ const payload =
93
+ typeof body === 'string'
94
+ ? body
95
+ : new Uint8Array(body.buffer, body.byteOffset, body.byteLength)
96
+ const response = new Response(payload, { headers: { 'content-type': contentType } })
97
+ return await response.formData()
98
+ } catch (err) {
99
+ throw new MailInboundError(
100
+ `Mailgun inbound webhook: invalid multipart payload — ${(err as Error).message}`,
101
+ { context: { provider: 'mailgun' }, cause: err },
102
+ )
103
+ }
104
+ }
105
+
106
+ private verifySignature(timestamp: string, token: string, signature: string): void {
107
+ const expected = createHmac('sha256', this.signingKey)
108
+ .update(timestamp + token)
109
+ .digest('hex')
110
+
111
+ // Constant-time compare — requires equal length, else timingSafeEqual throws.
112
+ if (expected.length !== signature.length) {
113
+ throw new AuthError('Mailgun webhook signature mismatch')
114
+ }
115
+ const ok = timingSafeEqual(
116
+ new Uint8Array(Buffer.from(expected, 'utf-8')),
117
+ new Uint8Array(Buffer.from(signature, 'utf-8')),
118
+ )
119
+ if (!ok) throw new AuthError('Mailgun webhook signature mismatch')
120
+ }
121
+
122
+ private verifyTimestamp(timestamp: string): void {
123
+ const ts = Number(timestamp)
124
+ if (!Number.isFinite(ts)) {
125
+ throw new AuthError('Mailgun webhook: invalid timestamp')
126
+ }
127
+ const nowSec = Math.floor(Date.now() / 1000)
128
+ if (Math.abs(nowSec - ts) > this.maxAgeSeconds) {
129
+ throw new AuthError('Mailgun webhook: timestamp outside allowed window')
130
+ }
131
+ }
132
+ }
133
+
134
+ function getField(form: DecodedForm, name: string): string | undefined {
135
+ const value = form.get(name)
136
+ if (value === null || value === undefined) return undefined
137
+ return typeof value === 'string' ? value : undefined
138
+ }
139
+
140
+ function parseMessageHeaders(raw: string | undefined): Record<string, string> {
141
+ if (!raw) return {}
142
+ try {
143
+ const pairs = JSON.parse(raw) as unknown
144
+ if (!Array.isArray(pairs)) return {}
145
+ const out: Record<string, string> = {}
146
+ for (const entry of pairs) {
147
+ if (!Array.isArray(entry) || entry.length < 2) continue
148
+ const [name, value] = entry
149
+ if (typeof name === 'string' && typeof value === 'string') {
150
+ out[name.toLowerCase()] = value
151
+ }
152
+ }
153
+ return out
154
+ } catch {
155
+ return {}
156
+ }
157
+ }
158
+
159
+ async function extractAttachments(form: DecodedForm): Promise<ParsedInboundAttachment[]> {
160
+ const count = Number(form.get('attachment-count') ?? 0)
161
+ if (!Number.isFinite(count) || count <= 0) return []
162
+
163
+ const result: ParsedInboundAttachment[] = []
164
+ for (let i = 1; i <= count; i++) {
165
+ const file = form.get(`attachment-${i}`)
166
+ if (!file || typeof file === 'string') continue
167
+ const content = Buffer.from(await file.arrayBuffer())
168
+ result.push({
169
+ filename: file.name || `attachment-${i}`,
170
+ contentType: file.type || 'application/octet-stream',
171
+ content,
172
+ size: content.length,
173
+ })
174
+ }
175
+ return result
176
+ }
177
+
178
+ function parseAddress(raw: string | undefined): ParsedInboundAddress | undefined {
179
+ if (!raw) return undefined
180
+ const trimmed = raw.trim()
181
+ if (!trimmed) return undefined
182
+
183
+ const match = trimmed.match(/^\s*(?:"?([^"<]*?)"?\s*)?<([^<>\s]+@[^<>\s]+)>\s*$/)
184
+ if (match) {
185
+ const name = match[1]?.trim()
186
+ const address = match[2]!
187
+ return name ? { address, name } : { address }
188
+ }
189
+ if (/^[^<>\s]+@[^<>\s]+$/.test(trimmed)) return { address: trimmed }
190
+ return undefined
191
+ }
192
+
193
+ function parseAddressList(raw: string | undefined): ParsedInboundAddress[] {
194
+ if (!raw) return []
195
+ // Mailgun delivers To / recipient as a comma-separated list.
196
+ return raw
197
+ .split(',')
198
+ .map((part) => parseAddress(part))
199
+ .filter((v): v is ParsedInboundAddress => v !== undefined)
200
+ }
201
+
202
+ function parseReferences(value: string | undefined): string[] {
203
+ if (!value) return []
204
+ return value
205
+ .split(/\s+/)
206
+ .map((r) => stripAngles(r))
207
+ .filter((v): v is string => Boolean(v))
208
+ }
209
+
210
+ function stripAngles(value: string | undefined): string | undefined {
211
+ if (!value) return undefined
212
+ const trimmed = value.trim()
213
+ if (trimmed.startsWith('<') && trimmed.endsWith('>')) {
214
+ const inner = trimmed.slice(1, -1).trim()
215
+ return inner || undefined
216
+ }
217
+ return trimmed || undefined
218
+ }
@@ -0,0 +1,159 @@
1
+ import { MailInboundError } from '../inbound_error.ts'
2
+ import { isAutoGeneratedMessage } from './loop_guard.ts'
3
+ import type {
4
+ InboundWebhookInput,
5
+ InboundWebhookParser,
6
+ ParsedInboundAddress,
7
+ ParsedInboundAttachment,
8
+ ParsedInboundMail,
9
+ } from './types.ts'
10
+
11
+ /**
12
+ * Parse a Postmark Inbound webhook payload into `ParsedInboundMail`.
13
+ *
14
+ * Postmark does NOT sign inbound webhooks with HMAC. Authenticate the request
15
+ * at the HTTP layer — Basic Auth on the webhook URL and/or IP allow-listing —
16
+ * before handing the body to this parser.
17
+ *
18
+ * @see https://postmarkapp.com/developer/user-guide/inbound/parse-an-email
19
+ */
20
+ export class PostmarkInboundParser implements InboundWebhookParser {
21
+ async parse(input: InboundWebhookInput): Promise<ParsedInboundMail> {
22
+ const payload = this.decode(input.body)
23
+ const headers = this.extractHeaders(payload.Headers ?? [])
24
+
25
+ const from = this.mapAddress(payload.FromFull) ?? {
26
+ address: payload.From ?? '',
27
+ ...(payload.FromName ? { name: payload.FromName } : {}),
28
+ }
29
+
30
+ const to = mapList(payload.ToFull, (a) => this.mapAddress(a))
31
+ const cc = mapList(payload.CcFull, (a) => this.mapAddress(a))
32
+ const bcc = mapList(payload.BccFull, (a) => this.mapAddress(a))
33
+ const replyTo = payload.ReplyTo ? { address: payload.ReplyTo } : undefined
34
+
35
+ return {
36
+ from,
37
+ to,
38
+ cc,
39
+ bcc,
40
+ replyTo,
41
+ subject: payload.Subject ?? '',
42
+ text: payload.TextBody || undefined,
43
+ html: payload.HtmlBody || undefined,
44
+ date: payload.Date ? new Date(payload.Date) : undefined,
45
+ headers,
46
+ attachments: this.mapAttachments(payload.Attachments ?? []),
47
+ messageId: stripAngles(headers['message-id']),
48
+ inReplyTo: stripAngles(headers['in-reply-to']),
49
+ references: parseReferences(headers['references']),
50
+ isAutoGenerated: isAutoGeneratedMessage(headers),
51
+ providerMessageId: payload.MessageID,
52
+ }
53
+ }
54
+
55
+ private decode(body: string | Buffer): PostmarkInboundPayload {
56
+ try {
57
+ const text = typeof body === 'string' ? body : body.toString('utf-8')
58
+ return JSON.parse(text) as PostmarkInboundPayload
59
+ } catch (err) {
60
+ throw new MailInboundError(
61
+ `Postmark inbound webhook: invalid JSON payload — ${(err as Error).message}`,
62
+ { context: { provider: 'postmark' }, cause: err },
63
+ )
64
+ }
65
+ }
66
+
67
+ private mapAddress(addr?: PostmarkAddress): ParsedInboundAddress | undefined {
68
+ if (!addr?.Email) return undefined
69
+ return addr.Name ? { address: addr.Email, name: addr.Name } : { address: addr.Email }
70
+ }
71
+
72
+ private extractHeaders(headers: PostmarkHeader[]): Record<string, string> {
73
+ const result: Record<string, string> = {}
74
+ for (const h of headers) {
75
+ if (h.Name) result[h.Name.toLowerCase()] = h.Value
76
+ }
77
+ return result
78
+ }
79
+
80
+ private mapAttachments(atts: PostmarkAttachment[]): ParsedInboundAttachment[] {
81
+ return atts.map((a) => {
82
+ const cid = a.ContentID ? stripAngles(a.ContentID) : undefined
83
+ return {
84
+ filename: a.Name,
85
+ contentType: a.ContentType,
86
+ content: Buffer.from(a.Content, 'base64'),
87
+ size: a.ContentLength,
88
+ ...(cid ? { cid } : {}),
89
+ }
90
+ })
91
+ }
92
+ }
93
+
94
+ interface PostmarkAddress {
95
+ Email: string
96
+ Name?: string
97
+ MailboxHash?: string
98
+ }
99
+
100
+ interface PostmarkHeader {
101
+ Name: string
102
+ Value: string
103
+ }
104
+
105
+ interface PostmarkAttachment {
106
+ Name: string
107
+ ContentType: string
108
+ Content: string
109
+ ContentLength: number
110
+ ContentID?: string | null
111
+ }
112
+
113
+ interface PostmarkInboundPayload {
114
+ From?: string
115
+ FromName?: string
116
+ FromFull?: PostmarkAddress
117
+ To?: string
118
+ ToFull?: PostmarkAddress[]
119
+ Cc?: string
120
+ CcFull?: PostmarkAddress[]
121
+ Bcc?: string
122
+ BccFull?: PostmarkAddress[]
123
+ ReplyTo?: string
124
+ Subject?: string
125
+ MessageID?: string
126
+ Date?: string
127
+ TextBody?: string
128
+ HtmlBody?: string
129
+ Headers?: PostmarkHeader[]
130
+ Attachments?: PostmarkAttachment[]
131
+ }
132
+
133
+ function mapList<T, R>(list: T[] | undefined, fn: (item: T) => R | undefined): NonNullable<R>[] {
134
+ if (!list) return []
135
+ const out: NonNullable<R>[] = []
136
+ for (const item of list) {
137
+ const mapped = fn(item)
138
+ if (mapped !== undefined && mapped !== null) out.push(mapped as NonNullable<R>)
139
+ }
140
+ return out
141
+ }
142
+
143
+ function stripAngles(value: string | undefined): string | undefined {
144
+ if (!value) return undefined
145
+ const trimmed = value.trim()
146
+ if (trimmed.startsWith('<') && trimmed.endsWith('>')) {
147
+ const inner = trimmed.slice(1, -1).trim()
148
+ return inner || undefined
149
+ }
150
+ return trimmed || undefined
151
+ }
152
+
153
+ function parseReferences(value: string | undefined): string[] {
154
+ if (!value) return []
155
+ return value
156
+ .split(/\s+/)
157
+ .map((ref) => stripAngles(ref))
158
+ .filter((v): v is string => Boolean(v))
159
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Inbound mail types.
3
+ *
4
+ * Provider webhooks (Postmark / Mailgun) — and any future driver — all
5
+ * normalize to `ParsedInboundMail` so application code can consume inbound
6
+ * mail from any source uniformly.
7
+ */
8
+
9
+ export interface ParsedInboundAddress {
10
+ address: string
11
+ name?: string
12
+ }
13
+
14
+ export interface ParsedInboundAttachment {
15
+ filename: string
16
+ contentType: string
17
+ content: Buffer
18
+ size: number
19
+ /** Content-ID for inline images, angle brackets stripped. */
20
+ cid?: string
21
+ }
22
+
23
+ export interface ParsedInboundMail {
24
+ from: ParsedInboundAddress
25
+ to: ParsedInboundAddress[]
26
+ cc: ParsedInboundAddress[]
27
+ bcc: ParsedInboundAddress[]
28
+ replyTo?: ParsedInboundAddress
29
+ subject: string
30
+ text?: string
31
+ html?: string
32
+ date?: Date
33
+ /**
34
+ * Lowercased header name → value. Duplicate headers keep the last value;
35
+ * widen to `string | string[]` if a future driver needs to preserve duplicates.
36
+ */
37
+ headers: Record<string, string>
38
+ attachments: ParsedInboundAttachment[]
39
+ /** RFC 5322 Message-ID of the inbound message, angle brackets stripped. */
40
+ messageId?: string
41
+ /** In-Reply-To header value, angle brackets stripped. */
42
+ inReplyTo?: string
43
+ /** References header parsed into a list, angle brackets stripped. */
44
+ references: string[]
45
+ /**
46
+ * True if the message looks auto-generated (auto-reply, vacation, bulk, list).
47
+ * Applications must not auto-respond when this is true — skipping this check
48
+ * causes mail loops.
49
+ */
50
+ isAutoGenerated: boolean
51
+ /** Provider's own message identifier (e.g. Postmark MessageID), if any. */
52
+ providerMessageId?: string
53
+ }
54
+
55
+ export interface InboundWebhookInput {
56
+ /**
57
+ * Raw request body. `Buffer` preferred so signature checks see the exact
58
+ * bytes the provider signed.
59
+ */
60
+ body: string | Buffer
61
+ /** Request headers. Keys must be lowercased by the caller. */
62
+ headers: Record<string, string | undefined>
63
+ }
64
+
65
+ export interface InboundWebhookParser {
66
+ parse(input: InboundWebhookInput): Promise<ParsedInboundMail>
67
+ }
68
+
69
+ // -- Mailgun Routes webhook ---------------------------------------------------
70
+
71
+ export interface MailgunInboundConfig {
72
+ /**
73
+ * Mailgun "HTTP webhook signing key" from the dashboard — distinct from the
74
+ * sending API key. Rotating it in the dashboard requires rotating here too.
75
+ */
76
+ webhookSigningKey: string
77
+ /**
78
+ * Reject signatures whose timestamp is older than N seconds. Default 300
79
+ * (5 minutes). Tightens replay protection; widen only if clock skew is an issue.
80
+ */
81
+ maxAgeSeconds?: number
82
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `MailInboundError` — typed error raised by inbound webhook parsers when
3
+ * the payload itself is malformed (bad JSON, wrong content-type, missing
4
+ * required fields).
5
+ *
6
+ * For signature / signing-key failures, parsers throw `AuthError` from
7
+ * `@strav/kernel`. For misconfiguration at construction time, they throw
8
+ * `ConfigError`.
9
+ *
10
+ * Carry the upstream provider's HTTP status (the one the parser would
11
+ * return to the provider's webhook delivery system) under `context.status`.
12
+ * The error's own `status` is fixed at 400 — the inbound webhook delivered
13
+ * something we could not parse.
14
+ */
15
+
16
+ import { StravError, type StravErrorOptions } from '@strav/kernel'
17
+
18
+ export class MailInboundError extends StravError {
19
+ constructor(message: string, options: StravErrorOptions = {}) {
20
+ super(message, { code: 'mail-inbound-error', status: 400 }, options)
21
+ }
22
+ }