@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 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
- # @thomasfosterau/effect-connect-resend
1
+ # `@thomasfosterau/effect-connect-resend`
2
2
 
3
- Placeholder reserved for npm trusted publishing; the first real release is 0.1.0, from https://github.com/thomasfosterau/effect-connect.
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
@@ -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.0.0",
4
- "description": "Placeholder reserved for npm trusted publishing; the first real release of @thomasfosterau/effect-connect-resend is 0.1.0.",
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": "git+https://github.com/thomasfosterau/effect-connect.git",
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);