@shivaedev/email 0.0.0 → 0.1.1

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -1
  4. package/dist/Email.d.ts +10 -0
  5. package/dist/Email.d.ts.map +1 -0
  6. package/dist/Email.js +9 -0
  7. package/dist/Email.js.map +1 -0
  8. package/dist/server/Mailer.d.ts +16 -0
  9. package/dist/server/Mailer.d.ts.map +1 -0
  10. package/dist/server/Mailer.js +12 -0
  11. package/dist/server/Mailer.js.map +1 -0
  12. package/dist/server/MailerError.d.ts +10 -0
  13. package/dist/server/MailerError.d.ts.map +1 -0
  14. package/dist/server/MailerError.js +10 -0
  15. package/dist/server/MailerError.js.map +1 -0
  16. package/dist/server/adapters/resendMailerLayer.d.ts +5 -0
  17. package/dist/server/adapters/resendMailerLayer.d.ts.map +1 -0
  18. package/dist/server/adapters/resendMailerLayer.js +39 -0
  19. package/dist/server/adapters/resendMailerLayer.js.map +1 -0
  20. package/dist/server/defineEmailQueue.d.ts +20 -0
  21. package/dist/server/defineEmailQueue.d.ts.map +1 -0
  22. package/dist/server/defineEmailQueue.js +29 -0
  23. package/dist/server/defineEmailQueue.js.map +1 -0
  24. package/dist/server/testing/adapters/inbox.d.ts +23 -0
  25. package/dist/server/testing/adapters/inbox.d.ts.map +1 -0
  26. package/dist/server/testing/adapters/inbox.js +39 -0
  27. package/dist/server/testing/adapters/inbox.js.map +1 -0
  28. package/package.json +77 -3
  29. package/src/Email.ts +10 -0
  30. package/src/server/Mailer.ts +16 -0
  31. package/src/server/MailerError.ts +10 -0
  32. package/src/server/adapters/resendMailerLayer.ts +45 -0
  33. package/src/server/defineEmailQueue.ts +52 -0
  34. package/src/server/testing/adapters/inbox.ts +44 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1 - 2026-11-02
4
+
5
+ ### Changed
6
+
7
+ - Update `@shivaedev/http` from 0.3.1 to 0.4.0.
8
+ - Update `@shivaedev/jobs` from 0.29.1 to 0.29.2.
9
+ - Update `@shivaedev/service` from 0.6.0 to 0.6.1.
10
+ - Update `@shivaedev/test-story` from 0.32.0 to 0.33.0.
11
+
12
+ ## 0.1.0 - 2026-11-01
13
+
14
+ ### Added
15
+
16
+ - Send transactional email through a Resend-backed Mailer or development log, with typed message queues, job-id idempotency, backoff and provider-timed rate-limit retries.
17
+ - Read delivered messages and one-time links through a fake Resend inbox in stories.
18
+ - Include the email package in Platform for applications, including the starter's queued authentication emails.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShivaeDev
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,69 @@
1
1
  # @shivaedev/email
2
2
 
3
- Placeholder. The package is published from https://github.com/ShivaeDev/platform.
3
+ Send transactional email through Resend with the same Effect services and durable jobs as the rest of your application. Share the delivery machinery once, and let each feature describe its own messages.
4
+
5
+ ## Why you want this
6
+
7
+ Confirmation and password reset emails need more than an HTTP call: a durable payload, rendering, retries, idempotency and an inbox a story can read. Define those messages once and enqueue them from the web process:
8
+
9
+ ```ts
10
+ import { Effect, Redacted, Schema } from "effect";
11
+ import { defineEmailQueue } from "@shivaedev/email/server/defineEmailQueue.ts";
12
+
13
+ const Confirmation = defineEmailQueue(Schema.Struct({ to: Schema.String, url: Schema.Redacted(Schema.String) }), ({ to, url }) =>
14
+ Effect.succeed({
15
+ from: "Accounts <accounts@example.com>",
16
+ to,
17
+ subject: "Confirm your account",
18
+ text: `Confirm your account: ${Redacted.value(url)}`,
19
+ html: `<p>Confirm your account</p>`,
20
+ }),
21
+ );
22
+
23
+ const confirm = Confirmation.enqueue({
24
+ to: "alice@example.com",
25
+ url: Redacted.make("https://example.com/confirm?token=one-time-token"),
26
+ });
27
+ ```
28
+
29
+ ## Using it
30
+
31
+ ### Describe a message and render it
32
+
33
+ `defineEmailQueue(schema, render, options?)` accepts an object Schema or an object union, and an Effect renderer returning `Email` from `@shivaedev/email/Email.ts`: `from`, `to`, `subject`, `text` and `html`. The optional `name` defaults to `transactional-email`; give separate queues distinct names. The renderer can read application services. Escape application values when interpolating them into HTML.
34
+
35
+ Use `Schema.Redacted(Schema.String)` for one-time links and keep them wrapped until rendering. The jobs codec durably stores the value and restores its Redacted wrapper in the worker. Redaction masks diagnostics; it does not encrypt PostgreSQL storage.
36
+
37
+ ### Compose the web process and worker
38
+
39
+ The returned `producerLayer` registers the queue without running a worker or requiring a mailer. `enqueue(message)` reads its `Jobs` service and returns the native optional job id. `queue`, `worker` and `Jobs` remain available for native jobs composition and diagnostics.
40
+
41
+ Provide `producerLayer` to the web process and `workerLayer` to the worker process. Both read the scoped `PgBoss` client provided by `pgBossLayer` from `@shivaedev/jobs/server/service.ts`. Install the jobs schema through that package's migration path.
42
+
43
+ ```ts
44
+ import { Layer } from "effect";
45
+ import { resendMailerLayer } from "@shivaedev/email/server/adapters/resendMailerLayer.ts";
46
+ import { logMailerLayer } from "@shivaedev/email/server/Mailer.ts";
47
+
48
+ const producer = Confirmation.producerLayer;
49
+ const worker = Confirmation.workerLayer.pipe(Layer.provide(resendMailerLayer()));
50
+ const developmentWorker = Confirmation.workerLayer.pipe(Layer.provide(logMailerLayer));
51
+ ```
52
+
53
+ `Mailer` is a service defined through `@shivaedev/service`. Its `send(email, idempotencyKey)` method reports typed `MailerError` or native jobs `RetryAt` failures. `resendMailerLayer()` reads `RESEND_API_KEY` with `Config.Redacted`; its options accept an already redacted `apiKey` for scoped configuration. `logMailerLayer` writes the rendered message and key to the development log instead of sending it. The jobs redactor masks sensitive URL parameters in worker logs.
54
+
55
+ The email worker sends its job id as Resend's idempotency key on every attempt. Ordinary failures retry with the jobs engine's exponential backoff. A Resend 429 uses `Retry-After` seconds or HTTP date as a native timed deferral without consuming an ordinary retry; a missing or invalid header uses a short timed fallback. `MailerError` keeps the provider response redacted and uses a fixed message in recorded failures.
56
+
57
+ ### Read email in stories
58
+
59
+ Install `@shivaedev/test-story` as a development dependency. In a scoped story setup, acquire `inbox()` from `@shivaedev/email/server/testing/adapters/inbox.ts` and provide its `mailerLayer` to the real worker. It uses the Resend SDK against a loopback fake HTTP endpoint.
60
+
61
+ `nextEmail` waits for the next accepted message, including its `idempotencyKey`; `emails()` reads accepted messages so far. `emailLink(email)` reads the first HTTP link in the message's text. A story can follow that link through the application's HTTP or React boundary. The inbox's `endpoint` exposes the native fake HTTP controls for scripting provider failures.
62
+
63
+ ### Install and limits
64
+
65
+ ```sh
66
+ pnpm add @shivaedev/email effect
67
+ ```
68
+
69
+ Resend is the only delivery provider and is included as a regular dependency. The jobs package requires PostgreSQL. Delivery uses Resend's idempotency window; the package does not promise permanent exactly-once delivery. Application policy owns expiring links and retaining queued messages.
@@ -0,0 +1,10 @@
1
+ import { Schema } from "effect";
2
+ export declare const Email: Schema.Struct<{
3
+ readonly from: Schema.String;
4
+ readonly html: Schema.String;
5
+ readonly subject: Schema.String;
6
+ readonly text: Schema.String;
7
+ readonly to: Schema.String;
8
+ }>;
9
+ export type Email = typeof Email.Type;
10
+ //# sourceMappingURL=Email.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Email.d.ts","sourceRoot":"","sources":["../src/Email.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,eAAO,MAAM,KAAK;;;;;;EAMhB,CAAC;AACH,MAAM,MAAM,KAAK,GAAG,OAAO,KAAK,CAAC,IAAI,CAAC"}
package/dist/Email.js ADDED
@@ -0,0 +1,9 @@
1
+ import { Schema } from "effect";
2
+ export const Email = Schema.Struct({
3
+ from: Schema.String,
4
+ html: Schema.String,
5
+ subject: Schema.String,
6
+ text: Schema.String,
7
+ to: Schema.String,
8
+ });
9
+ //# sourceMappingURL=Email.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Email.js","sourceRoot":"","sources":["../src/Email.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC,MAAM;IACnB,IAAI,EAAE,MAAM,CAAC,MAAM;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM;IACtB,IAAI,EAAE,MAAM,CAAC,MAAM;IACnB,EAAE,EAAE,MAAM,CAAC,MAAM;CACjB,CAAC,CAAC"}
@@ -0,0 +1,16 @@
1
+ import { Effect } from "effect";
2
+ import type { RetryAt } from "@shivaedev/jobs/server/definition.ts";
3
+ import type { MailerError } from "./MailerError.ts";
4
+ export declare const Mailer: import("effect/Context").Service<"@shivaedev/email/Mailer", Readonly<{
5
+ readonly send: (email: {
6
+ readonly from: string;
7
+ readonly html: string;
8
+ readonly subject: string;
9
+ readonly text: string;
10
+ readonly to: string;
11
+ }, idempotencyKey: string) => Effect.Effect<void, MailerError | RetryAt, never>;
12
+ }>> & {
13
+ readonly layer: import("effect/Layer").Layer<"@shivaedev/email/Mailer", never, never>;
14
+ };
15
+ export declare const logMailerLayer: import("effect/Layer").Layer<"@shivaedev/email/Mailer", never, never>;
16
+ //# sourceMappingURL=Mailer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Mailer.d.ts","sourceRoot":"","sources":["../../src/server/Mailer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sCAAsC,CAAC;AAGpE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,eAAO,MAAM,MAAM;;;;;;;;;;CAOjB,CAAC;AAEH,eAAO,MAAM,cAAc,uEAAe,CAAC"}
@@ -0,0 +1,12 @@
1
+ import { Effect } from "effect";
2
+ import { defineService } from "@shivaedev/service/defineService.ts";
3
+ export const Mailer = defineService({
4
+ id: "@shivaedev/email/Mailer",
5
+ initialize: Effect.void,
6
+ methods: () => ({
7
+ send: (email, idempotencyKey) => Effect.logInfo("Email", { ...email, idempotencyKey }),
8
+ }),
9
+ requires: [],
10
+ });
11
+ export const logMailerLayer = Mailer.layer;
12
+ //# sourceMappingURL=Mailer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Mailer.js","sourceRoot":"","sources":["../../src/server/Mailer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,OAAO,EAAE,aAAa,EAAE,MAAM,qCAAqC,CAAC;AAIpE,MAAM,CAAC,MAAM,MAAM,GAAG,aAAa,CAAC;IACnC,EAAE,EAAE,yBAAyB;IAC7B,UAAU,EAAE,MAAM,CAAC,IAAI;IACvB,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;QACf,IAAI,EAAE,CAAC,KAAY,EAAE,cAAsB,EAA8C,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,GAAG,KAAK,EAAE,cAAc,EAAE,CAAC;KACjJ,CAAC;IACF,QAAQ,EAAE,EAAE;CACZ,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,KAAK,CAAC"}
@@ -0,0 +1,10 @@
1
+ import { Schema } from "effect";
2
+ declare const MailerError_base: Schema.Class<MailerError, Schema.TaggedStruct<"MailerError", {
3
+ readonly original: Schema.Redacted<Schema.Unknown>;
4
+ readonly status: Schema.optionalKey<Schema.Number>;
5
+ }>, import("effect/Cause").YieldableError>;
6
+ export declare class MailerError extends MailerError_base {
7
+ get message(): string;
8
+ }
9
+ export {};
10
+ //# sourceMappingURL=MailerError.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MailerError.d.ts","sourceRoot":"","sources":["../../src/server/MailerError.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;;;;;AAEhC,qBAAa,WAAY,SAAQ,gBAG/B;IACD,IAAa,OAAO,WAEnB;CACD"}
@@ -0,0 +1,10 @@
1
+ import { Schema } from "effect";
2
+ export class MailerError extends Schema.TaggedError()("MailerError", {
3
+ original: Schema.Redacted(Schema.Unknown),
4
+ status: Schema.optionalKey(Schema.Number),
5
+ }) {
6
+ get message() {
7
+ return "Resend could not deliver the email";
8
+ }
9
+ }
10
+ //# sourceMappingURL=MailerError.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MailerError.js","sourceRoot":"","sources":["../../src/server/MailerError.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,OAAO,WAAY,SAAQ,MAAM,CAAC,WAAW,EAAe,CAAC,aAAa,EAAE;IACjF,QAAQ,EAAE,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC;IACzC,MAAM,EAAE,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,MAAM,CAAC;CACzC,CAAC;IACD,IAAa,OAAO;QACnB,OAAO,oCAAoC,CAAC;IAC7C,CAAC;CACD"}
@@ -0,0 +1,5 @@
1
+ import { Config, Layer, Redacted } from "effect";
2
+ export declare function resendMailerLayer(options?: {
3
+ readonly apiKey?: Redacted.Redacted<string>;
4
+ }): Layer.Layer<"@shivaedev/email/Mailer", Config.ConfigError, never>;
5
+ //# sourceMappingURL=resendMailerLayer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resendMailerLayer.d.ts","sourceRoot":"","sources":["../../../src/server/adapters/resendMailerLayer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAoB,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAQnE,wBAAgB,iBAAiB,CAAC,OAAO,GAAE;IAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;CAAO,qEAS9F"}
@@ -0,0 +1,39 @@
1
+ import { Config, DateTime, Effect, Layer, Redacted } from "effect";
2
+ import { Resend } from "resend";
3
+ import { parseRetryAfter } from "@shivaedev/http/parseRetryAfter.ts";
4
+ import { RetryAt } from "@shivaedev/jobs/server/definition.ts";
5
+ import { Mailer } from "#server/Mailer.ts";
6
+ import { MailerError } from "#server/MailerError.ts";
7
+ export function resendMailerLayer(options = {}) {
8
+ return Layer.effect(Mailer, Effect.gen(function* () {
9
+ const apiKey = options.apiKey ?? (yield* Config.Redacted("RESEND_API_KEY"));
10
+ const resend = new Resend(Redacted.value(apiKey));
11
+ return { send: sendEmail(resend) };
12
+ }));
13
+ }
14
+ function sendEmail(resend) {
15
+ return (email, idempotencyKey) => Effect.gen(function* () {
16
+ const response = yield* Effect.tryPromise({
17
+ catch: (original) => new MailerError({ original: Redacted.make(original) }),
18
+ try: (signal) => {
19
+ const request = { idempotencyKey, signal };
20
+ return resend.emails.send(email, request);
21
+ },
22
+ });
23
+ if (response.error === null) {
24
+ return;
25
+ }
26
+ if (response.error.statusCode === 429) {
27
+ const now = yield* DateTime.now;
28
+ return yield* new RetryAt({
29
+ at: parseRetryAfter(response.headers?.["retry-after"], now) ?? DateTime.add(now, { seconds: 1 }),
30
+ reason: "Resend rate limit",
31
+ });
32
+ }
33
+ return yield* new MailerError({
34
+ original: Redacted.make(response.error),
35
+ ...(response.error.statusCode === null ? {} : { status: response.error.statusCode }),
36
+ });
37
+ });
38
+ }
39
+ //# sourceMappingURL=resendMailerLayer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resendMailerLayer.js","sourceRoot":"","sources":["../../../src/server/adapters/resendMailerLayer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AACnE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,eAAe,EAAE,MAAM,oCAAoC,CAAC;AACrE,OAAO,EAAE,OAAO,EAAE,MAAM,sCAAsC,CAAC;AAE/D,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAC3C,OAAO,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAErD,MAAM,UAAU,iBAAiB,CAAC,OAAO,GAAoD,EAAE;IAC9F,OAAO,KAAK,CAAC,MAAM,CAClB,MAAM,EACN,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAAC,CAAC,CAAC;QAC5E,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC;QAClD,OAAO,EAAE,IAAI,EAAE,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC;IACpC,CAAC,CAAC,CACF,CAAC;AACH,CAAC;AAED,SAAS,SAAS,CAAC,MAAc;IAChC,OAAO,CAAC,KAAY,EAAE,cAAsB,EAAE,EAAE,CAC/C,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC;YACzC,KAAK,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,IAAI,WAAW,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC3E,GAAG,EAAE,CAAC,MAAM,EAAE,EAAE;gBACf,MAAM,OAAO,GAAG,EAAE,cAAc,EAAE,MAAM,EAAE,CAAC;gBAC3C,OAAO,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;YAC3C,CAAC;SACD,CAAC,CAAC;QACH,IAAI,QAAQ,CAAC,KAAK,KAAK,IAAI,EAAE,CAAC;YAC7B,OAAO;QACR,CAAC;QACD,IAAI,QAAQ,CAAC,KAAK,CAAC,UAAU,KAAK,GAAG,EAAE,CAAC;YACvC,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC;YAChC,OAAO,KAAK,CAAC,CAAC,IAAI,OAAO,CAAC;gBACzB,EAAE,EAAE,eAAe,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,aAAa,CAAC,EAAE,GAAG,CAAC,IAAI,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;gBAChG,MAAM,EAAE,mBAAmB;aAC3B,CAAC,CAAC;QACJ,CAAC;QACD,OAAO,KAAK,CAAC,CAAC,IAAI,WAAW,CAAC;YAC7B,QAAQ,EAAE,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;YACvC,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,KAAK,CAAC,UAAU,EAAE,CAAC;SACpF,CAAC,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,20 @@
1
+ import { Effect, type Schema } from "effect";
2
+ import { type JobPayloadSchema } from "@shivaedev/jobs/server/definition.ts";
3
+ import type { Email } from "#Email.ts";
4
+ type Render<TSchema extends JobPayloadSchema, E, R> = (message: Schema.Schema.Type<TSchema>) => Effect.Effect<Email, E, R>;
5
+ export declare function defineEmailQueue<const TSchema extends JobPayloadSchema, E, R>(schema: TSchema, render: Render<TSchema, E, R>): ReturnType<typeof makeEmailQueue<TSchema, E, R, "transactional-email">>;
6
+ export declare function defineEmailQueue<const TSchema extends JobPayloadSchema, E, R, const TName extends string>(schema: TSchema, render: Render<TSchema, E, R>, options: {
7
+ readonly name: TName;
8
+ }): ReturnType<typeof makeEmailQueue<TSchema, E, R, TName>>;
9
+ declare function makeEmailQueue<TSchema extends JobPayloadSchema, E, R, const TName extends string>(schema: TSchema, render: Render<TSchema, E, R>, name: TName): {
10
+ enqueue: (message: Schema.Schema.Type<TSchema>) => Effect.Effect<import("effect/Option").Option<string>, import("@shivaedev/jobs/server/error.ts").PgBossError | import("@shivaedev/jobs/server/error.ts").PgBossPayloadError, `@shivaedev/email/${TName}` | TSchema["EncodingServices"]>;
11
+ Jobs: import("@shivaedev/jobs/server/service.ts").PgBossDefinition<`@shivaedev/email/${TName}`>;
12
+ producerLayer: import("effect/Layer").Layer<`@shivaedev/email/${TName}`, import("@shivaedev/jobs/server/error.ts").PgBossError | import("@shivaedev/jobs/server/error.ts").PgBossQueueNamesError | import("@shivaedev/jobs/server/error.ts").PgBossQueueOptionsError, import("@shivaedev/jobs/server/PgBoss.ts").PgBoss>;
13
+ queue: import("@shivaedev/jobs/server/definition.ts").QueueDefinition<TName, TSchema> & {
14
+ readonly key?: never;
15
+ };
16
+ worker: import("@shivaedev/jobs/server/definition.ts").QueueWorker<TSchema, "@shivaedev/email/Mailer" | R>;
17
+ workerLayer: import("effect/Layer").Layer<`@shivaedev/email/${TName}`, import("@shivaedev/jobs/server/error.ts").PgBossError | import("@shivaedev/jobs/server/error.ts").PgBossQueueNamesError | import("@shivaedev/jobs/server/error.ts").PgBossQueueOptionsError, import("@shivaedev/jobs/server/PgBoss.ts").PgBoss | Exclude<"@shivaedev/email/Mailer", `@shivaedev/email/${TName}`> | Exclude<R, `@shivaedev/email/${TName}`> | Exclude<TSchema["DecodingServices"], `@shivaedev/email/${TName}`>>;
18
+ };
19
+ export {};
20
+ //# sourceMappingURL=defineEmailQueue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"defineEmailQueue.d.ts","sourceRoot":"","sources":["../../src/server/defineEmailQueue.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC7C,OAAO,EAAe,KAAK,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AAE1F,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AAGvC,KAAK,MAAM,CAAC,OAAO,SAAS,gBAAgB,EAAE,CAAC,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;AAE3H,wBAAgB,gBAAgB,CAAC,KAAK,CAAC,OAAO,SAAS,gBAAgB,EAAE,CAAC,EAAE,CAAC,EAC5E,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,GAC3B,UAAU,CAAC,OAAO,cAAc,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,qBAAqB,CAAC,CAAC,CAAC;AAC3E,wBAAgB,gBAAgB,CAAC,KAAK,CAAC,OAAO,SAAS,gBAAgB,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,KAAK,SAAS,MAAM,EACxG,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,EAC7B,OAAO,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAA;CAAE,GAC/B,UAAU,CAAC,OAAO,cAAc,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC;AAS3D,iBAAS,cAAc,CAAC,OAAO,SAAS,gBAAgB,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,KAAK,SAAS,MAAM,EACzF,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,EAC7B,IAAI,EAAE,KAAK;uBAgBS,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC;;;;;;;;EAO/C"}
@@ -0,0 +1,29 @@
1
+ import { Effect } from "effect";
2
+ import { defineQueue } from "@shivaedev/jobs/server/definition.ts";
3
+ import { makePgBoss } from "@shivaedev/jobs/server/service.ts";
4
+ import { Mailer } from "./Mailer.js";
5
+ export function defineEmailQueue(schema, render, options) {
6
+ return makeEmailQueue(schema, render, options?.name ?? "transactional-email");
7
+ }
8
+ function makeEmailQueue(schema, render, name) {
9
+ const queue = defineQueue({
10
+ name,
11
+ queue: { retryBackoff: true, retryDelay: 30, retryLimit: 3 },
12
+ schema,
13
+ });
14
+ const Jobs = makePgBoss(`@shivaedev/email/${name}`);
15
+ const worker = queue.handle((message, job) => Effect.gen(function* () {
16
+ const email = yield* render(message);
17
+ const mailer = yield* Mailer;
18
+ yield* mailer.send(email, job.id);
19
+ }));
20
+ return {
21
+ enqueue: (message) => Effect.flatMap(Jobs, (jobs) => jobs.enqueue(queue, message)),
22
+ Jobs,
23
+ producerLayer: Jobs.layer({ jobs: [queue] }),
24
+ queue,
25
+ worker,
26
+ workerLayer: Jobs.layer({ jobs: [worker] }),
27
+ };
28
+ }
29
+ //# sourceMappingURL=defineEmailQueue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"defineEmailQueue.js","sourceRoot":"","sources":["../../src/server/defineEmailQueue.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAe,MAAM,QAAQ,CAAC;AAC7C,OAAO,EAAE,WAAW,EAAyB,MAAM,sCAAsC,CAAC;AAC1F,OAAO,EAAE,UAAU,EAAE,MAAM,mCAAmC,CAAC;AAE/D,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAarC,MAAM,UAAU,gBAAgB,CAC/B,MAAe,EACf,MAA6B,EAC7B,OAAmC;IAEnC,OAAO,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,IAAI,qBAAqB,CAAC,CAAC;AAC/E,CAAC;AAED,SAAS,cAAc,CACtB,MAAe,EACf,MAA6B,EAC7B,IAAW;IAEX,MAAM,KAAK,GAAG,WAAW,CAAC;QACzB,IAAI;QACJ,KAAK,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,UAAU,EAAE,EAAE,EAAE,UAAU,EAAE,CAAC,EAAE;QAC5D,MAAM;KACN,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,UAAU,CAAC,oBAAoB,IAAI,EAAE,CAAC,CAAC;IACpD,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,GAAG,EAAE,EAAE,CAC5C,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QACrC,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC;QAC7B,KAAK,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;IACnC,CAAC,CAAC,CACF,CAAC;IACF,OAAO;QACN,OAAO,EAAE,CAAC,OAAoC,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;QAC/G,IAAI;QACJ,aAAa,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC;QAC5C,KAAK;QACL,MAAM;QACN,WAAW,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC;KAC3C,CAAC;AACH,CAAC"}
@@ -0,0 +1,23 @@
1
+ import { Effect } from "effect";
2
+ import { Email } from "#Email.ts";
3
+ export interface ReceivedEmail extends Email {
4
+ readonly idempotencyKey: string | undefined;
5
+ }
6
+ export declare const inbox: () => Effect.Effect<{
7
+ emails: () => Effect.Effect<ReceivedEmail[], never, never>;
8
+ endpoint: {
9
+ fault: (route: string, fault: import("@shivaedev/test-story/server/fakeHttpService.ts").FakeHttpFault, scope?: import("@shivaedev/test-story/server/fakeHttpService.ts").FakeHttpScope) => Effect.Effect<void, never, never>;
10
+ hold: (route: string, holdOptions?: import("@shivaedev/test-story/server/fakeHttpService.ts").FakeHttpHoldOptions) => Effect.Effect<import("@shivaedev/test-story/server/fakeHttpService.ts").FakeHttpHold, never, never>;
11
+ narrate: (sink: (line: string) => void) => void;
12
+ releaseOnArrival: (route: string, arrival: string, scope?: import("@shivaedev/test-story/server/fakeHttpService.ts").FakeHttpScope) => Effect.Effect<void, never, never>;
13
+ readonly requests: readonly import("@shivaedev/test-story/server/fakeHttpService.ts").FakeHttpRequestRecord[];
14
+ scriptFault: (route: string, fault: import("@shivaedev/test-story/server/fakeHttpService.ts").FakeHttpFault, scriptOptions?: import("@shivaedev/test-story/server/fakeHttpService.ts").FakeHttpScriptOptions) => Effect.Effect<import("@shivaedev/test-story/server/fakeHttpService.ts").FakeHttpScript, never, never>;
15
+ stall: (route: string, scope?: import("@shivaedev/test-story/server/fakeHttpService.ts").FakeHttpScope) => Effect.Effect<import("@shivaedev/test-story/server/fakeHttpService.ts").FakeHttpHold, never, never>;
16
+ untilAbandoned: (matches: (request: import("@shivaedev/test-story/server/fakeHttpService.ts").FakeHttpRequestRecord) => boolean, wait: import("@shivaedev/test-story/server/until.ts").UntilOptions) => Effect.Effect<import("@shivaedev/test-story/server/fakeHttpService.ts").FakeHttpRequestRecord, import("@shivaedev/test-story/server/until.ts").SignalNotReceived, never>;
17
+ url: string;
18
+ };
19
+ mailerLayer: import("effect/Layer").Layer<"@shivaedev/email/Mailer", import("effect/Config").ConfigError, never>;
20
+ nextEmail: Effect.Effect<ReceivedEmail, import("effect/Cause").TimeoutError, never>;
21
+ }, import("effect/Cause").UnknownError, import("effect/Scope").Scope>;
22
+ export declare function emailLink(email: Email): string;
23
+ //# sourceMappingURL=inbox.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inbox.d.ts","sourceRoot":"","sources":["../../../../src/server/testing/adapters/inbox.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAA2B,MAAM,QAAQ,CAAC;AAGzD,OAAO,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AAGlC,MAAM,WAAW,aAAc,SAAQ,KAAK;IAC3C,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;CAC5C;AAED,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;qEAyBhB,CAAC;AAEH,wBAAgB,SAAS,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAM9C"}
@@ -0,0 +1,39 @@
1
+ import { Effect, Queue, Redacted, Schema } from "effect";
2
+ import { fakeHttpService } from "@shivaedev/test-story/server/fakeHttpService.ts";
3
+ import { routeOutbound } from "@shivaedev/test-story/server/routeOutbound.ts";
4
+ import { Email } from "#Email.ts";
5
+ import { resendMailerLayer } from "#server/adapters/resendMailerLayer.ts";
6
+ export const inbox = Effect.fnUntraced(function* () {
7
+ const arrivals = yield* Queue.unbounded();
8
+ const emails = [];
9
+ const decode = Schema.decodeUnknownSync(Schema.fromJsonString(Email));
10
+ const endpoint = yield* fakeHttpService({
11
+ routes: {
12
+ send: {
13
+ method: "POST",
14
+ path: "/emails",
15
+ reply: (request) => {
16
+ const email = { ...decode(request.body.toString()), idempotencyKey: request.headers["idempotency-key"]?.toString() };
17
+ emails.push(email);
18
+ Queue.offerUnsafe(arrivals, email);
19
+ return { body: { id: `email-${emails.length}` } };
20
+ },
21
+ },
22
+ },
23
+ });
24
+ yield* routeOutbound({ "api.resend.com": endpoint.url });
25
+ return {
26
+ emails: () => Effect.sync(() => [...emails]),
27
+ endpoint,
28
+ mailerLayer: resendMailerLayer({ apiKey: Redacted.make("re_story") }),
29
+ nextEmail: Queue.take(arrivals).pipe(Effect.timeout("10 seconds")),
30
+ };
31
+ });
32
+ export function emailLink(email) {
33
+ const link = email.text.match(/https?:\/\/[^\s<>]+/u)?.[0];
34
+ if (link === undefined) {
35
+ throw new Error("The delivered email has no link in its text");
36
+ }
37
+ return link;
38
+ }
39
+ //# sourceMappingURL=inbox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inbox.js","sourceRoot":"","sources":["../../../../src/server/testing/adapters/inbox.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACzD,OAAO,EAAE,eAAe,EAAE,MAAM,iDAAiD,CAAC;AAClF,OAAO,EAAE,aAAa,EAAE,MAAM,+CAA+C,CAAC;AAC9E,OAAO,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AAClC,OAAO,EAAE,iBAAiB,EAAE,MAAM,uCAAuC,CAAC;AAM1E,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC;IAC/C,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,SAAS,EAAiB,CAAC;IACzD,MAAM,MAAM,GAAoB,EAAE,CAAC;IACnC,MAAM,MAAM,GAAG,MAAM,CAAC,iBAAiB,CAAC,MAAM,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;IACtE,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,eAAe,CAAC;QACvC,MAAM,EAAE;YACP,IAAI,EAAE;gBACL,MAAM,EAAE,MAAM;gBACd,IAAI,EAAE,SAAS;gBACf,KAAK,EAAE,CAAC,OAAO,EAAE,EAAE;oBAClB,MAAM,KAAK,GAAG,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,EAAE,cAAc,EAAE,OAAO,CAAC,OAAO,CAAC,iBAAiB,CAAC,EAAE,QAAQ,EAAE,EAAE,CAAC;oBACrH,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;oBACnB,KAAK,CAAC,WAAW,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;oBACnC,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,EAAE,EAAE,CAAC;gBACnD,CAAC;aACD;SACD;KACD,CAAC,CAAC;IACH,KAAK,CAAC,CAAC,aAAa,CAAC,EAAE,gBAAgB,EAAE,QAAQ,CAAC,GAAG,EAAE,CAAC,CAAC;IACzD,OAAO;QACN,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC;QAC5C,QAAQ;QACR,WAAW,EAAE,iBAAiB,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC;QACrE,SAAS,EAAE,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;KAClE,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,MAAM,UAAU,SAAS,CAAC,KAAY;IACrC,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,sBAAsB,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAC3D,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACxB,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;IAChE,CAAC;IACD,OAAO,IAAI,CAAC;AACb,CAAC"}
package/package.json CHANGED
@@ -1,11 +1,85 @@
1
1
  {
2
2
  "name": "@shivaedev/email",
3
- "version": "0.0.0",
4
- "description": "Placeholder for the ShivaeDev email package.",
5
- "license": "MIT",
3
+ "version": "0.1.1",
4
+ "description": "Transactional email with Resend, durable Effect jobs and story inboxes",
5
+ "keywords": [
6
+ "effect"
7
+ ],
8
+ "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/email#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/ShivaeDev/platform/issues"
11
+ },
6
12
  "repository": {
7
13
  "type": "git",
8
14
  "url": "git+https://github.com/ShivaeDev/platform.git",
9
15
  "directory": "packages/email"
16
+ },
17
+ "license": "MIT",
18
+ "sideEffects": false,
19
+ "type": "module",
20
+ "imports": {
21
+ "#test/*": "./src/test-support/*",
22
+ "#*.ts": {
23
+ "source": "./src/*.ts",
24
+ "types": "./dist/*.d.ts",
25
+ "default": "./dist/*.js"
26
+ }
27
+ },
28
+ "exports": {
29
+ "./*.ts": {
30
+ "source": "./src/*.ts",
31
+ "types": "./dist/*.d.ts",
32
+ "default": "./dist/*.js"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "!src/**/*.test.ts",
40
+ "!src/**/*.test.tsx",
41
+ "!src/**/*.spec.ts",
42
+ "!src/**/*.spec.tsx",
43
+ "README.md",
44
+ "!src/**/test-support",
45
+ "CHANGELOG.md"
46
+ ],
47
+ "dependencies": {
48
+ "@shivaedev/http": "0.4.0",
49
+ "@shivaedev/jobs": "0.29.2",
50
+ "@shivaedev/service": "0.6.1",
51
+ "resend": "6.34.1"
52
+ },
53
+ "devDependencies": {
54
+ "@effect/platform-node": "4.0.6",
55
+ "@shivaedev/quality": "0.55.0",
56
+ "@shivaedev/test-sql": "0.16.1",
57
+ "@shivaedev/test-story": "0.33.0",
58
+ "@types/node": "24.19.2",
59
+ "effect": "4.0.6",
60
+ "typescript": "7.0.2",
61
+ "vitest": "5.0.5"
62
+ },
63
+ "peerDependencies": {
64
+ "@shivaedev/test-story": "0.33.0",
65
+ "effect": "4.0.6"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "@shivaedev/test-story": {
69
+ "optional": true
70
+ }
71
+ },
72
+ "engines": {
73
+ "node": ">=24"
74
+ },
75
+ "publishConfig": {
76
+ "access": "public",
77
+ "provenance": true
78
+ },
79
+ "scripts": {
80
+ "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
81
+ "ready": "pnpm typecheck && pnpm test && pnpm build",
82
+ "test": "vitest run",
83
+ "typecheck": "tsc --noEmit"
10
84
  }
11
85
  }
package/src/Email.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { Schema } from "effect";
2
+
3
+ export const Email = Schema.Struct({
4
+ from: Schema.String,
5
+ html: Schema.String,
6
+ subject: Schema.String,
7
+ text: Schema.String,
8
+ to: Schema.String,
9
+ });
10
+ export type Email = typeof Email.Type;
@@ -0,0 +1,16 @@
1
+ import { Effect } from "effect";
2
+ import type { RetryAt } from "@shivaedev/jobs/server/definition.ts";
3
+ import { defineService } from "@shivaedev/service/defineService.ts";
4
+ import type { Email } from "#Email.ts";
5
+ import type { MailerError } from "./MailerError.ts";
6
+
7
+ export const Mailer = defineService({
8
+ id: "@shivaedev/email/Mailer",
9
+ initialize: Effect.void,
10
+ methods: () => ({
11
+ send: (email: Email, idempotencyKey: string): Effect.Effect<void, MailerError | RetryAt> => Effect.logInfo("Email", { ...email, idempotencyKey }),
12
+ }),
13
+ requires: [],
14
+ });
15
+
16
+ export const logMailerLayer = Mailer.layer;
@@ -0,0 +1,10 @@
1
+ import { Schema } from "effect";
2
+
3
+ export class MailerError extends Schema.TaggedError<MailerError>()("MailerError", {
4
+ original: Schema.Redacted(Schema.Unknown),
5
+ status: Schema.optionalKey(Schema.Number),
6
+ }) {
7
+ override get message() {
8
+ return "Resend could not deliver the email";
9
+ }
10
+ }
@@ -0,0 +1,45 @@
1
+ import { Config, DateTime, Effect, Layer, Redacted } from "effect";
2
+ import { Resend } from "resend";
3
+ import { parseRetryAfter } from "@shivaedev/http/parseRetryAfter.ts";
4
+ import { RetryAt } from "@shivaedev/jobs/server/definition.ts";
5
+ import type { Email } from "#Email.ts";
6
+ import { Mailer } from "#server/Mailer.ts";
7
+ import { MailerError } from "#server/MailerError.ts";
8
+
9
+ export function resendMailerLayer(options: { readonly apiKey?: Redacted.Redacted<string> } = {}) {
10
+ return Layer.effect(
11
+ Mailer,
12
+ Effect.gen(function* () {
13
+ const apiKey = options.apiKey ?? (yield* Config.Redacted("RESEND_API_KEY"));
14
+ const resend = new Resend(Redacted.value(apiKey));
15
+ return { send: sendEmail(resend) };
16
+ }),
17
+ );
18
+ }
19
+
20
+ function sendEmail(resend: Resend) {
21
+ return (email: Email, idempotencyKey: string) =>
22
+ Effect.gen(function* () {
23
+ const response = yield* Effect.tryPromise({
24
+ catch: (original) => new MailerError({ original: Redacted.make(original) }),
25
+ try: (signal) => {
26
+ const request = { idempotencyKey, signal };
27
+ return resend.emails.send(email, request);
28
+ },
29
+ });
30
+ if (response.error === null) {
31
+ return;
32
+ }
33
+ if (response.error.statusCode === 429) {
34
+ const now = yield* DateTime.now;
35
+ return yield* new RetryAt({
36
+ at: parseRetryAfter(response.headers?.["retry-after"], now) ?? DateTime.add(now, { seconds: 1 }),
37
+ reason: "Resend rate limit",
38
+ });
39
+ }
40
+ return yield* new MailerError({
41
+ original: Redacted.make(response.error),
42
+ ...(response.error.statusCode === null ? {} : { status: response.error.statusCode }),
43
+ });
44
+ });
45
+ }
@@ -0,0 +1,52 @@
1
+ import { Effect, type Schema } from "effect";
2
+ import { defineQueue, type JobPayloadSchema } from "@shivaedev/jobs/server/definition.ts";
3
+ import { makePgBoss } from "@shivaedev/jobs/server/service.ts";
4
+ import type { Email } from "#Email.ts";
5
+ import { Mailer } from "./Mailer.ts";
6
+
7
+ type Render<TSchema extends JobPayloadSchema, E, R> = (message: Schema.Schema.Type<TSchema>) => Effect.Effect<Email, E, R>;
8
+
9
+ export function defineEmailQueue<const TSchema extends JobPayloadSchema, E, R>(
10
+ schema: TSchema,
11
+ render: Render<TSchema, E, R>,
12
+ ): ReturnType<typeof makeEmailQueue<TSchema, E, R, "transactional-email">>;
13
+ export function defineEmailQueue<const TSchema extends JobPayloadSchema, E, R, const TName extends string>(
14
+ schema: TSchema,
15
+ render: Render<TSchema, E, R>,
16
+ options: { readonly name: TName },
17
+ ): ReturnType<typeof makeEmailQueue<TSchema, E, R, TName>>;
18
+ export function defineEmailQueue<TSchema extends JobPayloadSchema, E, R>(
19
+ schema: TSchema,
20
+ render: Render<TSchema, E, R>,
21
+ options?: { readonly name: string },
22
+ ): unknown {
23
+ return makeEmailQueue(schema, render, options?.name ?? "transactional-email");
24
+ }
25
+
26
+ function makeEmailQueue<TSchema extends JobPayloadSchema, E, R, const TName extends string>(
27
+ schema: TSchema,
28
+ render: Render<TSchema, E, R>,
29
+ name: TName,
30
+ ) {
31
+ const queue = defineQueue({
32
+ name,
33
+ queue: { retryBackoff: true, retryDelay: 30, retryLimit: 3 },
34
+ schema,
35
+ });
36
+ const Jobs = makePgBoss(`@shivaedev/email/${name}`);
37
+ const worker = queue.handle((message, job) =>
38
+ Effect.gen(function* () {
39
+ const email = yield* render(message);
40
+ const mailer = yield* Mailer;
41
+ yield* mailer.send(email, job.id);
42
+ }),
43
+ );
44
+ return {
45
+ enqueue: (message: Schema.Schema.Type<TSchema>) => Effect.flatMap(Jobs, (jobs) => jobs.enqueue(queue, message)),
46
+ Jobs,
47
+ producerLayer: Jobs.layer({ jobs: [queue] }),
48
+ queue,
49
+ worker,
50
+ workerLayer: Jobs.layer({ jobs: [worker] }),
51
+ };
52
+ }
@@ -0,0 +1,44 @@
1
+ import { Effect, Queue, Redacted, Schema } from "effect";
2
+ import { fakeHttpService } from "@shivaedev/test-story/server/fakeHttpService.ts";
3
+ import { routeOutbound } from "@shivaedev/test-story/server/routeOutbound.ts";
4
+ import { Email } from "#Email.ts";
5
+ import { resendMailerLayer } from "#server/adapters/resendMailerLayer.ts";
6
+
7
+ export interface ReceivedEmail extends Email {
8
+ readonly idempotencyKey: string | undefined;
9
+ }
10
+
11
+ export const inbox = Effect.fnUntraced(function* () {
12
+ const arrivals = yield* Queue.unbounded<ReceivedEmail>();
13
+ const emails: ReceivedEmail[] = [];
14
+ const decode = Schema.decodeUnknownSync(Schema.fromJsonString(Email));
15
+ const endpoint = yield* fakeHttpService({
16
+ routes: {
17
+ send: {
18
+ method: "POST",
19
+ path: "/emails",
20
+ reply: (request) => {
21
+ const email = { ...decode(request.body.toString()), idempotencyKey: request.headers["idempotency-key"]?.toString() };
22
+ emails.push(email);
23
+ Queue.offerUnsafe(arrivals, email);
24
+ return { body: { id: `email-${emails.length}` } };
25
+ },
26
+ },
27
+ },
28
+ });
29
+ yield* routeOutbound({ "api.resend.com": endpoint.url });
30
+ return {
31
+ emails: () => Effect.sync(() => [...emails]),
32
+ endpoint,
33
+ mailerLayer: resendMailerLayer({ apiKey: Redacted.make("re_story") }),
34
+ nextEmail: Queue.take(arrivals).pipe(Effect.timeout("10 seconds")),
35
+ };
36
+ });
37
+
38
+ export function emailLink(email: Email): string {
39
+ const link = email.text.match(/https?:\/\/[^\s<>]+/u)?.[0];
40
+ if (link === undefined) {
41
+ throw new Error("The delivered email has no link in its text");
42
+ }
43
+ return link;
44
+ }