@thomasfosterau/effect-connect-resend 0.0.0 → 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 +118 -2
- package/dist/index.d.ts +214 -0
- package/dist/index.js +310 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -4
- package/src/Resend.ts +266 -0
- package/src/ResendConfig.ts +81 -0
- package/src/index.ts +110 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Foster
|
|
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
CHANGED
|
@@ -1,3 +1,119 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@thomasfosterau/effect-connect-resend`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Resend provider for [`@thomasfosterau/effect-connect`](https://github.com/thomasfosterau/effect-connect/tree/trunk/packages/core#readme): `POST /emails` over Effect's `HttpClient` (`effect/http`) with no SDK, a `Redacted` API key, a no-op layer for un-provisioned stages and an env-driven resolver that picks between the two.
|
|
4
|
+
|
|
5
|
+
This package was created using Claude Code, and its API is in flux: expect churn and breaking changes.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @thomasfosterau/effect-connect-resend @thomasfosterau/effect-connect effect
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`effect` (`^4.0.0`) is a required peer dependency. Node.js 22 or newer is required. Nothing here is Node-shaped: the provider runs on Cloudflare Workers and in browsers as well as on Node.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import * as Effect from "effect/Effect";
|
|
19
|
+
import * as Layer from "effect/Layer";
|
|
20
|
+
import * as Redacted from "effect/Redacted";
|
|
21
|
+
import { FetchHttpClient } from "effect/http";
|
|
22
|
+
import { EmailService } from "@thomasfosterau/effect-connect";
|
|
23
|
+
import * as Resend from "@thomasfosterau/effect-connect-resend";
|
|
24
|
+
|
|
25
|
+
const EmailLive = Resend.layer({ apiKey: Redacted.make(process.env.RESEND_API_KEY!) }).pipe(
|
|
26
|
+
Layer.provide(FetchHttpClient.layer),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const program = Effect.gen(function* () {
|
|
30
|
+
const email = yield* EmailService;
|
|
31
|
+
const result = yield* email.send({
|
|
32
|
+
from: { email: "noreply@example.com", name: "Example" },
|
|
33
|
+
to: [{ email: "reader@example.com" }],
|
|
34
|
+
subject: "Welcome",
|
|
35
|
+
text: "Welcome aboard.",
|
|
36
|
+
html: "<p>Welcome aboard.</p>",
|
|
37
|
+
});
|
|
38
|
+
// result.id is Resend's email id, as an Option (always `some` here)
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`Resend.layerConfig` reads `RESEND_API_KEY` (and an optional `RESEND_BASE_URL`) through `effect/Config` instead. It fails at layer construction with a `ConfigError` when `RESEND_API_KEY` is unset or empty (an empty variable counts as missing); an empty `RESEND_BASE_URL` falls back to the default host. On a Worker whose stages may lack the key, use `resolveTransport(env)` below.
|
|
43
|
+
|
|
44
|
+
## API
|
|
45
|
+
|
|
46
|
+
| Export | What it is |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `layer(config)`, `layerConfig` | The `EmailService` over the `HttpClient` in context, from literal config or from `RESEND_API_KEY` / `RESEND_BASE_URL`. |
|
|
49
|
+
| `resolveTransport(env)`, `apiKeyFromEnv(env)`, `ResendEnv` | Pick the live sender or the no-op from a Worker env; needs no `HttpClient`. |
|
|
50
|
+
| `noop` | The `EmailService` that succeeds without sending and logs the subject only. |
|
|
51
|
+
| `ResendConfig`, `ResendConfigInput`, `DEFAULT_BASE_URL` | The credential and host as a service. |
|
|
52
|
+
| `toResendPayload`, `formatAddress`, `ResendSendPayload` | The core-message → `POST /emails` mapping. |
|
|
53
|
+
| `layerEmailService` | The `EmailService` over a `ResendConfig` and `HttpClient` you provide yourself. |
|
|
54
|
+
|
|
55
|
+
## On a Worker: `resolveTransport(env)`
|
|
56
|
+
|
|
57
|
+
The consuming applications' preview and dev stages deploy without the credential. `resolveTransport` is the one call a Worker makes: the live sender on the platform `fetch` when `RESEND_API_KEY` is bound and non-blank (as a string or a `Redacted`), the logging no-op otherwise, so the fallback cannot be forgotten.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import * as Effect from "effect/Effect";
|
|
61
|
+
import type { EmailService } from "@thomasfosterau/effect-connect";
|
|
62
|
+
import * as Resend from "@thomasfosterau/effect-connect-resend";
|
|
63
|
+
|
|
64
|
+
// Your request handler; it sends through the EmailService in context.
|
|
65
|
+
declare const handle: (request: Request) => Effect.Effect<Response, never, EmailService>;
|
|
66
|
+
|
|
67
|
+
export default {
|
|
68
|
+
fetch: (request: Request, env: Resend.ResendEnv) =>
|
|
69
|
+
handle(request).pipe(Effect.provide(Resend.resolveTransport(env)), Effect.runPromise),
|
|
70
|
+
};
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`Resend.noop` succeeds without sending and logs the subject only — never the recipient, never the body (a verification URL is the token). That trade is only safe while the mail is advisory; a send that gates an account should fail loudly at boot instead.
|
|
74
|
+
|
|
75
|
+
## What the transport pins
|
|
76
|
+
|
|
77
|
+
- **A fixed literal host.** `https://api.resend.com` is a compile-time constant; `baseUrl` exists for a stub server in a test, never for request input.
|
|
78
|
+
- **`redirect: "error"`** on the underlying fetch, layered over any `FetchHttpClient.RequestInit` defaults you provide. Workers' default (`follow`) replays a request at the redirect target with the headers intact, so a 3xx from a hijacked host would hand the bearer token to whatever host the response named. A Resend send has no legitimate reason to redirect.
|
|
79
|
+
- **Truncated error bodies.** A non-2xx becomes an `EmailSendError` with `provider: "resend"`, `status: some(status)` and at most 200 characters of the response; an unreadable body still fails with the status; a transport failure carries `status: none`. The key never appears in a message.
|
|
80
|
+
- **No retry.** The caller decides the policy (`Effect.retry`).
|
|
81
|
+
|
|
82
|
+
## Batches
|
|
83
|
+
|
|
84
|
+
`sendBatch(messages)` is one `POST /emails/batch`, one result per message in order. Resend accepts up to 100 messages per request; chunk beyond that. An empty batch makes no request.
|
|
85
|
+
|
|
86
|
+
## Message mapping
|
|
87
|
+
|
|
88
|
+
| Core `EmailMessage` | Resend `POST /emails` |
|
|
89
|
+
| ------------------------------------ | ------------------------------------------ |
|
|
90
|
+
| `from`, `to`, `cc`, `bcc` | `Name <address>` strings (`formatAddress`) |
|
|
91
|
+
| `replyTo` | `reply_to: [address]` |
|
|
92
|
+
| `subject`, `text`, `html`, `headers` | the same |
|
|
93
|
+
| `attachments` | `attachments` (`content_type`) |
|
|
94
|
+
| `tags` | `tags: [{ name: tag, value: tag }]` |
|
|
95
|
+
|
|
96
|
+
`toResendPayload(message)` is exported for callers that want to inspect the payload.
|
|
97
|
+
|
|
98
|
+
## Testing a consumer
|
|
99
|
+
|
|
100
|
+
Provide `EmailService.layerRecording(sent)` from the core. To exercise this package's own transport, provide `FetchHttpClient.layer` with a stub `Fetch`:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import * as Layer from "effect/Layer";
|
|
104
|
+
import * as Redacted from "effect/Redacted";
|
|
105
|
+
import { FetchHttpClient } from "effect/http";
|
|
106
|
+
import * as Resend from "@thomasfosterau/effect-connect-resend";
|
|
107
|
+
|
|
108
|
+
// Your stand-in for the platform fetch: return canned Responses.
|
|
109
|
+
declare const stubFetch: typeof globalThis.fetch;
|
|
110
|
+
|
|
111
|
+
const http = FetchHttpClient.layer.pipe(
|
|
112
|
+
Layer.provide(Layer.succeed(FetchHttpClient.Fetch, stubFetch)),
|
|
113
|
+
);
|
|
114
|
+
const EmailStub = Resend.layer({ apiKey: Redacted.make("re_test") }).pipe(Layer.provide(http));
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import * as Layer from "effect/Layer";
|
|
2
|
+
import * as Option from "effect/Option";
|
|
3
|
+
import * as Redacted from "effect/Redacted";
|
|
4
|
+
import { EmailMessage, EmailService, EnvSecretBinding, Recipient } from "@thomasfosterau/effect-connect";
|
|
5
|
+
import * as Schema from "effect/Schema";
|
|
6
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
7
|
+
import * as Config from "effect/Config";
|
|
8
|
+
import * as Context from "effect/Context";
|
|
9
|
+
/**
|
|
10
|
+
* Resend's API host, as a compile-time constant. A hard-coded literal rather
|
|
11
|
+
* than anything derived from input: a `fetch` whose host could be steered is
|
|
12
|
+
* exactly the outbound-request hazard the consuming applications' security
|
|
13
|
+
* policies forbid (SEC-SSRF-001), and the only values that ever vary in a
|
|
14
|
+
* send are the message fields in the body. `baseUrl` exists for a stub
|
|
15
|
+
* server in a test, never for request input.
|
|
16
|
+
*
|
|
17
|
+
* @since 0.1.0
|
|
18
|
+
*/
|
|
19
|
+
export declare const DEFAULT_BASE_URL = "https://api.resend.com";
|
|
20
|
+
/**
|
|
21
|
+
* What {@link ResendConfig.layer} takes.
|
|
22
|
+
*
|
|
23
|
+
* @since 0.1.0
|
|
24
|
+
*/
|
|
25
|
+
export interface ResendConfigInput {
|
|
26
|
+
/** The API key. Wrap with `Redacted.make(...)` or read it via `Config.redacted(...)`. */
|
|
27
|
+
readonly apiKey: Redacted.Redacted;
|
|
28
|
+
/** The API host, without a trailing slash. Defaults to {@link DEFAULT_BASE_URL}. */
|
|
29
|
+
readonly baseUrl?: string;
|
|
30
|
+
}
|
|
31
|
+
declare const ResendConfig_base: Context.ServiceClass<ResendConfig, "@thomasfosterau/effect-connect-resend/ResendConfig", {
|
|
32
|
+
readonly apiKey: Redacted.Redacted;
|
|
33
|
+
readonly baseUrl: string;
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* The Resend configuration as a service.
|
|
37
|
+
*
|
|
38
|
+
* @since 0.1.0
|
|
39
|
+
*/
|
|
40
|
+
export declare class ResendConfig extends ResendConfig_base {
|
|
41
|
+
/**
|
|
42
|
+
* A configuration from literal values.
|
|
43
|
+
*
|
|
44
|
+
* @since 0.1.0
|
|
45
|
+
*/
|
|
46
|
+
static readonly layer: (config: ResendConfigInput) => Layer.Layer<ResendConfig>;
|
|
47
|
+
/**
|
|
48
|
+
* A configuration read through `effect/Config`: `RESEND_API_KEY`
|
|
49
|
+
* (required, redacted) and `RESEND_BASE_URL` (optional). Fails with a
|
|
50
|
+
* `ConfigError` when `RESEND_API_KEY` is unset or empty; an empty
|
|
51
|
+
* `RESEND_BASE_URL` falls back to {@link DEFAULT_BASE_URL}.
|
|
52
|
+
*
|
|
53
|
+
* @since 0.1.0
|
|
54
|
+
*/
|
|
55
|
+
static readonly layerConfig: Layer.Layer<ResendConfig, Config.ConfigError, never>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The wire payload Resend's send endpoint accepts, stated once and checked
|
|
59
|
+
* against what `EmailMessage` carries. Optional members are `Schema.optional`
|
|
60
|
+
* rather than `optionalKey` on purpose: the mapping assigns `undefined` for
|
|
61
|
+
* an absent message field and JSON encoding drops the key.
|
|
62
|
+
*
|
|
63
|
+
* @since 0.1.0
|
|
64
|
+
*/
|
|
65
|
+
export declare const ResendSendPayload: Schema.Struct<{
|
|
66
|
+
readonly from: Schema.String;
|
|
67
|
+
readonly to: Schema.NonEmptyArray<Schema.String>;
|
|
68
|
+
readonly cc: Schema.optional<Schema.$Array<Schema.String>>;
|
|
69
|
+
readonly bcc: Schema.optional<Schema.$Array<Schema.String>>;
|
|
70
|
+
readonly reply_to: Schema.optional<Schema.$Array<Schema.String>>;
|
|
71
|
+
readonly subject: Schema.String;
|
|
72
|
+
readonly text: Schema.optional<Schema.String>;
|
|
73
|
+
readonly html: Schema.optional<Schema.String>;
|
|
74
|
+
readonly headers: Schema.optional<Schema.$Record<Schema.String, Schema.String>>;
|
|
75
|
+
readonly attachments: Schema.optional<Schema.$Array<Schema.Struct<{
|
|
76
|
+
readonly filename: Schema.String;
|
|
77
|
+
readonly content: Schema.String;
|
|
78
|
+
readonly content_type: Schema.String;
|
|
79
|
+
}>>>;
|
|
80
|
+
readonly tags: Schema.optional<Schema.$Array<Schema.Struct<{
|
|
81
|
+
readonly name: Schema.String;
|
|
82
|
+
readonly value: Schema.String;
|
|
83
|
+
}>>>;
|
|
84
|
+
}>;
|
|
85
|
+
/**
|
|
86
|
+
* The decoded type of the {@link ResendSendPayload} schema.
|
|
87
|
+
*
|
|
88
|
+
* @since 0.1.0
|
|
89
|
+
*/
|
|
90
|
+
export type ResendSendPayload = typeof ResendSendPayload.Type;
|
|
91
|
+
/**
|
|
92
|
+
* `Name <address>` when the recipient has a display name, else the bare address.
|
|
93
|
+
*
|
|
94
|
+
* @since 0.1.0
|
|
95
|
+
*/
|
|
96
|
+
export declare const formatAddress: (recipient: Recipient) => string;
|
|
97
|
+
/**
|
|
98
|
+
* Map a core message onto Resend's send payload: recipients as
|
|
99
|
+
* `Name <address>` strings, `replyTo` as a one-element `reply_to`, core
|
|
100
|
+
* `tags` as Resend tags whose name and value are both the tag.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```ts
|
|
104
|
+
* import * as assert from "node:assert";
|
|
105
|
+
* import { toResendPayload } from "@thomasfosterau/effect-connect-resend";
|
|
106
|
+
*
|
|
107
|
+
* const payload = toResendPayload({
|
|
108
|
+
* from: { email: "noreply@example.com", name: "Example" },
|
|
109
|
+
* to: [{ email: "reader@example.com" }],
|
|
110
|
+
* replyTo: { email: "support@example.com" },
|
|
111
|
+
* subject: "Welcome",
|
|
112
|
+
* tags: ["welcome"],
|
|
113
|
+
* });
|
|
114
|
+
*
|
|
115
|
+
* assert.strictEqual(payload.from, "Example <noreply@example.com>");
|
|
116
|
+
* assert.deepStrictEqual(payload.to, ["reader@example.com"]);
|
|
117
|
+
* assert.deepStrictEqual(payload.reply_to, ["support@example.com"]);
|
|
118
|
+
* assert.deepStrictEqual(payload.tags, [{ name: "welcome", value: "welcome" }]);
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* @since 0.1.0
|
|
122
|
+
*/
|
|
123
|
+
export declare const toResendPayload: (message: EmailMessage) => ResendSendPayload;
|
|
124
|
+
/**
|
|
125
|
+
* The core `EmailService` over Resend's REST API and the `HttpClient` in
|
|
126
|
+
* context. `sendBatch` uses `POST /emails/batch` (one request, up to 100
|
|
127
|
+
* messages per Resend's limit — the caller chunks beyond that).
|
|
128
|
+
*
|
|
129
|
+
* @since 0.1.0
|
|
130
|
+
*/
|
|
131
|
+
export declare const layerEmailService: Layer.Layer<EmailService, never, ResendConfig | HttpClient.HttpClient>;
|
|
132
|
+
/**
|
|
133
|
+
* The sender used when no API key is configured: every send **succeeds**
|
|
134
|
+
* without sending, and logs the subject only.
|
|
135
|
+
*
|
|
136
|
+
* Deliberately succeeds rather than failing. The consuming applications'
|
|
137
|
+
* preview and dev stages deploy without the credential, so a failing stub
|
|
138
|
+
* would turn "no mail configured" into "signup is broken" on every preview
|
|
139
|
+
* stage — and inside a queue consumer, into every notification in the batch
|
|
140
|
+
* being re-delivered, forever. That trade is only safe while the mail is
|
|
141
|
+
* advisory (a verification a user can still sign in without, a notification
|
|
142
|
+
* that also went out as push); if a send ever gates an account, fail loudly
|
|
143
|
+
* at boot instead of installing this.
|
|
144
|
+
*
|
|
145
|
+
* @since 0.1.0
|
|
146
|
+
*/
|
|
147
|
+
export declare const noop: Layer.Layer<EmailService>;
|
|
148
|
+
/**
|
|
149
|
+
* The Resend provider from literal configuration, over the `HttpClient` in
|
|
150
|
+
* context (provide `FetchHttpClient.layer` on Workers, browsers and Node).
|
|
151
|
+
*
|
|
152
|
+
* @since 0.1.0
|
|
153
|
+
*/
|
|
154
|
+
export declare const layer: (config: ResendConfigInput) => Layer.Layer<EmailService, never, HttpClient.HttpClient>;
|
|
155
|
+
/**
|
|
156
|
+
* The Resend provider configured through `effect/Config` (`RESEND_API_KEY`,
|
|
157
|
+
* optional `RESEND_BASE_URL`), over the `HttpClient` in context. Fails at
|
|
158
|
+
* layer construction with a `ConfigError` when `RESEND_API_KEY` is unset or
|
|
159
|
+
* empty (an empty variable reads as missing); an empty `RESEND_BASE_URL`
|
|
160
|
+
* falls back to the default host. On a Worker, {@link resolveTransport}
|
|
161
|
+
* tolerates a missing key.
|
|
162
|
+
*
|
|
163
|
+
* @since 0.1.0
|
|
164
|
+
*/
|
|
165
|
+
export declare const layerConfig: Layer.Layer<EmailService, import("effect/Config").ConfigError, HttpClient.HttpClient>;
|
|
166
|
+
/**
|
|
167
|
+
* The env {@link resolveTransport} reads: the Worker's `RESEND_API_KEY` binding, if any.
|
|
168
|
+
*
|
|
169
|
+
* @since 0.1.0
|
|
170
|
+
*/
|
|
171
|
+
export interface ResendEnv {
|
|
172
|
+
readonly RESEND_API_KEY?: EnvSecretBinding;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The credential from a Worker env, normalised: `none` when the binding is
|
|
176
|
+
* absent, `null`, or blank (a stage can bind `""` by hand, and a truthiness
|
|
177
|
+
* check is what makes that the same case as "unset" rather than a real
|
|
178
|
+
* transport built around an empty bearer token).
|
|
179
|
+
*
|
|
180
|
+
* @since 0.1.0
|
|
181
|
+
*/
|
|
182
|
+
export declare const apiKeyFromEnv: (env: ResendEnv) => Option.Option<Redacted.Redacted>;
|
|
183
|
+
/**
|
|
184
|
+
* Pick the transport for a Worker's environment: the live sender (on the
|
|
185
|
+
* platform `fetch`, via `FetchHttpClient`) when `RESEND_API_KEY` is present,
|
|
186
|
+
* the logging {@link noop} when it is not. One call site per Worker, so the
|
|
187
|
+
* fallback cannot be forgotten at one of two.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```ts
|
|
191
|
+
* import * as assert from "node:assert";
|
|
192
|
+
* import * as Effect from "effect/Effect";
|
|
193
|
+
* import * as Option from "effect/Option";
|
|
194
|
+
* import { send } from "@thomasfosterau/effect-connect";
|
|
195
|
+
* import { resolveTransport } from "@thomasfosterau/effect-connect-resend";
|
|
196
|
+
*
|
|
197
|
+
* // No `RESEND_API_KEY` bound (a blank one counts as unbound): the no-op
|
|
198
|
+
* // transport, which logs the subject and succeeds without a network call.
|
|
199
|
+
* const result = Effect.runSync(
|
|
200
|
+
* send({
|
|
201
|
+
* from: { email: "noreply@example.com" },
|
|
202
|
+
* to: [{ email: "reader@example.com" }],
|
|
203
|
+
* subject: "Welcome",
|
|
204
|
+
* }).pipe(Effect.provide(resolveTransport({ RESEND_API_KEY: " " }))),
|
|
205
|
+
* );
|
|
206
|
+
*
|
|
207
|
+
* assert.ok(Option.isNone(result.id));
|
|
208
|
+
* ```
|
|
209
|
+
*
|
|
210
|
+
* @since 0.1.0
|
|
211
|
+
*/
|
|
212
|
+
export declare const resolveTransport: (env: ResendEnv) => Layer.Layer<EmailService>;
|
|
213
|
+
export {};
|
|
214
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import * as Layer from "effect/Layer";
|
|
2
|
+
import * as Option from "effect/Option";
|
|
3
|
+
import "effect/Redacted";
|
|
4
|
+
import { EmailSendError, EmailService, boundSecret, describeHttpError, droppingSender, httpErrorStatus, isSuccessStatus, refuseRedirects, rejectionMessage, selectTransport } from "@thomasfosterau/effect-connect";
|
|
5
|
+
import * as Arr from "effect/Array";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Schema from "effect/Schema";
|
|
8
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
9
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
10
|
+
import * as HttpClientResponse from "effect/http/HttpClientResponse";
|
|
11
|
+
import * as Config from "effect/Config";
|
|
12
|
+
import * as Context from "effect/Context";
|
|
13
|
+
// src/ResendConfig.ts
|
|
14
|
+
/**
|
|
15
|
+
* The Resend credential and host, as a service with two constructors: a
|
|
16
|
+
* literal (`layer`) and one read from `effect/Config` (`layerConfig`).
|
|
17
|
+
*
|
|
18
|
+
* @module ResendConfig
|
|
19
|
+
* @since 0.1.0
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Resend's API host, as a compile-time constant. A hard-coded literal rather
|
|
23
|
+
* than anything derived from input: a `fetch` whose host could be steered is
|
|
24
|
+
* exactly the outbound-request hazard the consuming applications' security
|
|
25
|
+
* policies forbid (SEC-SSRF-001), and the only values that ever vary in a
|
|
26
|
+
* send are the message fields in the body. `baseUrl` exists for a stub
|
|
27
|
+
* server in a test, never for request input.
|
|
28
|
+
*
|
|
29
|
+
* @since 0.1.0
|
|
30
|
+
*/
|
|
31
|
+
const DEFAULT_BASE_URL = "https://api.resend.com";
|
|
32
|
+
/**
|
|
33
|
+
* The Resend configuration as a service.
|
|
34
|
+
*
|
|
35
|
+
* @since 0.1.0
|
|
36
|
+
*/
|
|
37
|
+
var ResendConfig = class ResendConfig extends Context.Service()("@thomasfosterau/effect-connect-resend/ResendConfig") {
|
|
38
|
+
/**
|
|
39
|
+
* A configuration from literal values.
|
|
40
|
+
*
|
|
41
|
+
* @since 0.1.0
|
|
42
|
+
*/
|
|
43
|
+
static layer = (config) => Layer.succeed(ResendConfig, {
|
|
44
|
+
apiKey: config.apiKey,
|
|
45
|
+
baseUrl: config.baseUrl ?? "https://api.resend.com"
|
|
46
|
+
});
|
|
47
|
+
/**
|
|
48
|
+
* A configuration read through `effect/Config`: `RESEND_API_KEY`
|
|
49
|
+
* (required, redacted) and `RESEND_BASE_URL` (optional). Fails with a
|
|
50
|
+
* `ConfigError` when `RESEND_API_KEY` is unset or empty; an empty
|
|
51
|
+
* `RESEND_BASE_URL` falls back to {@link DEFAULT_BASE_URL}.
|
|
52
|
+
*
|
|
53
|
+
* @since 0.1.0
|
|
54
|
+
*/
|
|
55
|
+
static layerConfig = Layer.effect(ResendConfig, Effect.gen(function* () {
|
|
56
|
+
return {
|
|
57
|
+
apiKey: yield* Config.Redacted("RESEND_API_KEY"),
|
|
58
|
+
baseUrl: yield* Config.String("RESEND_BASE_URL").pipe(Config.withDefault(DEFAULT_BASE_URL))
|
|
59
|
+
};
|
|
60
|
+
}));
|
|
61
|
+
};
|
|
62
|
+
// src/Resend.ts
|
|
63
|
+
/**
|
|
64
|
+
* The Resend sender: the core `EmailMessage` mapped onto Resend's
|
|
65
|
+
* `POST /emails` (and `POST /emails/batch`) JSON, sent over Effect's
|
|
66
|
+
* `HttpClient` with no SDK — the library form of the hand-rolled senders the
|
|
67
|
+
* website, gardeneria and preferenceflow applications carry.
|
|
68
|
+
*
|
|
69
|
+
* The properties those senders pin, kept here:
|
|
70
|
+
*
|
|
71
|
+
* - a fixed literal host ({@link DEFAULT_BASE_URL});
|
|
72
|
+
* - `redirect: "error"` on the underlying `fetch`. Workers' default
|
|
73
|
+
* (`follow`) replays the request at the redirect target **with the headers
|
|
74
|
+
* intact** — it does not drop `Authorization` on a cross-origin hop the way
|
|
75
|
+
* a browser does — so a 3xx from a hijacked or misconfigured
|
|
76
|
+
* `api.resend.com` would hand the API key to whatever host the response
|
|
77
|
+
* named. A Resend send has no legitimate reason to redirect, so the correct
|
|
78
|
+
* behaviour is to fail loudly (SEC-SECRET-001);
|
|
79
|
+
* - a non-2xx becomes an `EmailSendError` carrying the status and a
|
|
80
|
+
* **truncated** body, never the raw unbounded one, and never the key;
|
|
81
|
+
* - no retry: the caller decides the policy.
|
|
82
|
+
*
|
|
83
|
+
* @module Resend
|
|
84
|
+
* @since 0.1.0
|
|
85
|
+
*/
|
|
86
|
+
const ResendAttachment = Schema.Struct({
|
|
87
|
+
filename: Schema.String,
|
|
88
|
+
content: Schema.String,
|
|
89
|
+
content_type: Schema.String
|
|
90
|
+
});
|
|
91
|
+
const ResendTag = Schema.Struct({
|
|
92
|
+
name: Schema.String,
|
|
93
|
+
value: Schema.String
|
|
94
|
+
});
|
|
95
|
+
/**
|
|
96
|
+
* The wire payload Resend's send endpoint accepts, stated once and checked
|
|
97
|
+
* against what `EmailMessage` carries. Optional members are `Schema.optional`
|
|
98
|
+
* rather than `optionalKey` on purpose: the mapping assigns `undefined` for
|
|
99
|
+
* an absent message field and JSON encoding drops the key.
|
|
100
|
+
*
|
|
101
|
+
* @since 0.1.0
|
|
102
|
+
*/
|
|
103
|
+
const ResendSendPayload = Schema.Struct({
|
|
104
|
+
from: Schema.String,
|
|
105
|
+
to: Schema.NonEmptyArray(Schema.String),
|
|
106
|
+
cc: Schema.optional(Schema.Array(Schema.String)),
|
|
107
|
+
bcc: Schema.optional(Schema.Array(Schema.String)),
|
|
108
|
+
reply_to: Schema.optional(Schema.Array(Schema.String)),
|
|
109
|
+
subject: Schema.String,
|
|
110
|
+
text: Schema.optional(Schema.String),
|
|
111
|
+
html: Schema.optional(Schema.String),
|
|
112
|
+
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
113
|
+
attachments: Schema.optional(Schema.Array(ResendAttachment)),
|
|
114
|
+
tags: Schema.optional(Schema.Array(ResendTag))
|
|
115
|
+
});
|
|
116
|
+
const SendResponse = Schema.Struct({ id: Schema.String });
|
|
117
|
+
const BatchResponse = Schema.Struct({ data: Schema.Array(SendResponse) });
|
|
118
|
+
/**
|
|
119
|
+
* `Name <address>` when the recipient has a display name, else the bare address.
|
|
120
|
+
*
|
|
121
|
+
* @since 0.1.0
|
|
122
|
+
*/
|
|
123
|
+
const formatAddress = (recipient) => Option.match(Option.fromNullishOr(recipient.name), {
|
|
124
|
+
onNone: () => recipient.email,
|
|
125
|
+
onSome: (name) => `${name} <${recipient.email}>`
|
|
126
|
+
});
|
|
127
|
+
/**
|
|
128
|
+
* Map a core message onto Resend's send payload: recipients as
|
|
129
|
+
* `Name <address>` strings, `replyTo` as a one-element `reply_to`, core
|
|
130
|
+
* `tags` as Resend tags whose name and value are both the tag.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* import * as assert from "node:assert";
|
|
135
|
+
* import { toResendPayload } from "@thomasfosterau/effect-connect-resend";
|
|
136
|
+
*
|
|
137
|
+
* const payload = toResendPayload({
|
|
138
|
+
* from: { email: "noreply@example.com", name: "Example" },
|
|
139
|
+
* to: [{ email: "reader@example.com" }],
|
|
140
|
+
* replyTo: { email: "support@example.com" },
|
|
141
|
+
* subject: "Welcome",
|
|
142
|
+
* tags: ["welcome"],
|
|
143
|
+
* });
|
|
144
|
+
*
|
|
145
|
+
* assert.strictEqual(payload.from, "Example <noreply@example.com>");
|
|
146
|
+
* assert.deepStrictEqual(payload.to, ["reader@example.com"]);
|
|
147
|
+
* assert.deepStrictEqual(payload.reply_to, ["support@example.com"]);
|
|
148
|
+
* assert.deepStrictEqual(payload.tags, [{ name: "welcome", value: "welcome" }]);
|
|
149
|
+
* ```
|
|
150
|
+
*
|
|
151
|
+
* @since 0.1.0
|
|
152
|
+
*/
|
|
153
|
+
const toResendPayload = (message) => ({
|
|
154
|
+
from: formatAddress(message.from),
|
|
155
|
+
to: [formatAddress(message.to[0]), ...message.to.slice(1).map(formatAddress)],
|
|
156
|
+
cc: message.cc?.map(formatAddress),
|
|
157
|
+
bcc: message.bcc?.map(formatAddress),
|
|
158
|
+
reply_to: Option.getOrUndefined(Option.map(Option.fromNullishOr(message.replyTo), (replyTo) => [formatAddress(replyTo)])),
|
|
159
|
+
subject: message.subject,
|
|
160
|
+
text: message.text,
|
|
161
|
+
html: message.html,
|
|
162
|
+
headers: message.headers,
|
|
163
|
+
attachments: message.attachments?.map((attachment) => ({
|
|
164
|
+
filename: attachment.filename,
|
|
165
|
+
content: attachment.content,
|
|
166
|
+
content_type: attachment.contentType
|
|
167
|
+
})),
|
|
168
|
+
tags: message.tags?.map((tag) => ({
|
|
169
|
+
name: tag,
|
|
170
|
+
value: tag
|
|
171
|
+
}))
|
|
172
|
+
});
|
|
173
|
+
const PROVIDER = "resend";
|
|
174
|
+
/**
|
|
175
|
+
* The core `EmailService` over Resend's REST API and the `HttpClient` in
|
|
176
|
+
* context. `sendBatch` uses `POST /emails/batch` (one request, up to 100
|
|
177
|
+
* messages per Resend's limit — the caller chunks beyond that).
|
|
178
|
+
*
|
|
179
|
+
* @since 0.1.0
|
|
180
|
+
*/
|
|
181
|
+
const layerEmailService = Layer.effect(EmailService, Effect.gen(function* () {
|
|
182
|
+
const config = yield* ResendConfig;
|
|
183
|
+
const http = yield* HttpClient.HttpClient;
|
|
184
|
+
/** One `POST` to Resend, decoded with `schema` on a 2xx. */
|
|
185
|
+
const post = Effect.fn("Resend.post")(function* (path, body, schema) {
|
|
186
|
+
const request = yield* HttpClientRequest.post(`${config.baseUrl}${path}`, { acceptJson: true }).pipe(HttpClientRequest.bearerToken(config.apiKey), HttpClientRequest.bodyJson(body), Effect.mapError((cause) => new EmailSendError({
|
|
187
|
+
provider: PROVIDER,
|
|
188
|
+
message: "Resend request body could not be encoded as JSON",
|
|
189
|
+
status: Option.none(),
|
|
190
|
+
cause
|
|
191
|
+
})));
|
|
192
|
+
const response = yield* http.execute(request).pipe(refuseRedirects, Effect.mapError((error) => new EmailSendError({
|
|
193
|
+
provider: PROVIDER,
|
|
194
|
+
message: `Resend was unreachable: ${describeHttpError(error)}`,
|
|
195
|
+
status: httpErrorStatus(error),
|
|
196
|
+
cause: error
|
|
197
|
+
})));
|
|
198
|
+
if (!isSuccessStatus(response.status)) {
|
|
199
|
+
const message = yield* rejectionMessage(response, "Resend rejected the send");
|
|
200
|
+
return yield* new EmailSendError({
|
|
201
|
+
provider: PROVIDER,
|
|
202
|
+
message,
|
|
203
|
+
status: Option.some(response.status)
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe(Effect.mapError((cause) => new EmailSendError({
|
|
207
|
+
provider: PROVIDER,
|
|
208
|
+
message: `Resend answered ${response.status} with an unexpected body`,
|
|
209
|
+
status: Option.some(response.status),
|
|
210
|
+
cause
|
|
211
|
+
})));
|
|
212
|
+
});
|
|
213
|
+
return {
|
|
214
|
+
send: Effect.fn("Resend.send")(function* (message) {
|
|
215
|
+
const { id } = yield* post("/emails", toResendPayload(message), SendResponse);
|
|
216
|
+
return { id: Option.some(id) };
|
|
217
|
+
}),
|
|
218
|
+
sendBatch: Effect.fn("Resend.sendBatch")(function* (messages) {
|
|
219
|
+
if (!Arr.isReadonlyArrayNonEmpty(messages)) return [];
|
|
220
|
+
const { data } = yield* post("/emails/batch", messages.map(toResendPayload), BatchResponse);
|
|
221
|
+
return data.map((entry) => ({ id: Option.some(entry.id) }));
|
|
222
|
+
})
|
|
223
|
+
};
|
|
224
|
+
}));
|
|
225
|
+
/**
|
|
226
|
+
* The sender used when no API key is configured: every send **succeeds**
|
|
227
|
+
* without sending, and logs the subject only.
|
|
228
|
+
*
|
|
229
|
+
* Deliberately succeeds rather than failing. The consuming applications'
|
|
230
|
+
* preview and dev stages deploy without the credential, so a failing stub
|
|
231
|
+
* would turn "no mail configured" into "signup is broken" on every preview
|
|
232
|
+
* stage — and inside a queue consumer, into every notification in the batch
|
|
233
|
+
* being re-delivered, forever. That trade is only safe while the mail is
|
|
234
|
+
* advisory (a verification a user can still sign in without, a notification
|
|
235
|
+
* that also went out as push); if a send ever gates an account, fail loudly
|
|
236
|
+
* at boot instead of installing this.
|
|
237
|
+
*
|
|
238
|
+
* @since 0.1.0
|
|
239
|
+
*/
|
|
240
|
+
const noop = Layer.succeed(EmailService, droppingSender((message) => `[effect-connect-resend] no RESEND_API_KEY configured; dropping "${message.subject}"`));
|
|
241
|
+
// src/index.ts
|
|
242
|
+
/**
|
|
243
|
+
* `@thomasfosterau/effect-connect-resend` — the Resend provider for
|
|
244
|
+
* `@thomasfosterau/effect-connect`: `POST /emails` over `effect/http`
|
|
245
|
+
* with no SDK, a `Redacted` key, a no-op layer for un-provisioned stages and
|
|
246
|
+
* an env-driven resolver that picks between the two.
|
|
247
|
+
*
|
|
248
|
+
* @module index
|
|
249
|
+
* @since 0.1.0
|
|
250
|
+
*/
|
|
251
|
+
/**
|
|
252
|
+
* The Resend provider from literal configuration, over the `HttpClient` in
|
|
253
|
+
* context (provide `FetchHttpClient.layer` on Workers, browsers and Node).
|
|
254
|
+
*
|
|
255
|
+
* @since 0.1.0
|
|
256
|
+
*/
|
|
257
|
+
const layer = (config) => layerEmailService.pipe(Layer.provide(ResendConfig.layer(config)));
|
|
258
|
+
/**
|
|
259
|
+
* The Resend provider configured through `effect/Config` (`RESEND_API_KEY`,
|
|
260
|
+
* optional `RESEND_BASE_URL`), over the `HttpClient` in context. Fails at
|
|
261
|
+
* layer construction with a `ConfigError` when `RESEND_API_KEY` is unset or
|
|
262
|
+
* empty (an empty variable reads as missing); an empty `RESEND_BASE_URL`
|
|
263
|
+
* falls back to the default host. On a Worker, {@link resolveTransport}
|
|
264
|
+
* tolerates a missing key.
|
|
265
|
+
*
|
|
266
|
+
* @since 0.1.0
|
|
267
|
+
*/
|
|
268
|
+
const layerConfig = layerEmailService.pipe(Layer.provide(ResendConfig.layerConfig));
|
|
269
|
+
/**
|
|
270
|
+
* The credential from a Worker env, normalised: `none` when the binding is
|
|
271
|
+
* absent, `null`, or blank (a stage can bind `""` by hand, and a truthiness
|
|
272
|
+
* check is what makes that the same case as "unset" rather than a real
|
|
273
|
+
* transport built around an empty bearer token).
|
|
274
|
+
*
|
|
275
|
+
* @since 0.1.0
|
|
276
|
+
*/
|
|
277
|
+
const apiKeyFromEnv = (env) => boundSecret(env.RESEND_API_KEY);
|
|
278
|
+
/**
|
|
279
|
+
* Pick the transport for a Worker's environment: the live sender (on the
|
|
280
|
+
* platform `fetch`, via `FetchHttpClient`) when `RESEND_API_KEY` is present,
|
|
281
|
+
* the logging {@link noop} when it is not. One call site per Worker, so the
|
|
282
|
+
* fallback cannot be forgotten at one of two.
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* ```ts
|
|
286
|
+
* import * as assert from "node:assert";
|
|
287
|
+
* import * as Effect from "effect/Effect";
|
|
288
|
+
* import * as Option from "effect/Option";
|
|
289
|
+
* import { send } from "@thomasfosterau/effect-connect";
|
|
290
|
+
* import { resolveTransport } from "@thomasfosterau/effect-connect-resend";
|
|
291
|
+
*
|
|
292
|
+
* // No `RESEND_API_KEY` bound (a blank one counts as unbound): the no-op
|
|
293
|
+
* // transport, which logs the subject and succeeds without a network call.
|
|
294
|
+
* const result = Effect.runSync(
|
|
295
|
+
* send({
|
|
296
|
+
* from: { email: "noreply@example.com" },
|
|
297
|
+
* to: [{ email: "reader@example.com" }],
|
|
298
|
+
* subject: "Welcome",
|
|
299
|
+
* }).pipe(Effect.provide(resolveTransport({ RESEND_API_KEY: " " }))),
|
|
300
|
+
* );
|
|
301
|
+
*
|
|
302
|
+
* assert.ok(Option.isNone(result.id));
|
|
303
|
+
* ```
|
|
304
|
+
*
|
|
305
|
+
* @since 0.1.0
|
|
306
|
+
*/
|
|
307
|
+
const resolveTransport = (env) => selectTransport(apiKeyFromEnv(env), (apiKey) => layer({ apiKey }), noop);
|
|
308
|
+
export { DEFAULT_BASE_URL, ResendConfig, ResendSendPayload, apiKeyFromEnv, formatAddress, layer, layerConfig, layerEmailService, noop, resolveTransport, toResendPayload };
|
|
309
|
+
|
|
310
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/ResendConfig.ts","../src/Resend.ts","../src/index.ts"],"sourcesContent":["/**\n * The Resend credential and host, as a service with two constructors: a\n * literal (`layer`) and one read from `effect/Config` (`layerConfig`).\n *\n * @module ResendConfig\n * @since 0.1.0\n */\n\nimport * as Config from \"effect/Config\";\nimport * as Context from \"effect/Context\";\nimport * as Effect from \"effect/Effect\";\nimport * as Layer from \"effect/Layer\";\nimport type * as Redacted from \"effect/Redacted\";\n\n/**\n * Resend's API host, as a compile-time constant. A hard-coded literal rather\n * than anything derived from input: a `fetch` whose host could be steered is\n * exactly the outbound-request hazard the consuming applications' security\n * policies forbid (SEC-SSRF-001), and the only values that ever vary in a\n * send are the message fields in the body. `baseUrl` exists for a stub\n * server in a test, never for request input.\n *\n * @since 0.1.0\n */\nexport const DEFAULT_BASE_URL = \"https://api.resend.com\";\n\n/**\n * What {@link ResendConfig.layer} takes.\n *\n * @since 0.1.0\n */\nexport interface ResendConfigInput {\n /** The API key. Wrap with `Redacted.make(...)` or read it via `Config.redacted(...)`. */\n readonly apiKey: Redacted.Redacted;\n /** The API host, without a trailing slash. Defaults to {@link DEFAULT_BASE_URL}. */\n // oxlint-disable-next-line effect/prefer-option-over-null -- a consumer-facing config input: an omitted key IS the default, and asking every caller to spell `Option.none()` for it buys nothing\n readonly baseUrl?: string;\n}\n\n/**\n * The Resend configuration as a service.\n *\n * @since 0.1.0\n */\nexport class ResendConfig extends Context.Service<\n ResendConfig,\n {\n readonly apiKey: Redacted.Redacted;\n readonly baseUrl: string;\n }\n>()(\"@thomasfosterau/effect-connect-resend/ResendConfig\") {\n /**\n * A configuration from literal values.\n *\n * @since 0.1.0\n */\n static readonly layer = (config: ResendConfigInput): Layer.Layer<ResendConfig> =>\n Layer.succeed(ResendConfig, {\n apiKey: config.apiKey,\n baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,\n });\n\n /**\n * A configuration read through `effect/Config`: `RESEND_API_KEY`\n * (required, redacted) and `RESEND_BASE_URL` (optional). Fails with a\n * `ConfigError` when `RESEND_API_KEY` is unset or empty; an empty\n * `RESEND_BASE_URL` falls back to {@link DEFAULT_BASE_URL}.\n *\n * @since 0.1.0\n */\n static readonly layerConfig = Layer.effect(\n ResendConfig,\n Effect.gen(function* () {\n const apiKey = yield* Config.Redacted(\"RESEND_API_KEY\");\n const baseUrl = yield* Config.String(\"RESEND_BASE_URL\").pipe(\n Config.withDefault(DEFAULT_BASE_URL),\n );\n return { apiKey, baseUrl };\n }),\n );\n}\n","/**\n * The Resend sender: the core `EmailMessage` mapped onto Resend's\n * `POST /emails` (and `POST /emails/batch`) JSON, sent over Effect's\n * `HttpClient` with no SDK — the library form of the hand-rolled senders the\n * website, gardeneria and preferenceflow applications carry.\n *\n * The properties those senders pin, kept here:\n *\n * - a fixed literal host ({@link DEFAULT_BASE_URL});\n * - `redirect: \"error\"` on the underlying `fetch`. Workers' default\n * (`follow`) replays the request at the redirect target **with the headers\n * intact** — it does not drop `Authorization` on a cross-origin hop the way\n * a browser does — so a 3xx from a hijacked or misconfigured\n * `api.resend.com` would hand the API key to whatever host the response\n * named. A Resend send has no legitimate reason to redirect, so the correct\n * behaviour is to fail loudly (SEC-SECRET-001);\n * - a non-2xx becomes an `EmailSendError` carrying the status and a\n * **truncated** body, never the raw unbounded one, and never the key;\n * - no retry: the caller decides the policy.\n *\n * @module Resend\n * @since 0.1.0\n */\n\nimport * as Arr from \"effect/Array\";\nimport * as Effect from \"effect/Effect\";\nimport * as Layer from \"effect/Layer\";\nimport * as Option from \"effect/Option\";\nimport * as Schema from \"effect/Schema\";\nimport * as HttpClient from \"effect/http/HttpClient\";\nimport * as HttpClientRequest from \"effect/http/HttpClientRequest\";\nimport * as HttpClientResponse from \"effect/http/HttpClientResponse\";\nimport {\n describeHttpError,\n droppingSender,\n EmailSendError,\n EmailService,\n httpErrorStatus,\n isSuccessStatus,\n refuseRedirects,\n rejectionMessage,\n type EmailMessage,\n type EmailSendResult,\n type Recipient,\n} from \"@thomasfosterau/effect-connect\";\nimport { ResendConfig } from \"./ResendConfig.ts\";\n\nconst ResendAttachment = Schema.Struct({\n filename: Schema.String,\n content: Schema.String,\n content_type: Schema.String,\n});\n\nconst ResendTag = Schema.Struct({\n name: Schema.String,\n value: Schema.String,\n});\n\n/**\n * The wire payload Resend's send endpoint accepts, stated once and checked\n * against what `EmailMessage` carries. Optional members are `Schema.optional`\n * rather than `optionalKey` on purpose: the mapping assigns `undefined` for\n * an absent message field and JSON encoding drops the key.\n *\n * @since 0.1.0\n */\nexport const ResendSendPayload = Schema.Struct({\n from: Schema.String,\n to: Schema.NonEmptyArray(Schema.String),\n cc: Schema.optional(Schema.Array(Schema.String)),\n bcc: Schema.optional(Schema.Array(Schema.String)),\n reply_to: Schema.optional(Schema.Array(Schema.String)),\n subject: Schema.String,\n text: Schema.optional(Schema.String),\n html: Schema.optional(Schema.String),\n headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),\n attachments: Schema.optional(Schema.Array(ResendAttachment)),\n tags: Schema.optional(Schema.Array(ResendTag)),\n});\n/**\n * The decoded type of the {@link ResendSendPayload} schema.\n *\n * @since 0.1.0\n */\nexport type ResendSendPayload = typeof ResendSendPayload.Type;\n\nconst SendResponse = Schema.Struct({ id: Schema.String });\nconst BatchResponse = Schema.Struct({ data: Schema.Array(SendResponse) });\n\n/**\n * `Name <address>` when the recipient has a display name, else the bare address.\n *\n * @since 0.1.0\n */\nexport const formatAddress = (recipient: Recipient): string =>\n Option.match(Option.fromNullishOr(recipient.name), {\n onNone: () => recipient.email,\n onSome: (name) => `${name} <${recipient.email}>`,\n });\n\n/**\n * Map a core message onto Resend's send payload: recipients as\n * `Name <address>` strings, `replyTo` as a one-element `reply_to`, core\n * `tags` as Resend tags whose name and value are both the tag.\n *\n * @example\n * ```ts\n * import * as assert from \"node:assert\";\n * import { toResendPayload } from \"@thomasfosterau/effect-connect-resend\";\n *\n * const payload = toResendPayload({\n * from: { email: \"noreply@example.com\", name: \"Example\" },\n * to: [{ email: \"reader@example.com\" }],\n * replyTo: { email: \"support@example.com\" },\n * subject: \"Welcome\",\n * tags: [\"welcome\"],\n * });\n *\n * assert.strictEqual(payload.from, \"Example <noreply@example.com>\");\n * assert.deepStrictEqual(payload.to, [\"reader@example.com\"]);\n * assert.deepStrictEqual(payload.reply_to, [\"support@example.com\"]);\n * assert.deepStrictEqual(payload.tags, [{ name: \"welcome\", value: \"welcome\" }]);\n * ```\n *\n * @since 0.1.0\n */\nexport const toResendPayload = (message: EmailMessage): ResendSendPayload => ({\n from: formatAddress(message.from),\n to: [formatAddress(message.to[0]), ...message.to.slice(1).map(formatAddress)],\n cc: message.cc?.map(formatAddress),\n bcc: message.bcc?.map(formatAddress),\n reply_to: Option.getOrUndefined(\n Option.map(Option.fromNullishOr(message.replyTo), (replyTo) => [formatAddress(replyTo)]),\n ),\n subject: message.subject,\n text: message.text,\n html: message.html,\n headers: message.headers,\n attachments: message.attachments?.map((attachment) => ({\n filename: attachment.filename,\n content: attachment.content,\n content_type: attachment.contentType,\n })),\n tags: message.tags?.map((tag) => ({ name: tag, value: tag })),\n});\n\nconst PROVIDER = \"resend\";\n\n/**\n * The core `EmailService` over Resend's REST API and the `HttpClient` in\n * context. `sendBatch` uses `POST /emails/batch` (one request, up to 100\n * messages per Resend's limit — the caller chunks beyond that).\n *\n * @since 0.1.0\n */\nexport const layerEmailService: Layer.Layer<\n EmailService,\n never,\n ResendConfig | HttpClient.HttpClient\n> = Layer.effect(\n EmailService,\n Effect.gen(function* () {\n const config = yield* ResendConfig;\n const http = yield* HttpClient.HttpClient;\n\n /** One `POST` to Resend, decoded with `schema` on a 2xx. */\n const post = Effect.fn(\"Resend.post\")(function* <A, I>(\n path: string,\n body: ResendSendPayload | ReadonlyArray<ResendSendPayload>,\n schema: Schema.Codec<A, I>,\n ) {\n const request = yield* HttpClientRequest.post(`${config.baseUrl}${path}`, {\n acceptJson: true,\n }).pipe(\n HttpClientRequest.bearerToken(config.apiKey),\n HttpClientRequest.bodyJson(body),\n Effect.mapError(\n (cause) =>\n new EmailSendError({\n provider: PROVIDER,\n message: \"Resend request body could not be encoded as JSON\",\n status: Option.none(),\n cause,\n }),\n ),\n );\n const response = yield* http.execute(request).pipe(\n refuseRedirects,\n Effect.mapError(\n (error) =>\n new EmailSendError({\n provider: PROVIDER,\n message: `Resend was unreachable: ${describeHttpError(error)}`,\n status: httpErrorStatus(error),\n cause: error,\n }),\n ),\n );\n if (!isSuccessStatus(response.status)) {\n const message = yield* rejectionMessage(response, \"Resend rejected the send\");\n return yield* new EmailSendError({\n provider: PROVIDER,\n message,\n status: Option.some(response.status),\n });\n }\n return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe(\n Effect.mapError(\n (cause) =>\n new EmailSendError({\n provider: PROVIDER,\n message: `Resend answered ${response.status} with an unexpected body`,\n status: Option.some(response.status),\n cause,\n }),\n ),\n );\n });\n\n const send = Effect.fn(\"Resend.send\")(function* (message: EmailMessage) {\n const { id } = yield* post(\"/emails\", toResendPayload(message), SendResponse);\n const result: EmailSendResult = { id: Option.some(id) };\n return result;\n });\n\n const sendBatch = Effect.fn(\"Resend.sendBatch\")(function* (\n messages: ReadonlyArray<EmailMessage>,\n ) {\n if (!Arr.isReadonlyArrayNonEmpty(messages)) {\n const none: ReadonlyArray<EmailSendResult> = [];\n return none;\n }\n const { data } = yield* post(\"/emails/batch\", messages.map(toResendPayload), BatchResponse);\n return data.map((entry): EmailSendResult => ({ id: Option.some(entry.id) }));\n });\n\n return { send, sendBatch };\n }),\n);\n\n/**\n * The sender used when no API key is configured: every send **succeeds**\n * without sending, and logs the subject only.\n *\n * Deliberately succeeds rather than failing. The consuming applications'\n * preview and dev stages deploy without the credential, so a failing stub\n * would turn \"no mail configured\" into \"signup is broken\" on every preview\n * stage — and inside a queue consumer, into every notification in the batch\n * being re-delivered, forever. That trade is only safe while the mail is\n * advisory (a verification a user can still sign in without, a notification\n * that also went out as push); if a send ever gates an account, fail loudly\n * at boot instead of installing this.\n *\n * @since 0.1.0\n */\nexport const noop: Layer.Layer<EmailService> = Layer.succeed(\n EmailService,\n // Logs the subject only — never the recipient (PII) and never the body\n // (a verification URL IS the token). The subject alone answers the\n // question this line exists for: \"is mail being dropped for want of a\n // credential?\"\n droppingSender<EmailMessage>(\n (message) =>\n `[effect-connect-resend] no RESEND_API_KEY configured; dropping \"${message.subject}\"`,\n ),\n);\n","/**\n * `@thomasfosterau/effect-connect-resend` — the Resend provider for\n * `@thomasfosterau/effect-connect`: `POST /emails` over `effect/http`\n * with no SDK, a `Redacted` key, a no-op layer for un-provisioned stages and\n * an env-driven resolver that picks between the two.\n *\n * @module index\n * @since 0.1.0\n */\n\nimport * as Layer from \"effect/Layer\";\nimport * as Option from \"effect/Option\";\nimport * as Redacted from \"effect/Redacted\";\nimport type * as HttpClient from \"effect/http/HttpClient\";\nimport {\n boundSecret,\n selectTransport,\n type EmailService,\n type EnvSecretBinding,\n} from \"@thomasfosterau/effect-connect\";\nimport { layerEmailService, noop } from \"./Resend.ts\";\nimport { ResendConfig, type ResendConfigInput } from \"./ResendConfig.ts\";\n\n/**\n * The Resend sender: the payload mapping, the `EmailService` layer and the no-op transport.\n *\n * @since 0.1.0\n */\nexport * from \"./Resend.ts\";\n/**\n * The Resend credential and host, as a service.\n *\n * @since 0.1.0\n */\nexport * from \"./ResendConfig.ts\";\n\n/**\n * The Resend provider from literal configuration, over the `HttpClient` in\n * context (provide `FetchHttpClient.layer` on Workers, browsers and Node).\n *\n * @since 0.1.0\n */\nexport const layer = (\n config: ResendConfigInput,\n): Layer.Layer<EmailService, never, HttpClient.HttpClient> =>\n layerEmailService.pipe(Layer.provide(ResendConfig.layer(config)));\n\n/**\n * The Resend provider configured through `effect/Config` (`RESEND_API_KEY`,\n * optional `RESEND_BASE_URL`), over the `HttpClient` in context. Fails at\n * layer construction with a `ConfigError` when `RESEND_API_KEY` is unset or\n * empty (an empty variable reads as missing); an empty `RESEND_BASE_URL`\n * falls back to the default host. On a Worker, {@link resolveTransport}\n * tolerates a missing key.\n *\n * @since 0.1.0\n */\nexport const layerConfig = layerEmailService.pipe(Layer.provide(ResendConfig.layerConfig));\n\n/**\n * The env {@link resolveTransport} reads: the Worker's `RESEND_API_KEY` binding, if any.\n *\n * @since 0.1.0\n */\nexport interface ResendEnv {\n readonly RESEND_API_KEY?: EnvSecretBinding;\n}\n\n/**\n * The credential from a Worker env, normalised: `none` when the binding is\n * absent, `null`, or blank (a stage can bind `\"\"` by hand, and a truthiness\n * check is what makes that the same case as \"unset\" rather than a real\n * transport built around an empty bearer token).\n *\n * @since 0.1.0\n */\nexport const apiKeyFromEnv = (env: ResendEnv): Option.Option<Redacted.Redacted> =>\n boundSecret(env.RESEND_API_KEY);\n\n/**\n * Pick the transport for a Worker's environment: the live sender (on the\n * platform `fetch`, via `FetchHttpClient`) when `RESEND_API_KEY` is present,\n * the logging {@link noop} when it is not. One call site per Worker, so the\n * fallback cannot be forgotten at one of two.\n *\n * @example\n * ```ts\n * import * as assert from \"node:assert\";\n * import * as Effect from \"effect/Effect\";\n * import * as Option from \"effect/Option\";\n * import { send } from \"@thomasfosterau/effect-connect\";\n * import { resolveTransport } from \"@thomasfosterau/effect-connect-resend\";\n *\n * // No `RESEND_API_KEY` bound (a blank one counts as unbound): the no-op\n * // transport, which logs the subject and succeeds without a network call.\n * const result = Effect.runSync(\n * send({\n * from: { email: \"noreply@example.com\" },\n * to: [{ email: \"reader@example.com\" }],\n * subject: \"Welcome\",\n * }).pipe(Effect.provide(resolveTransport({ RESEND_API_KEY: \" \" }))),\n * );\n *\n * assert.ok(Option.isNone(result.id));\n * ```\n *\n * @since 0.1.0\n */\nexport const resolveTransport = (env: ResendEnv): Layer.Layer<EmailService> =>\n selectTransport(apiKeyFromEnv(env), (apiKey) => layer({ apiKey }), noop);\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,14 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thomasfosterau/effect-connect-resend",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Resend provider for @thomasfosterau/effect-connect: a fetch-based POST /emails over Effect's HttpClient with no SDK, a Redacted API key, a no-op layer for un-provisioned stages and an env-driven resolver.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"effect",
|
|
7
|
+
"email",
|
|
8
|
+
"resend",
|
|
9
|
+
"transactional",
|
|
10
|
+
"typescript"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/thomasfosterau/effect-connect#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/thomasfosterau/effect-connect/issues"
|
|
15
|
+
},
|
|
5
16
|
"license": "MIT",
|
|
17
|
+
"author": "Thomas Foster <me@thomasfoster.au>",
|
|
6
18
|
"repository": {
|
|
7
19
|
"type": "git",
|
|
8
|
-
"url": "
|
|
20
|
+
"url": "https://github.com/thomasfosterau/effect-connect.git",
|
|
9
21
|
"directory": "packages/resend"
|
|
10
22
|
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"src",
|
|
26
|
+
"!src/**/*.test.ts",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"type": "module",
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"default": "./dist/index.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
11
38
|
"publishConfig": {
|
|
12
39
|
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@thomasfosterau/effect-connect": "^0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@effect/language-service": "^0.87.5",
|
|
46
|
+
"@effect/vitest": "4.1.2",
|
|
47
|
+
"@types/node": "^26.6.6",
|
|
48
|
+
"effect": "4.1.2"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"effect": "^4.0.0"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=22"
|
|
13
55
|
}
|
|
14
|
-
}
|
|
56
|
+
}
|
package/src/Resend.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Resend sender: the core `EmailMessage` mapped onto Resend's
|
|
3
|
+
* `POST /emails` (and `POST /emails/batch`) JSON, sent over Effect's
|
|
4
|
+
* `HttpClient` with no SDK — the library form of the hand-rolled senders the
|
|
5
|
+
* website, gardeneria and preferenceflow applications carry.
|
|
6
|
+
*
|
|
7
|
+
* The properties those senders pin, kept here:
|
|
8
|
+
*
|
|
9
|
+
* - a fixed literal host ({@link DEFAULT_BASE_URL});
|
|
10
|
+
* - `redirect: "error"` on the underlying `fetch`. Workers' default
|
|
11
|
+
* (`follow`) replays the request at the redirect target **with the headers
|
|
12
|
+
* intact** — it does not drop `Authorization` on a cross-origin hop the way
|
|
13
|
+
* a browser does — so a 3xx from a hijacked or misconfigured
|
|
14
|
+
* `api.resend.com` would hand the API key to whatever host the response
|
|
15
|
+
* named. A Resend send has no legitimate reason to redirect, so the correct
|
|
16
|
+
* behaviour is to fail loudly (SEC-SECRET-001);
|
|
17
|
+
* - a non-2xx becomes an `EmailSendError` carrying the status and a
|
|
18
|
+
* **truncated** body, never the raw unbounded one, and never the key;
|
|
19
|
+
* - no retry: the caller decides the policy.
|
|
20
|
+
*
|
|
21
|
+
* @module Resend
|
|
22
|
+
* @since 0.1.0
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as Arr from "effect/Array";
|
|
26
|
+
import * as Effect from "effect/Effect";
|
|
27
|
+
import * as Layer from "effect/Layer";
|
|
28
|
+
import * as Option from "effect/Option";
|
|
29
|
+
import * as Schema from "effect/Schema";
|
|
30
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
31
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
32
|
+
import * as HttpClientResponse from "effect/http/HttpClientResponse";
|
|
33
|
+
import {
|
|
34
|
+
describeHttpError,
|
|
35
|
+
droppingSender,
|
|
36
|
+
EmailSendError,
|
|
37
|
+
EmailService,
|
|
38
|
+
httpErrorStatus,
|
|
39
|
+
isSuccessStatus,
|
|
40
|
+
refuseRedirects,
|
|
41
|
+
rejectionMessage,
|
|
42
|
+
type EmailMessage,
|
|
43
|
+
type EmailSendResult,
|
|
44
|
+
type Recipient,
|
|
45
|
+
} from "@thomasfosterau/effect-connect";
|
|
46
|
+
import { ResendConfig } from "./ResendConfig.ts";
|
|
47
|
+
|
|
48
|
+
const ResendAttachment = Schema.Struct({
|
|
49
|
+
filename: Schema.String,
|
|
50
|
+
content: Schema.String,
|
|
51
|
+
content_type: Schema.String,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const ResendTag = Schema.Struct({
|
|
55
|
+
name: Schema.String,
|
|
56
|
+
value: Schema.String,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The wire payload Resend's send endpoint accepts, stated once and checked
|
|
61
|
+
* against what `EmailMessage` carries. Optional members are `Schema.optional`
|
|
62
|
+
* rather than `optionalKey` on purpose: the mapping assigns `undefined` for
|
|
63
|
+
* an absent message field and JSON encoding drops the key.
|
|
64
|
+
*
|
|
65
|
+
* @since 0.1.0
|
|
66
|
+
*/
|
|
67
|
+
export const ResendSendPayload = Schema.Struct({
|
|
68
|
+
from: Schema.String,
|
|
69
|
+
to: Schema.NonEmptyArray(Schema.String),
|
|
70
|
+
cc: Schema.optional(Schema.Array(Schema.String)),
|
|
71
|
+
bcc: Schema.optional(Schema.Array(Schema.String)),
|
|
72
|
+
reply_to: Schema.optional(Schema.Array(Schema.String)),
|
|
73
|
+
subject: Schema.String,
|
|
74
|
+
text: Schema.optional(Schema.String),
|
|
75
|
+
html: Schema.optional(Schema.String),
|
|
76
|
+
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
77
|
+
attachments: Schema.optional(Schema.Array(ResendAttachment)),
|
|
78
|
+
tags: Schema.optional(Schema.Array(ResendTag)),
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* The decoded type of the {@link ResendSendPayload} schema.
|
|
82
|
+
*
|
|
83
|
+
* @since 0.1.0
|
|
84
|
+
*/
|
|
85
|
+
export type ResendSendPayload = typeof ResendSendPayload.Type;
|
|
86
|
+
|
|
87
|
+
const SendResponse = Schema.Struct({ id: Schema.String });
|
|
88
|
+
const BatchResponse = Schema.Struct({ data: Schema.Array(SendResponse) });
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `Name <address>` when the recipient has a display name, else the bare address.
|
|
92
|
+
*
|
|
93
|
+
* @since 0.1.0
|
|
94
|
+
*/
|
|
95
|
+
export const formatAddress = (recipient: Recipient): string =>
|
|
96
|
+
Option.match(Option.fromNullishOr(recipient.name), {
|
|
97
|
+
onNone: () => recipient.email,
|
|
98
|
+
onSome: (name) => `${name} <${recipient.email}>`,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Map a core message onto Resend's send payload: recipients as
|
|
103
|
+
* `Name <address>` strings, `replyTo` as a one-element `reply_to`, core
|
|
104
|
+
* `tags` as Resend tags whose name and value are both the tag.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* import * as assert from "node:assert";
|
|
109
|
+
* import { toResendPayload } from "@thomasfosterau/effect-connect-resend";
|
|
110
|
+
*
|
|
111
|
+
* const payload = toResendPayload({
|
|
112
|
+
* from: { email: "noreply@example.com", name: "Example" },
|
|
113
|
+
* to: [{ email: "reader@example.com" }],
|
|
114
|
+
* replyTo: { email: "support@example.com" },
|
|
115
|
+
* subject: "Welcome",
|
|
116
|
+
* tags: ["welcome"],
|
|
117
|
+
* });
|
|
118
|
+
*
|
|
119
|
+
* assert.strictEqual(payload.from, "Example <noreply@example.com>");
|
|
120
|
+
* assert.deepStrictEqual(payload.to, ["reader@example.com"]);
|
|
121
|
+
* assert.deepStrictEqual(payload.reply_to, ["support@example.com"]);
|
|
122
|
+
* assert.deepStrictEqual(payload.tags, [{ name: "welcome", value: "welcome" }]);
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* @since 0.1.0
|
|
126
|
+
*/
|
|
127
|
+
export const toResendPayload = (message: EmailMessage): ResendSendPayload => ({
|
|
128
|
+
from: formatAddress(message.from),
|
|
129
|
+
to: [formatAddress(message.to[0]), ...message.to.slice(1).map(formatAddress)],
|
|
130
|
+
cc: message.cc?.map(formatAddress),
|
|
131
|
+
bcc: message.bcc?.map(formatAddress),
|
|
132
|
+
reply_to: Option.getOrUndefined(
|
|
133
|
+
Option.map(Option.fromNullishOr(message.replyTo), (replyTo) => [formatAddress(replyTo)]),
|
|
134
|
+
),
|
|
135
|
+
subject: message.subject,
|
|
136
|
+
text: message.text,
|
|
137
|
+
html: message.html,
|
|
138
|
+
headers: message.headers,
|
|
139
|
+
attachments: message.attachments?.map((attachment) => ({
|
|
140
|
+
filename: attachment.filename,
|
|
141
|
+
content: attachment.content,
|
|
142
|
+
content_type: attachment.contentType,
|
|
143
|
+
})),
|
|
144
|
+
tags: message.tags?.map((tag) => ({ name: tag, value: tag })),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const PROVIDER = "resend";
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The core `EmailService` over Resend's REST API and the `HttpClient` in
|
|
151
|
+
* context. `sendBatch` uses `POST /emails/batch` (one request, up to 100
|
|
152
|
+
* messages per Resend's limit — the caller chunks beyond that).
|
|
153
|
+
*
|
|
154
|
+
* @since 0.1.0
|
|
155
|
+
*/
|
|
156
|
+
export const layerEmailService: Layer.Layer<
|
|
157
|
+
EmailService,
|
|
158
|
+
never,
|
|
159
|
+
ResendConfig | HttpClient.HttpClient
|
|
160
|
+
> = Layer.effect(
|
|
161
|
+
EmailService,
|
|
162
|
+
Effect.gen(function* () {
|
|
163
|
+
const config = yield* ResendConfig;
|
|
164
|
+
const http = yield* HttpClient.HttpClient;
|
|
165
|
+
|
|
166
|
+
/** One `POST` to Resend, decoded with `schema` on a 2xx. */
|
|
167
|
+
const post = Effect.fn("Resend.post")(function* <A, I>(
|
|
168
|
+
path: string,
|
|
169
|
+
body: ResendSendPayload | ReadonlyArray<ResendSendPayload>,
|
|
170
|
+
schema: Schema.Codec<A, I>,
|
|
171
|
+
) {
|
|
172
|
+
const request = yield* HttpClientRequest.post(`${config.baseUrl}${path}`, {
|
|
173
|
+
acceptJson: true,
|
|
174
|
+
}).pipe(
|
|
175
|
+
HttpClientRequest.bearerToken(config.apiKey),
|
|
176
|
+
HttpClientRequest.bodyJson(body),
|
|
177
|
+
Effect.mapError(
|
|
178
|
+
(cause) =>
|
|
179
|
+
new EmailSendError({
|
|
180
|
+
provider: PROVIDER,
|
|
181
|
+
message: "Resend request body could not be encoded as JSON",
|
|
182
|
+
status: Option.none(),
|
|
183
|
+
cause,
|
|
184
|
+
}),
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
const response = yield* http.execute(request).pipe(
|
|
188
|
+
refuseRedirects,
|
|
189
|
+
Effect.mapError(
|
|
190
|
+
(error) =>
|
|
191
|
+
new EmailSendError({
|
|
192
|
+
provider: PROVIDER,
|
|
193
|
+
message: `Resend was unreachable: ${describeHttpError(error)}`,
|
|
194
|
+
status: httpErrorStatus(error),
|
|
195
|
+
cause: error,
|
|
196
|
+
}),
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
if (!isSuccessStatus(response.status)) {
|
|
200
|
+
const message = yield* rejectionMessage(response, "Resend rejected the send");
|
|
201
|
+
return yield* new EmailSendError({
|
|
202
|
+
provider: PROVIDER,
|
|
203
|
+
message,
|
|
204
|
+
status: Option.some(response.status),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe(
|
|
208
|
+
Effect.mapError(
|
|
209
|
+
(cause) =>
|
|
210
|
+
new EmailSendError({
|
|
211
|
+
provider: PROVIDER,
|
|
212
|
+
message: `Resend answered ${response.status} with an unexpected body`,
|
|
213
|
+
status: Option.some(response.status),
|
|
214
|
+
cause,
|
|
215
|
+
}),
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const send = Effect.fn("Resend.send")(function* (message: EmailMessage) {
|
|
221
|
+
const { id } = yield* post("/emails", toResendPayload(message), SendResponse);
|
|
222
|
+
const result: EmailSendResult = { id: Option.some(id) };
|
|
223
|
+
return result;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const sendBatch = Effect.fn("Resend.sendBatch")(function* (
|
|
227
|
+
messages: ReadonlyArray<EmailMessage>,
|
|
228
|
+
) {
|
|
229
|
+
if (!Arr.isReadonlyArrayNonEmpty(messages)) {
|
|
230
|
+
const none: ReadonlyArray<EmailSendResult> = [];
|
|
231
|
+
return none;
|
|
232
|
+
}
|
|
233
|
+
const { data } = yield* post("/emails/batch", messages.map(toResendPayload), BatchResponse);
|
|
234
|
+
return data.map((entry): EmailSendResult => ({ id: Option.some(entry.id) }));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return { send, sendBatch };
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The sender used when no API key is configured: every send **succeeds**
|
|
243
|
+
* without sending, and logs the subject only.
|
|
244
|
+
*
|
|
245
|
+
* Deliberately succeeds rather than failing. The consuming applications'
|
|
246
|
+
* preview and dev stages deploy without the credential, so a failing stub
|
|
247
|
+
* would turn "no mail configured" into "signup is broken" on every preview
|
|
248
|
+
* stage — and inside a queue consumer, into every notification in the batch
|
|
249
|
+
* being re-delivered, forever. That trade is only safe while the mail is
|
|
250
|
+
* advisory (a verification a user can still sign in without, a notification
|
|
251
|
+
* that also went out as push); if a send ever gates an account, fail loudly
|
|
252
|
+
* at boot instead of installing this.
|
|
253
|
+
*
|
|
254
|
+
* @since 0.1.0
|
|
255
|
+
*/
|
|
256
|
+
export const noop: Layer.Layer<EmailService> = Layer.succeed(
|
|
257
|
+
EmailService,
|
|
258
|
+
// Logs the subject only — never the recipient (PII) and never the body
|
|
259
|
+
// (a verification URL IS the token). The subject alone answers the
|
|
260
|
+
// question this line exists for: "is mail being dropped for want of a
|
|
261
|
+
// credential?"
|
|
262
|
+
droppingSender<EmailMessage>(
|
|
263
|
+
(message) =>
|
|
264
|
+
`[effect-connect-resend] no RESEND_API_KEY configured; dropping "${message.subject}"`,
|
|
265
|
+
),
|
|
266
|
+
);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Resend credential and host, as a service with two constructors: a
|
|
3
|
+
* literal (`layer`) and one read from `effect/Config` (`layerConfig`).
|
|
4
|
+
*
|
|
5
|
+
* @module ResendConfig
|
|
6
|
+
* @since 0.1.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as Config from "effect/Config";
|
|
10
|
+
import * as Context from "effect/Context";
|
|
11
|
+
import * as Effect from "effect/Effect";
|
|
12
|
+
import * as Layer from "effect/Layer";
|
|
13
|
+
import type * as Redacted from "effect/Redacted";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resend's API host, as a compile-time constant. A hard-coded literal rather
|
|
17
|
+
* than anything derived from input: a `fetch` whose host could be steered is
|
|
18
|
+
* exactly the outbound-request hazard the consuming applications' security
|
|
19
|
+
* policies forbid (SEC-SSRF-001), and the only values that ever vary in a
|
|
20
|
+
* send are the message fields in the body. `baseUrl` exists for a stub
|
|
21
|
+
* server in a test, never for request input.
|
|
22
|
+
*
|
|
23
|
+
* @since 0.1.0
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_BASE_URL = "https://api.resend.com";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What {@link ResendConfig.layer} takes.
|
|
29
|
+
*
|
|
30
|
+
* @since 0.1.0
|
|
31
|
+
*/
|
|
32
|
+
export interface ResendConfigInput {
|
|
33
|
+
/** The API key. Wrap with `Redacted.make(...)` or read it via `Config.redacted(...)`. */
|
|
34
|
+
readonly apiKey: Redacted.Redacted;
|
|
35
|
+
/** The API host, without a trailing slash. Defaults to {@link DEFAULT_BASE_URL}. */
|
|
36
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- a consumer-facing config input: an omitted key IS the default, and asking every caller to spell `Option.none()` for it buys nothing
|
|
37
|
+
readonly baseUrl?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The Resend configuration as a service.
|
|
42
|
+
*
|
|
43
|
+
* @since 0.1.0
|
|
44
|
+
*/
|
|
45
|
+
export class ResendConfig extends Context.Service<
|
|
46
|
+
ResendConfig,
|
|
47
|
+
{
|
|
48
|
+
readonly apiKey: Redacted.Redacted;
|
|
49
|
+
readonly baseUrl: string;
|
|
50
|
+
}
|
|
51
|
+
>()("@thomasfosterau/effect-connect-resend/ResendConfig") {
|
|
52
|
+
/**
|
|
53
|
+
* A configuration from literal values.
|
|
54
|
+
*
|
|
55
|
+
* @since 0.1.0
|
|
56
|
+
*/
|
|
57
|
+
static readonly layer = (config: ResendConfigInput): Layer.Layer<ResendConfig> =>
|
|
58
|
+
Layer.succeed(ResendConfig, {
|
|
59
|
+
apiKey: config.apiKey,
|
|
60
|
+
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A configuration read through `effect/Config`: `RESEND_API_KEY`
|
|
65
|
+
* (required, redacted) and `RESEND_BASE_URL` (optional). Fails with a
|
|
66
|
+
* `ConfigError` when `RESEND_API_KEY` is unset or empty; an empty
|
|
67
|
+
* `RESEND_BASE_URL` falls back to {@link DEFAULT_BASE_URL}.
|
|
68
|
+
*
|
|
69
|
+
* @since 0.1.0
|
|
70
|
+
*/
|
|
71
|
+
static readonly layerConfig = Layer.effect(
|
|
72
|
+
ResendConfig,
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
const apiKey = yield* Config.Redacted("RESEND_API_KEY");
|
|
75
|
+
const baseUrl = yield* Config.String("RESEND_BASE_URL").pipe(
|
|
76
|
+
Config.withDefault(DEFAULT_BASE_URL),
|
|
77
|
+
);
|
|
78
|
+
return { apiKey, baseUrl };
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@thomasfosterau/effect-connect-resend` — the Resend provider for
|
|
3
|
+
* `@thomasfosterau/effect-connect`: `POST /emails` over `effect/http`
|
|
4
|
+
* with no SDK, a `Redacted` key, a no-op layer for un-provisioned stages and
|
|
5
|
+
* an env-driven resolver that picks between the two.
|
|
6
|
+
*
|
|
7
|
+
* @module index
|
|
8
|
+
* @since 0.1.0
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as Layer from "effect/Layer";
|
|
12
|
+
import * as Option from "effect/Option";
|
|
13
|
+
import * as Redacted from "effect/Redacted";
|
|
14
|
+
import type * as HttpClient from "effect/http/HttpClient";
|
|
15
|
+
import {
|
|
16
|
+
boundSecret,
|
|
17
|
+
selectTransport,
|
|
18
|
+
type EmailService,
|
|
19
|
+
type EnvSecretBinding,
|
|
20
|
+
} from "@thomasfosterau/effect-connect";
|
|
21
|
+
import { layerEmailService, noop } from "./Resend.ts";
|
|
22
|
+
import { ResendConfig, type ResendConfigInput } from "./ResendConfig.ts";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The Resend sender: the payload mapping, the `EmailService` layer and the no-op transport.
|
|
26
|
+
*
|
|
27
|
+
* @since 0.1.0
|
|
28
|
+
*/
|
|
29
|
+
export * from "./Resend.ts";
|
|
30
|
+
/**
|
|
31
|
+
* The Resend credential and host, as a service.
|
|
32
|
+
*
|
|
33
|
+
* @since 0.1.0
|
|
34
|
+
*/
|
|
35
|
+
export * from "./ResendConfig.ts";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The Resend provider from literal configuration, over the `HttpClient` in
|
|
39
|
+
* context (provide `FetchHttpClient.layer` on Workers, browsers and Node).
|
|
40
|
+
*
|
|
41
|
+
* @since 0.1.0
|
|
42
|
+
*/
|
|
43
|
+
export const layer = (
|
|
44
|
+
config: ResendConfigInput,
|
|
45
|
+
): Layer.Layer<EmailService, never, HttpClient.HttpClient> =>
|
|
46
|
+
layerEmailService.pipe(Layer.provide(ResendConfig.layer(config)));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The Resend provider configured through `effect/Config` (`RESEND_API_KEY`,
|
|
50
|
+
* optional `RESEND_BASE_URL`), over the `HttpClient` in context. Fails at
|
|
51
|
+
* layer construction with a `ConfigError` when `RESEND_API_KEY` is unset or
|
|
52
|
+
* empty (an empty variable reads as missing); an empty `RESEND_BASE_URL`
|
|
53
|
+
* falls back to the default host. On a Worker, {@link resolveTransport}
|
|
54
|
+
* tolerates a missing key.
|
|
55
|
+
*
|
|
56
|
+
* @since 0.1.0
|
|
57
|
+
*/
|
|
58
|
+
export const layerConfig = layerEmailService.pipe(Layer.provide(ResendConfig.layerConfig));
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The env {@link resolveTransport} reads: the Worker's `RESEND_API_KEY` binding, if any.
|
|
62
|
+
*
|
|
63
|
+
* @since 0.1.0
|
|
64
|
+
*/
|
|
65
|
+
export interface ResendEnv {
|
|
66
|
+
readonly RESEND_API_KEY?: EnvSecretBinding;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The credential from a Worker env, normalised: `none` when the binding is
|
|
71
|
+
* absent, `null`, or blank (a stage can bind `""` by hand, and a truthiness
|
|
72
|
+
* check is what makes that the same case as "unset" rather than a real
|
|
73
|
+
* transport built around an empty bearer token).
|
|
74
|
+
*
|
|
75
|
+
* @since 0.1.0
|
|
76
|
+
*/
|
|
77
|
+
export const apiKeyFromEnv = (env: ResendEnv): Option.Option<Redacted.Redacted> =>
|
|
78
|
+
boundSecret(env.RESEND_API_KEY);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pick the transport for a Worker's environment: the live sender (on the
|
|
82
|
+
* platform `fetch`, via `FetchHttpClient`) when `RESEND_API_KEY` is present,
|
|
83
|
+
* the logging {@link noop} when it is not. One call site per Worker, so the
|
|
84
|
+
* fallback cannot be forgotten at one of two.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* import * as assert from "node:assert";
|
|
89
|
+
* import * as Effect from "effect/Effect";
|
|
90
|
+
* import * as Option from "effect/Option";
|
|
91
|
+
* import { send } from "@thomasfosterau/effect-connect";
|
|
92
|
+
* import { resolveTransport } from "@thomasfosterau/effect-connect-resend";
|
|
93
|
+
*
|
|
94
|
+
* // No `RESEND_API_KEY` bound (a blank one counts as unbound): the no-op
|
|
95
|
+
* // transport, which logs the subject and succeeds without a network call.
|
|
96
|
+
* const result = Effect.runSync(
|
|
97
|
+
* send({
|
|
98
|
+
* from: { email: "noreply@example.com" },
|
|
99
|
+
* to: [{ email: "reader@example.com" }],
|
|
100
|
+
* subject: "Welcome",
|
|
101
|
+
* }).pipe(Effect.provide(resolveTransport({ RESEND_API_KEY: " " }))),
|
|
102
|
+
* );
|
|
103
|
+
*
|
|
104
|
+
* assert.ok(Option.isNone(result.id));
|
|
105
|
+
* ```
|
|
106
|
+
*
|
|
107
|
+
* @since 0.1.0
|
|
108
|
+
*/
|
|
109
|
+
export const resolveTransport = (env: ResendEnv): Layer.Layer<EmailService> =>
|
|
110
|
+
selectTransport(apiKeyFromEnv(env), (apiKey) => layer({ apiKey }), noop);
|