@porulle/adapter-resend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # @porulle/adapter-resend
2
+
3
+ Transactional email via [Resend](https://resend.com). Implements the `email.send` callback in `CommerceConfig`.
4
+
5
+ ## Usage
6
+
7
+ ```ts
8
+ import { defineConfig } from "@porulle/core";
9
+ import { resendEmailAdapter } from "@porulle/adapter-resend";
10
+
11
+ export default defineConfig({
12
+ email: {
13
+ send: resendEmailAdapter({
14
+ apiKey: process.env.RESEND_API_KEY!,
15
+ from: "Acme Store <orders@acme.com>",
16
+ // Optional: per-template subject + HTML overrides
17
+ subjects: {
18
+ "order-confirmation": (d) => `Your order ${d.orderNumber} is confirmed`,
19
+ },
20
+ templates: {
21
+ "order-confirmation": (d) => `<p>Thanks for ordering ${d.itemCount} items.</p>`,
22
+ },
23
+ // Optional: use Resend's server-side templates instead of local HTML
24
+ resendTemplateIds: {
25
+ "order-confirmation": "tmpl_abc123",
26
+ },
27
+ }),
28
+ },
29
+ // …
30
+ });
31
+ ```
32
+
33
+ ## What gets sent
34
+
35
+ The kernel emits emails via the `email.send` callback for:
36
+
37
+ - `email-verification` — Better Auth signup
38
+ - `password-reset` — Better Auth password reset
39
+ - `order-confirmation` — `orders.afterCreate`
40
+ - (plugins can add their own — see plugin docs)
41
+
42
+ Templates not in `subjects` / `templates` get a minimal fallback so nothing silently fails to render.
43
+
44
+ ## See also
45
+
46
+ - [Resend docs](https://resend.com/docs)
47
+ - `@porulle/adapter-ses` — AWS SES alternative
@@ -0,0 +1,47 @@
1
+ export interface ResendAdapterOptions {
2
+ /** Resend API key (starts with re_). */
3
+ apiKey: string;
4
+ /** Default sender address (e.g., "Acme Store <orders@acme.com>"). */
5
+ from: string;
6
+ /**
7
+ * Maps template names to subject line generators.
8
+ * If a template is not in this map, the subject defaults to the template name.
9
+ */
10
+ subjects?: Record<string, (data: Record<string, unknown>) => string>;
11
+ /**
12
+ * Maps template names to HTML body generators.
13
+ * If a template is not in this map, a minimal default is used.
14
+ */
15
+ templates?: Record<string, (data: Record<string, unknown>) => string>;
16
+ /**
17
+ * Optional Resend template IDs. When provided, the adapter uses
18
+ * Resend's server-side template rendering instead of local HTML.
19
+ */
20
+ resendTemplateIds?: Record<string, string>;
21
+ }
22
+ /**
23
+ * Creates an email adapter backed by Resend.
24
+ *
25
+ * Implements the `config.email.send()` interface consumed by checkout hooks,
26
+ * auth (password reset, email verification), and appointment plugin notifications.
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * import { resendAdapter } from "@porulle/adapter-resend";
31
+ *
32
+ * export default defineConfig({
33
+ * email: resendAdapter({
34
+ * apiKey: process.env.RESEND_API_KEY!,
35
+ * from: "Acme Store <orders@acme.com>",
36
+ * }),
37
+ * });
38
+ * ```
39
+ */
40
+ export declare function resendAdapter(options: ResendAdapterOptions): {
41
+ send(input: {
42
+ template: string;
43
+ to: string;
44
+ data?: Record<string, unknown>;
45
+ }): Promise<void>;
46
+ };
47
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,oBAAoB;IACnC,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC,CAAC;IACrE;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC,CAAC;IACtE;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC5C;AAgCD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG;IAC5D,IAAI,CAAC,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9F,CAkDA"}
package/dist/index.js ADDED
@@ -0,0 +1,84 @@
1
+ import { Resend } from "resend";
2
+ const DEFAULT_SUBJECTS = {
3
+ "order-confirmation": (d) => `Order Confirmed${d.orderId ? ` - #${String(d.orderId).slice(0, 8)}` : ""}`,
4
+ "order-status-change": (d) => `Order Update - ${d.newStatus ?? "Status Changed"}`,
5
+ "password-reset": () => "Reset Your Password",
6
+ "email-verification": () => "Verify Your Email Address",
7
+ "appointment:reminder": (d) => `Appointment Reminder${d.reminderType === "1h" ? " - Starting Soon" : ""}`,
8
+ "appointment:confirmation-notice": () => "Appointment Confirmed",
9
+ "appointment:cancellation-notice": () => "Appointment Cancelled",
10
+ "appointment:no-show-notice": () => "Missed Appointment",
11
+ };
12
+ const DEFAULT_TEMPLATES = {
13
+ "order-confirmation": (d) => `<h2>Order Confirmed</h2><p>Thank you for your order${d.orderId ? ` <strong>#${String(d.orderId).slice(0, 8)}</strong>` : ""}.</p>${d.total ? `<p>Total: ${d.currency ?? "USD"} ${String(d.total)}</p>` : ""}`,
14
+ "order-status-change": (d) => `<h2>Order Update</h2><p>Your order${d.orderId ? ` <strong>#${String(d.orderId).slice(0, 8)}</strong>` : ""} status has been updated to <strong>${d.newStatus ?? "unknown"}</strong>.</p>`,
15
+ "password-reset": (d) => `<h2>Reset Your Password</h2><p>Click the link below to reset your password:</p><p><a href="${d.url ?? "#"}">Reset Password</a></p>`,
16
+ "email-verification": (d) => `<h2>Verify Your Email</h2><p>Click the link below to verify your email address:</p><p><a href="${d.url ?? "#"}">Verify Email</a></p>`,
17
+ "appointment:reminder": (d) => `<h2>Appointment Reminder</h2><p>This is a${d.reminderType === "1h" ? " 1-hour" : " 24-hour"} reminder for your upcoming appointment.</p><p>Booking ID: ${d.bookingId ?? "N/A"}</p>`,
18
+ "appointment:confirmation-notice": (d) => `<h2>Appointment Confirmed</h2><p>Your appointment has been confirmed.</p><p>Booking ID: ${d.bookingId ?? "N/A"}</p>`,
19
+ "appointment:cancellation-notice": (d) => `<h2>Appointment Cancelled</h2><p>Your appointment has been cancelled.</p><p>Booking ID: ${d.bookingId ?? "N/A"}</p>`,
20
+ "appointment:no-show-notice": (d) => `<h2>Missed Appointment</h2><p>You missed your appointment. Please contact us to rebook.</p><p>Booking ID: ${d.bookingId ?? "N/A"}</p>`,
21
+ };
22
+ /**
23
+ * Creates an email adapter backed by Resend.
24
+ *
25
+ * Implements the `config.email.send()` interface consumed by checkout hooks,
26
+ * auth (password reset, email verification), and appointment plugin notifications.
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * import { resendAdapter } from "@porulle/adapter-resend";
31
+ *
32
+ * export default defineConfig({
33
+ * email: resendAdapter({
34
+ * apiKey: process.env.RESEND_API_KEY!,
35
+ * from: "Acme Store <orders@acme.com>",
36
+ * }),
37
+ * });
38
+ * ```
39
+ */
40
+ export function resendAdapter(options) {
41
+ const resend = new Resend(options.apiKey);
42
+ const subjects = { ...DEFAULT_SUBJECTS, ...options.subjects };
43
+ const templates = { ...DEFAULT_TEMPLATES, ...options.templates };
44
+ return {
45
+ async send(input) {
46
+ const data = input.data ?? {};
47
+ const subjectFn = subjects[input.template];
48
+ const subject = subjectFn ? subjectFn(data) : input.template;
49
+ // If a Resend template ID is configured, use server-side template rendering
50
+ const resendTemplateId = options.resendTemplateIds?.[input.template];
51
+ if (resendTemplateId) {
52
+ // Resend's template field is not yet typed in the SDK (upstream type gap).
53
+ // Cast required because CreateEmailOptions doesn't include `template`.
54
+ const { error: templateError } = await resend.emails.send({
55
+ from: options.from,
56
+ to: [input.to],
57
+ subject,
58
+ template: {
59
+ id: resendTemplateId,
60
+ variables: data,
61
+ },
62
+ });
63
+ if (templateError) {
64
+ throw new Error(`Resend template email failed: ${templateError.message}`);
65
+ }
66
+ return;
67
+ }
68
+ // Otherwise, use local HTML template
69
+ const templateFn = templates[input.template];
70
+ const html = templateFn
71
+ ? templateFn(data)
72
+ : `<p>Notification: ${input.template}</p><pre>${JSON.stringify(data, null, 2)}</pre>`;
73
+ const { error } = await resend.emails.send({
74
+ from: options.from,
75
+ to: [input.to],
76
+ subject,
77
+ html,
78
+ });
79
+ if (error) {
80
+ throw new Error(`Resend email failed: ${error.message}`);
81
+ }
82
+ },
83
+ };
84
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@porulle/adapter-resend",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "bun": "./src/index.ts",
9
+ "import": "./dist/index.js",
10
+ "types": "./src/index.ts"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "build": "rm -rf dist tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json",
15
+ "check-types": "tsc --noEmit",
16
+ "lint": "eslint . --max-warnings 1000",
17
+ "test": "vitest run"
18
+ },
19
+ "dependencies": {
20
+ "resend": "^4.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@repo/eslint-config": "*",
24
+ "@repo/typescript-config": "*",
25
+ "@types/node": "^24.5.2",
26
+ "eslint": "^9.39.1",
27
+ "typescript": "5.9.2",
28
+ "vitest": "^3.2.4"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "files": [
34
+ "src",
35
+ "dist",
36
+ "README.md"
37
+ ],
38
+ "description": "Transactional email via Resend. Implements the email.send callback in CommerceConfig.",
39
+ "homepage": "https://porulle-docs.vercel.app",
40
+ "bugs": {
41
+ "url": "https://github.com/asyncdotengineering/porulle/issues"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/asyncdotengineering/porulle.git",
46
+ "directory": "packages/adapters/adapter-resend"
47
+ },
48
+ "author": "Porulle contributors"
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { Resend } from "resend";
2
+
3
+ export interface ResendAdapterOptions {
4
+ /** Resend API key (starts with re_). */
5
+ apiKey: string;
6
+ /** Default sender address (e.g., "Acme Store <orders@acme.com>"). */
7
+ from: string;
8
+ /**
9
+ * Maps template names to subject line generators.
10
+ * If a template is not in this map, the subject defaults to the template name.
11
+ */
12
+ subjects?: Record<string, (data: Record<string, unknown>) => string>;
13
+ /**
14
+ * Maps template names to HTML body generators.
15
+ * If a template is not in this map, a minimal default is used.
16
+ */
17
+ templates?: Record<string, (data: Record<string, unknown>) => string>;
18
+ /**
19
+ * Optional Resend template IDs. When provided, the adapter uses
20
+ * Resend's server-side template rendering instead of local HTML.
21
+ */
22
+ resendTemplateIds?: Record<string, string>;
23
+ }
24
+
25
+ const DEFAULT_SUBJECTS: Record<string, (data: Record<string, unknown>) => string> = {
26
+ "order-confirmation": (d) => `Order Confirmed${d.orderId ? ` - #${String(d.orderId).slice(0, 8)}` : ""}`,
27
+ "order-status-change": (d) => `Order Update - ${d.newStatus ?? "Status Changed"}`,
28
+ "password-reset": () => "Reset Your Password",
29
+ "email-verification": () => "Verify Your Email Address",
30
+ "appointment:reminder": (d) => `Appointment Reminder${d.reminderType === "1h" ? " - Starting Soon" : ""}`,
31
+ "appointment:confirmation-notice": () => "Appointment Confirmed",
32
+ "appointment:cancellation-notice": () => "Appointment Cancelled",
33
+ "appointment:no-show-notice": () => "Missed Appointment",
34
+ };
35
+
36
+ const DEFAULT_TEMPLATES: Record<string, (data: Record<string, unknown>) => string> = {
37
+ "order-confirmation": (d) =>
38
+ `<h2>Order Confirmed</h2><p>Thank you for your order${d.orderId ? ` <strong>#${String(d.orderId).slice(0, 8)}</strong>` : ""}.</p>${d.total ? `<p>Total: ${d.currency ?? "USD"} ${String(d.total)}</p>` : ""}`,
39
+ "order-status-change": (d) =>
40
+ `<h2>Order Update</h2><p>Your order${d.orderId ? ` <strong>#${String(d.orderId).slice(0, 8)}</strong>` : ""} status has been updated to <strong>${d.newStatus ?? "unknown"}</strong>.</p>`,
41
+ "password-reset": (d) =>
42
+ `<h2>Reset Your Password</h2><p>Click the link below to reset your password:</p><p><a href="${d.url ?? "#"}">Reset Password</a></p>`,
43
+ "email-verification": (d) =>
44
+ `<h2>Verify Your Email</h2><p>Click the link below to verify your email address:</p><p><a href="${d.url ?? "#"}">Verify Email</a></p>`,
45
+ "appointment:reminder": (d) =>
46
+ `<h2>Appointment Reminder</h2><p>This is a${d.reminderType === "1h" ? " 1-hour" : " 24-hour"} reminder for your upcoming appointment.</p><p>Booking ID: ${d.bookingId ?? "N/A"}</p>`,
47
+ "appointment:confirmation-notice": (d) =>
48
+ `<h2>Appointment Confirmed</h2><p>Your appointment has been confirmed.</p><p>Booking ID: ${d.bookingId ?? "N/A"}</p>`,
49
+ "appointment:cancellation-notice": (d) =>
50
+ `<h2>Appointment Cancelled</h2><p>Your appointment has been cancelled.</p><p>Booking ID: ${d.bookingId ?? "N/A"}</p>`,
51
+ "appointment:no-show-notice": (d) =>
52
+ `<h2>Missed Appointment</h2><p>You missed your appointment. Please contact us to rebook.</p><p>Booking ID: ${d.bookingId ?? "N/A"}</p>`,
53
+ };
54
+
55
+ /**
56
+ * Creates an email adapter backed by Resend.
57
+ *
58
+ * Implements the `config.email.send()` interface consumed by checkout hooks,
59
+ * auth (password reset, email verification), and appointment plugin notifications.
60
+ *
61
+ * @example
62
+ * ```typescript
63
+ * import { resendAdapter } from "@porulle/adapter-resend";
64
+ *
65
+ * export default defineConfig({
66
+ * email: resendAdapter({
67
+ * apiKey: process.env.RESEND_API_KEY!,
68
+ * from: "Acme Store <orders@acme.com>",
69
+ * }),
70
+ * });
71
+ * ```
72
+ */
73
+ export function resendAdapter(options: ResendAdapterOptions): {
74
+ send(input: { template: string; to: string; data?: Record<string, unknown> }): Promise<void>;
75
+ } {
76
+ const resend = new Resend(options.apiKey);
77
+
78
+ const subjects = { ...DEFAULT_SUBJECTS, ...options.subjects };
79
+ const templates = { ...DEFAULT_TEMPLATES, ...options.templates };
80
+
81
+ return {
82
+ async send(input) {
83
+ const data = input.data ?? {};
84
+ const subjectFn = subjects[input.template];
85
+ const subject = subjectFn ? subjectFn(data) : input.template;
86
+
87
+ // If a Resend template ID is configured, use server-side template rendering
88
+ const resendTemplateId = options.resendTemplateIds?.[input.template];
89
+ if (resendTemplateId) {
90
+ // Resend's template field is not yet typed in the SDK (upstream type gap).
91
+ // Cast required because CreateEmailOptions doesn't include `template`.
92
+ const { error: templateError } = await resend.emails.send({
93
+ from: options.from,
94
+ to: [input.to],
95
+ subject,
96
+ template: {
97
+ id: resendTemplateId,
98
+ variables: data as Record<string, string | number>,
99
+ },
100
+ } as unknown as Parameters<typeof resend.emails.send>[0]);
101
+ if (templateError) {
102
+ throw new Error(`Resend template email failed: ${templateError.message}`);
103
+ }
104
+ return;
105
+ }
106
+
107
+ // Otherwise, use local HTML template
108
+ const templateFn = templates[input.template];
109
+ const html = templateFn
110
+ ? templateFn(data)
111
+ : `<p>Notification: ${input.template}</p><pre>${JSON.stringify(data, null, 2)}</pre>`;
112
+
113
+ const { error } = await resend.emails.send({
114
+ from: options.from,
115
+ to: [input.to],
116
+ subject,
117
+ html,
118
+ });
119
+
120
+ if (error) {
121
+ throw new Error(`Resend email failed: ${error.message}`);
122
+ }
123
+ },
124
+ };
125
+ }