@avelonjs/resend 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.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/package.json +50 -0
- package/src/driver.ts +197 -0
- package/src/index.ts +7 -0
- package/src/local-mail.ts +117 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan Yannelli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @avelonjs/resend
|
|
2
|
+
|
|
3
|
+
`@avelonjs/resend` is the Resend mail driver for Avelon. It sends transport-neutral `MailMessage` values over HTTP and maps malformed recipients to `Invalid`. Reach for this package when you want transactional mail without importing the Resend SDK in application code.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @avelonjs/resend
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
export RESEND_API_KEY=re_your_key
|
|
13
|
+
export RESEND_FROM=mailer@example.test
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Basic Usage
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { createResendMail } from '@avelonjs/resend'
|
|
20
|
+
|
|
21
|
+
const mail = createResendMail()
|
|
22
|
+
|
|
23
|
+
await mail.send({
|
|
24
|
+
to: 'reader@example.test',
|
|
25
|
+
subject: 'Welcome',
|
|
26
|
+
text: 'Your account is ready.',
|
|
27
|
+
html: '<p>Your account is ready.</p>',
|
|
28
|
+
})
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Capabilities
|
|
32
|
+
|
|
33
|
+
| Capability | Value | Notes |
|
|
34
|
+
| ----------- | ------ | --------------------------------------------------------- |
|
|
35
|
+
| `templates` | `true` | `sendTemplate()` posts a hosted template id and variables |
|
|
36
|
+
|
|
37
|
+
## Hosted Templates
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { createResendMail } from '@avelonjs/resend'
|
|
41
|
+
|
|
42
|
+
const mail = createResendMail()
|
|
43
|
+
await mail.sendTemplate('welcome-message', ['reader@example.test'], { actorName: 'Morgan' })
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Method Reference
|
|
47
|
+
|
|
48
|
+
| Method | Signature | Description |
|
|
49
|
+
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
|
|
50
|
+
| `createResendMail` | `(options?: ResendMailOptions) => ResendMail` | Constructs a Resend HTTP mail driver. |
|
|
51
|
+
| `resendMailCapabilities` | `{ templates: true }` | Exact capability declaration for the Resend driver. |
|
|
52
|
+
| `ResendMailOptions` | `interface` | API key, endpoint, instance, and default sender. |
|
|
53
|
+
| `ResendMail.send` | `(message: MailMessage) => Promise<MailReceipt>` | Sends one transactional message. |
|
|
54
|
+
| `ResendMail.sendTemplate` | `(template: string, to: string \| readonly string[], variables: Readonly<Record<string, unknown>>) => Promise<MailReceipt>` | Sends a hosted template. |
|
|
55
|
+
| `ResendMail.raw` | `() => { endpoint: string }` | Returns the API root at the vendor boundary. |
|
|
56
|
+
| `ResendMail.sent` | `readonly MailMessage[]` | Copies of messages this driver accepted. |
|
|
57
|
+
| `ResendMail.reset` | `() => void` | Clears recorded messages between tests. |
|
|
58
|
+
| `LocalResendServer.start` | `() => Promise<string>` | Starts a Resend-shaped HTTP fixture. |
|
|
59
|
+
| `LocalResendServer.reset` | `() => void` | Clears accepted fixture messages. |
|
|
60
|
+
| `LocalResendServer.stop` | `() => Promise<void>` | Stops the fixture listener. |
|
|
61
|
+
| `LocalResendServer.url` | `string` | Base API URL after `start()`. |
|
|
62
|
+
| `LocalResendServer.sent` | `readonly MailMessage[]` | Messages the fixture accepted, in send order. |
|
|
63
|
+
|
|
64
|
+
## Testing
|
|
65
|
+
|
|
66
|
+
Run the shared mail conformance suite against `LocalResendServer` when `RESEND_API_KEY` is absent. Live Resend verification requires a real API key and is fail-closed in this environment.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { mailSuite } from '@avelonjs/conformance/suites'
|
|
70
|
+
import { createResendMail, LocalResendServer } from '@avelonjs/resend'
|
|
71
|
+
|
|
72
|
+
const server = new LocalResendServer()
|
|
73
|
+
const endpoint = await server.start()
|
|
74
|
+
|
|
75
|
+
mailSuite({
|
|
76
|
+
name: 'resend local fixture',
|
|
77
|
+
create: () => createResendMail({ endpoint, defaultFrom: 'mailer@example.test' }),
|
|
78
|
+
})
|
|
79
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@avelonjs/resend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Resend HTTP mail driver for Avelon.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Ryan Yannelli <ryanyannelli@gmail.com>",
|
|
8
|
+
"homepage": "https://github.com/yannelli/avelon",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/yannelli/avelon.git",
|
|
12
|
+
"directory": "packages/resend"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/yannelli/avelon/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"avelon",
|
|
19
|
+
"typescript",
|
|
20
|
+
"resend",
|
|
21
|
+
"email"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./src/index.ts"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "bun test",
|
|
37
|
+
"typecheck": "tsc --noEmit"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@avelonjs/core": "workspace:*"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@avelonjs/conformance": "workspace:*",
|
|
44
|
+
"@types/bun": "1.3.14",
|
|
45
|
+
"typescript": "5.9.3"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"bun": ">=1.3.14"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/driver.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Invalid,
|
|
3
|
+
type MailDriver,
|
|
4
|
+
type MailMessage,
|
|
5
|
+
type MailReceipt,
|
|
6
|
+
type TemplateMailSurface,
|
|
7
|
+
} from '@avelonjs/core'
|
|
8
|
+
|
|
9
|
+
/** Exact capability declaration for the Resend mail driver. */
|
|
10
|
+
export const resendMailCapabilities = {
|
|
11
|
+
templates: true,
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
/** Construction options for {@link createResendMail}. */
|
|
15
|
+
export interface ResendMailOptions {
|
|
16
|
+
/** Resend API key sent as a bearer token. */
|
|
17
|
+
apiKey?: string
|
|
18
|
+
/** API root. Defaults to `https://api.resend.com`. */
|
|
19
|
+
endpoint?: string
|
|
20
|
+
/** Configured mailer name. */
|
|
21
|
+
instance?: string
|
|
22
|
+
/** Sender used when a message omits `from`. */
|
|
23
|
+
defaultFrom?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function addressList(value: string | readonly string[] | undefined): readonly string[] {
|
|
27
|
+
if (value === undefined) return []
|
|
28
|
+
return typeof value === 'string' ? [value] : value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validAddress(address: string): boolean {
|
|
32
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateAddresses(
|
|
36
|
+
message: Pick<MailMessage, 'to' | 'from' | 'replyTo' | 'cc' | 'bcc'>,
|
|
37
|
+
): void {
|
|
38
|
+
const fields: readonly [string, readonly string[]][] = [
|
|
39
|
+
['to', addressList(message.to)],
|
|
40
|
+
['from', addressList(message.from)],
|
|
41
|
+
['replyTo', addressList(message.replyTo)],
|
|
42
|
+
['cc', addressList(message.cc)],
|
|
43
|
+
['bcc', addressList(message.bcc)],
|
|
44
|
+
]
|
|
45
|
+
for (const [field, addresses] of fields) {
|
|
46
|
+
const malformed = addresses.find((address) => !validAddress(address))
|
|
47
|
+
if (malformed) {
|
|
48
|
+
throw new Invalid(`Invalid email address in ${field}.`, {
|
|
49
|
+
metadata: { fields: { [field]: [`${malformed} is not a valid email address.`] } },
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function copyMessage(message: MailMessage, defaultFrom: string): MailMessage {
|
|
56
|
+
return {
|
|
57
|
+
to: typeof message.to === 'string' ? message.to : [...message.to],
|
|
58
|
+
from: message.from ?? defaultFrom,
|
|
59
|
+
...(message.replyTo === undefined
|
|
60
|
+
? {}
|
|
61
|
+
: { replyTo: typeof message.replyTo === 'string' ? message.replyTo : [...message.replyTo] }),
|
|
62
|
+
...(message.cc === undefined ? {} : { cc: [...message.cc] }),
|
|
63
|
+
...(message.bcc === undefined ? {} : { bcc: [...message.bcc] }),
|
|
64
|
+
subject: message.subject,
|
|
65
|
+
...(message.text === undefined ? {} : { text: message.text }),
|
|
66
|
+
...(message.html === undefined ? {} : { html: message.html }),
|
|
67
|
+
...(message.attachments === undefined
|
|
68
|
+
? {}
|
|
69
|
+
: {
|
|
70
|
+
attachments: message.attachments.map((attachment) => ({
|
|
71
|
+
filename: attachment.filename,
|
|
72
|
+
content: attachment.content.slice(),
|
|
73
|
+
...(attachment.contentType === undefined
|
|
74
|
+
? {}
|
|
75
|
+
: { contentType: attachment.contentType }),
|
|
76
|
+
})),
|
|
77
|
+
}),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resend mail driver.
|
|
83
|
+
*
|
|
84
|
+
* Vendor access is HTTP to the Resend emails API. Application code never imports the Resend SDK.
|
|
85
|
+
*/
|
|
86
|
+
export class ResendMail
|
|
87
|
+
implements MailDriver<typeof resendMailCapabilities, { endpoint: string }>, TemplateMailSurface
|
|
88
|
+
{
|
|
89
|
+
readonly name = 'resend'
|
|
90
|
+
readonly instance: string
|
|
91
|
+
readonly capabilities = resendMailCapabilities
|
|
92
|
+
|
|
93
|
+
readonly #apiKey: string
|
|
94
|
+
readonly #endpoint: string
|
|
95
|
+
readonly #defaultFrom: string
|
|
96
|
+
readonly #sent: MailMessage[] = []
|
|
97
|
+
|
|
98
|
+
constructor(options: Required<ResendMailOptions>) {
|
|
99
|
+
this.instance = options.instance
|
|
100
|
+
this.#apiKey = options.apiKey
|
|
101
|
+
this.#endpoint = options.endpoint.replace(/\/$/, '')
|
|
102
|
+
this.#defaultFrom = options.defaultFrom
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Returns copies of messages this driver accepted. */
|
|
106
|
+
get sent(): readonly MailMessage[] {
|
|
107
|
+
return this.#sent.map((message) => copyMessage(message, this.#defaultFrom))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
raw(): { endpoint: string } {
|
|
111
|
+
return { endpoint: this.#endpoint }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Clears recorded messages between isolated suite cases. */
|
|
115
|
+
reset(): void {
|
|
116
|
+
this.#sent.length = 0
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async send(message: MailMessage): Promise<MailReceipt> {
|
|
120
|
+
const normalized = copyMessage(message, this.#defaultFrom)
|
|
121
|
+
validateAddresses(normalized)
|
|
122
|
+
const receipt = await this.#post('/emails', {
|
|
123
|
+
from: normalized.from,
|
|
124
|
+
to: addressList(normalized.to),
|
|
125
|
+
cc: addressList(normalized.cc),
|
|
126
|
+
bcc: addressList(normalized.bcc),
|
|
127
|
+
reply_to: addressList(normalized.replyTo),
|
|
128
|
+
subject: normalized.subject,
|
|
129
|
+
text: normalized.text,
|
|
130
|
+
html: normalized.html,
|
|
131
|
+
attachments: normalized.attachments?.map((attachment) => ({
|
|
132
|
+
filename: attachment.filename,
|
|
133
|
+
content: Buffer.from(attachment.content).toString('base64'),
|
|
134
|
+
content_type: attachment.contentType,
|
|
135
|
+
})),
|
|
136
|
+
})
|
|
137
|
+
this.#sent.push(normalized)
|
|
138
|
+
return {
|
|
139
|
+
id: receipt,
|
|
140
|
+
accepted: [
|
|
141
|
+
...addressList(normalized.to),
|
|
142
|
+
...addressList(normalized.cc),
|
|
143
|
+
...addressList(normalized.bcc),
|
|
144
|
+
],
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async sendTemplate(
|
|
149
|
+
template: string,
|
|
150
|
+
to: string | readonly string[],
|
|
151
|
+
variables: Readonly<Record<string, unknown>>,
|
|
152
|
+
): Promise<MailReceipt> {
|
|
153
|
+
validateAddresses({ to, from: this.#defaultFrom })
|
|
154
|
+
const recipients = addressList(to)
|
|
155
|
+
const receipt = await this.#post('/emails', {
|
|
156
|
+
from: this.#defaultFrom,
|
|
157
|
+
to: recipients,
|
|
158
|
+
template,
|
|
159
|
+
data: variables,
|
|
160
|
+
subject: template,
|
|
161
|
+
})
|
|
162
|
+
this.#sent.push({
|
|
163
|
+
to: typeof to === 'string' ? to : [...to],
|
|
164
|
+
from: this.#defaultFrom,
|
|
165
|
+
subject: template,
|
|
166
|
+
})
|
|
167
|
+
return { id: receipt, accepted: [...recipients] }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async #post(path: string, body: Record<string, unknown>): Promise<string> {
|
|
171
|
+
const response = await fetch(`${this.#endpoint}${path}`, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: {
|
|
174
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify(body),
|
|
178
|
+
})
|
|
179
|
+
const parsed = (await response.json()) as { id?: string; message?: string }
|
|
180
|
+
if (!response.ok || typeof parsed.id !== 'string') {
|
|
181
|
+
throw new Invalid(parsed.message ?? 'Resend rejected the message.', {
|
|
182
|
+
metadata: { fields: { mail: [parsed.message ?? 'send failed'] } },
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
return parsed.id
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Creates a Resend mail driver from options or environment defaults. */
|
|
190
|
+
export function createResendMail(options: ResendMailOptions = {}): ResendMail {
|
|
191
|
+
return new ResendMail({
|
|
192
|
+
apiKey: options.apiKey ?? process.env.RESEND_API_KEY ?? 're_local_fixture',
|
|
193
|
+
endpoint: options.endpoint ?? process.env.RESEND_ENDPOINT ?? 'https://api.resend.com',
|
|
194
|
+
instance: options.instance ?? 'default',
|
|
195
|
+
defaultFrom: options.defaultFrom ?? process.env.RESEND_FROM ?? 'mailer@example.test',
|
|
196
|
+
})
|
|
197
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { MailAttachment, MailMessage } from '@avelonjs/core'
|
|
2
|
+
|
|
3
|
+
interface StoredMail {
|
|
4
|
+
message: MailMessage
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function json(body: unknown, status = 200): Response {
|
|
8
|
+
return new Response(JSON.stringify(body), {
|
|
9
|
+
status,
|
|
10
|
+
headers: { 'Content-Type': 'application/json' },
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimal Resend-shaped HTTP fixture for mail conformance.
|
|
16
|
+
*
|
|
17
|
+
* Real deployments point {@link createResendMail} at `https://api.resend.com`. This server exists so
|
|
18
|
+
* envelope and attachment round trips can run without a Resend API key.
|
|
19
|
+
*/
|
|
20
|
+
export class LocalResendServer {
|
|
21
|
+
readonly #sent: StoredMail[] = []
|
|
22
|
+
#server: ReturnType<typeof Bun.serve> | undefined
|
|
23
|
+
#url = ''
|
|
24
|
+
#nextId = 1
|
|
25
|
+
|
|
26
|
+
/** Base API URL, e.g. `http://127.0.0.1:4010`. */
|
|
27
|
+
get url(): string {
|
|
28
|
+
return this.#url
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Messages accepted by this fixture, in send order. */
|
|
32
|
+
get sent(): readonly MailMessage[] {
|
|
33
|
+
return this.#sent.map((entry) => entry.message)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Starts the server on an ephemeral port. */
|
|
37
|
+
async start(): Promise<string> {
|
|
38
|
+
const self = this
|
|
39
|
+
this.#server = Bun.serve({
|
|
40
|
+
port: 0,
|
|
41
|
+
async fetch(request) {
|
|
42
|
+
return self.#handle(request)
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
this.#url = `http://127.0.0.1:${this.#server.port}`
|
|
46
|
+
return this.#url
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Clears accepted messages without restarting the listener. */
|
|
50
|
+
reset(): void {
|
|
51
|
+
this.#sent.length = 0
|
|
52
|
+
this.#nextId = 1
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Stops the fixture listener. */
|
|
56
|
+
async stop(): Promise<void> {
|
|
57
|
+
this.#server?.stop(true)
|
|
58
|
+
this.#server = undefined
|
|
59
|
+
this.reset()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async #handle(request: Request): Promise<Response> {
|
|
63
|
+
const url = new URL(request.url)
|
|
64
|
+
if (request.method !== 'POST' || url.pathname !== '/emails') {
|
|
65
|
+
return json({ message: 'not_found' }, 404)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const body = (await request.json()) as Record<string, unknown>
|
|
69
|
+
const id = `re_${this.#nextId++}`
|
|
70
|
+
const to = asAddressList(body.to)
|
|
71
|
+
const cc = asAddressList(body.cc)
|
|
72
|
+
const bcc = asAddressList(body.bcc)
|
|
73
|
+
const replyTo = asAddressList(body.reply_to)
|
|
74
|
+
const attachments = asAttachments(body.attachments)
|
|
75
|
+
|
|
76
|
+
this.#sent.push({
|
|
77
|
+
message: {
|
|
78
|
+
to: to.length === 1 ? (to[0] as string) : to,
|
|
79
|
+
from: typeof body.from === 'string' ? body.from : undefined,
|
|
80
|
+
...(replyTo.length === 0 ? {} : { replyTo: replyTo.length === 1 ? replyTo[0] : replyTo }),
|
|
81
|
+
...(cc.length === 0 ? {} : { cc }),
|
|
82
|
+
...(bcc.length === 0 ? {} : { bcc }),
|
|
83
|
+
subject: typeof body.subject === 'string' ? body.subject : String(body.template ?? ''),
|
|
84
|
+
...(typeof body.text === 'string' ? { text: body.text } : {}),
|
|
85
|
+
...(typeof body.html === 'string' ? { html: body.html } : {}),
|
|
86
|
+
...(attachments === undefined ? {} : { attachments }),
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return json({ id })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function asAddressList(value: unknown): string[] {
|
|
95
|
+
if (typeof value === 'string') return [value]
|
|
96
|
+
if (!Array.isArray(value)) return []
|
|
97
|
+
return value.filter((entry): entry is string => typeof entry === 'string')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function asAttachments(value: unknown): readonly MailAttachment[] | undefined {
|
|
101
|
+
if (!Array.isArray(value)) return undefined
|
|
102
|
+
return value.flatMap((entry) => {
|
|
103
|
+
if (typeof entry !== 'object' || entry === null) return []
|
|
104
|
+
const filename = Reflect.get(entry, 'filename')
|
|
105
|
+
const content = Reflect.get(entry, 'content')
|
|
106
|
+
const contentType = Reflect.get(entry, 'content_type')
|
|
107
|
+
if (typeof filename !== 'string' || typeof content !== 'string') return []
|
|
108
|
+
const bytes = Buffer.from(content, 'base64')
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
filename,
|
|
112
|
+
content: new Uint8Array(bytes),
|
|
113
|
+
...(typeof contentType === 'string' ? { contentType } : {}),
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
})
|
|
117
|
+
}
|