@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 +112 -0
- package/package.json +29 -0
- package/src/inbound/loop_guard.ts +18 -0
- package/src/inbound/mailgun_parser.ts +218 -0
- package/src/inbound/postmark_parser.ts +159 -0
- package/src/inbound/types.ts +82 -0
- package/src/inbound_error.ts +22 -0
- package/src/index.ts +56 -0
- package/src/mail_manager.ts +322 -0
- package/src/mail_provider.ts +58 -0
- package/src/mailable.ts +107 -0
- package/src/message.ts +72 -0
- package/src/transport.ts +34 -0
- package/src/transport_error.ts +31 -0
- package/src/transports/alibaba_transport.ts +273 -0
- package/src/transports/array_transport.ts +36 -0
- package/src/transports/internal/normalize.ts +92 -0
- package/src/transports/log_transport.ts +74 -0
- package/src/transports/mailgun_transport.ts +160 -0
- package/src/transports/resend_transport.ts +149 -0
- package/src/transports/sendgrid_transport.ts +179 -0
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
|
+
}
|