@decocms/apps-resend 7.2.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/package.json +44 -0
- package/src/__tests__/send.test.ts +101 -0
- package/src/actions/send.ts +57 -0
- package/src/client.ts +39 -0
- package/src/index.ts +10 -0
- package/src/mod.ts +67 -0
- package/src/registry.ts +9 -0
- package/src/types.ts +54 -0
- package/tsconfig.json +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@decocms/apps-resend",
|
|
3
|
+
"version": "7.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deco app: Resend transactional email integration",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/decocms/blocks.git",
|
|
9
|
+
"directory": "packages/apps-resend"
|
|
10
|
+
},
|
|
11
|
+
"main": "./src/index.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.ts",
|
|
14
|
+
"./mod": "./src/mod.ts",
|
|
15
|
+
"./client": "./src/client.ts",
|
|
16
|
+
"./types": "./src/types.ts",
|
|
17
|
+
"./actions/send": "./src/actions/send.ts",
|
|
18
|
+
"./registry": "./src/registry.ts"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"test": "vitest run --root ../.. packages/apps-resend/",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"lint:unused": "knip"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@decocms/blocks": "7.2.0",
|
|
28
|
+
"@decocms/apps-commerce": "7.2.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"react": "^19.0.0",
|
|
32
|
+
"react-dom": "^19.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/react": "^19.0.0",
|
|
36
|
+
"@types/react-dom": "^19.0.0",
|
|
37
|
+
"knip": "^5.86.0",
|
|
38
|
+
"typescript": "^5.9.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"registry": "https://registry.npmjs.org",
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { sendEmail } from "../actions/send";
|
|
3
|
+
import { configureResend } from "../client";
|
|
4
|
+
|
|
5
|
+
describe("sendEmail", () => {
|
|
6
|
+
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
fetchSpy = vi.fn();
|
|
10
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
11
|
+
configureResend({
|
|
12
|
+
apiKey: "re_test_123",
|
|
13
|
+
emailFrom: "Test <test@example.com>",
|
|
14
|
+
emailTo: ["default@example.com"],
|
|
15
|
+
subject: "Default Subject",
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.restoreAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("sends email with provided fields", async () => {
|
|
24
|
+
fetchSpy.mockResolvedValueOnce({
|
|
25
|
+
ok: true,
|
|
26
|
+
json: () => Promise.resolve({ id: "email_abc123" }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const result = await sendEmail({
|
|
30
|
+
to: "user@example.com",
|
|
31
|
+
subject: "Hello",
|
|
32
|
+
html: "<p>World</p>",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(result.data).toEqual({ id: "email_abc123" });
|
|
36
|
+
expect(result.error).toBeNull();
|
|
37
|
+
|
|
38
|
+
expect(fetchSpy).toHaveBeenCalledWith("https://api.resend.com/emails", {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
Authorization: "Bearer re_test_123",
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
from: "Test <test@example.com>",
|
|
46
|
+
to: "user@example.com",
|
|
47
|
+
subject: "Hello",
|
|
48
|
+
html: "<p>World</p>",
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("falls back to defaults when fields are omitted", async () => {
|
|
54
|
+
fetchSpy.mockResolvedValueOnce({
|
|
55
|
+
ok: true,
|
|
56
|
+
json: () => Promise.resolve({ id: "email_def456" }),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await sendEmail({
|
|
60
|
+
html: "<p>Contact form</p>",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
|
|
64
|
+
expect(body.from).toBe("Test <test@example.com>");
|
|
65
|
+
expect(body.to).toEqual(["default@example.com"]);
|
|
66
|
+
expect(body.subject).toBe("Default Subject");
|
|
67
|
+
expect(body.html).toBe("<p>Contact form</p>");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns error on API failure", async () => {
|
|
71
|
+
fetchSpy.mockResolvedValueOnce({
|
|
72
|
+
ok: false,
|
|
73
|
+
json: () =>
|
|
74
|
+
Promise.resolve({
|
|
75
|
+
message: "Invalid API key",
|
|
76
|
+
name: "invalid_api_Key",
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const result = await sendEmail({
|
|
81
|
+
to: "user@example.com",
|
|
82
|
+
subject: "Test",
|
|
83
|
+
html: "<p>Test</p>",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result.data).toBeNull();
|
|
87
|
+
expect(result.error).toEqual({
|
|
88
|
+
message: "Invalid API key",
|
|
89
|
+
name: "invalid_api_Key",
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("throws when configureResend was not called", async () => {
|
|
94
|
+
// Reset the config by importing a fresh module
|
|
95
|
+
// We can't easily reset the singleton, so we test the error path
|
|
96
|
+
// by calling getResendConfig directly
|
|
97
|
+
const { getResendConfig } = await import("../client");
|
|
98
|
+
// Config was set in beforeEach, so this should work
|
|
99
|
+
expect(() => getResendConfig()).not.toThrow();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { getResendConfig } from "../client";
|
|
2
|
+
import type { CreateEmailOptions, CreateEmailResponse } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Send an email via Resend API.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { sendEmail } from "@decocms/apps/resend/actions/send";
|
|
9
|
+
*
|
|
10
|
+
* const result = await sendEmail({
|
|
11
|
+
* subject: "Hello",
|
|
12
|
+
* html: "<p>World</p>",
|
|
13
|
+
* });
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Fields not provided fall back to the defaults set in `configureResend()`.
|
|
17
|
+
*/
|
|
18
|
+
export async function sendEmail(
|
|
19
|
+
payload: Partial<CreateEmailOptions> & { subject?: string; html?: string },
|
|
20
|
+
): Promise<CreateEmailResponse> {
|
|
21
|
+
const config = getResendConfig();
|
|
22
|
+
|
|
23
|
+
const body: CreateEmailOptions = {
|
|
24
|
+
from: payload.from ?? config.emailFrom ?? "Contact <onboarding@resend.dev>",
|
|
25
|
+
to: payload.to ?? config.emailTo ?? [],
|
|
26
|
+
subject: payload.subject ?? config.subject ?? "No subject",
|
|
27
|
+
...(payload.bcc && { bcc: payload.bcc }),
|
|
28
|
+
...(payload.cc && { cc: payload.cc }),
|
|
29
|
+
...(payload.reply_to && { reply_to: payload.reply_to }),
|
|
30
|
+
...(payload.html && { html: payload.html }),
|
|
31
|
+
...(payload.text && { text: payload.text }),
|
|
32
|
+
...(payload.headers && { headers: payload.headers }),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: {
|
|
38
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
},
|
|
41
|
+
body: JSON.stringify(body),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
return {
|
|
48
|
+
data: null,
|
|
49
|
+
error: data,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
data,
|
|
55
|
+
error: null,
|
|
56
|
+
};
|
|
57
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ResendConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
let _config: ResendConfig | null = null;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configure the Resend client. Call once in your site's setup.ts.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { configureResend } from "@decocms/apps/resend/client";
|
|
10
|
+
*
|
|
11
|
+
* configureResend({
|
|
12
|
+
* apiKey: process.env.RESEND_API_KEY!,
|
|
13
|
+
* emailFrom: "Contact <hello@example.com>",
|
|
14
|
+
* emailTo: ["team@example.com"],
|
|
15
|
+
* subject: "Contact form submission",
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* TODO(secrets-decrypt): Add an `initResendFromBlocks(blocks, blockKey?)`
|
|
20
|
+
* helper that mirrors magento/algolia/vtex. The Deco CMS Resend block
|
|
21
|
+
* stores `apiKey` as an encrypted Secret reference (`{ encrypted, name }`)
|
|
22
|
+
* — sites currently have to call `configureResend()` with a manually
|
|
23
|
+
* resolved env var, missing the AES-CBC decrypt path via
|
|
24
|
+
* `@decocms/start/sdk/crypto#resolveSecret`. Until that ships, sites
|
|
25
|
+
* keep passing a string they obtain from `process.env` or a custom
|
|
26
|
+
* resolver.
|
|
27
|
+
*/
|
|
28
|
+
export function configureResend(config: ResendConfig) {
|
|
29
|
+
_config = config;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getResendConfig(): ResendConfig {
|
|
33
|
+
if (!_config) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
"Resend not configured. Call configureResend() in setup.ts before using Resend actions.",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return _config;
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { sendEmail } from "./actions/send";
|
|
2
|
+
export { configureResend, getResendConfig } from "./client";
|
|
3
|
+
export type {
|
|
4
|
+
CreateEmailOptions,
|
|
5
|
+
CreateEmailResponse,
|
|
6
|
+
CreateEmailResponseSuccess,
|
|
7
|
+
ErrorResponse,
|
|
8
|
+
ResendConfig,
|
|
9
|
+
ResendErrorCodeKey,
|
|
10
|
+
} from "./types";
|
package/src/mod.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resend app module — standard autoconfig contract.
|
|
3
|
+
*
|
|
4
|
+
* Exports `configure` and `handlers` following the AppModContract pattern.
|
|
5
|
+
* The framework's `autoconfigApps()` calls these generically — no hardcoded
|
|
6
|
+
* app knowledge needed in the framework.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AppDefinition, AppHandler, ResolveSecretFn } from "@decocms/apps-commerce/app-types";
|
|
10
|
+
import { sendEmail } from "./actions/send";
|
|
11
|
+
import { configureResend } from "./client";
|
|
12
|
+
import manifest from "./manifest.gen";
|
|
13
|
+
import type { ResendConfig } from "./types";
|
|
14
|
+
|
|
15
|
+
// -------------------------------------------------------------------------
|
|
16
|
+
// State
|
|
17
|
+
// -------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface ResendState {
|
|
20
|
+
config: ResendConfig;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// -------------------------------------------------------------------------
|
|
24
|
+
// Configure
|
|
25
|
+
// -------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Configure Resend from CMS block data.
|
|
29
|
+
* Returns an AppDefinition or null if missing credentials.
|
|
30
|
+
*/
|
|
31
|
+
export async function configure(
|
|
32
|
+
block: any,
|
|
33
|
+
resolveSecret: ResolveSecretFn,
|
|
34
|
+
): Promise<AppDefinition<ResendState> | null> {
|
|
35
|
+
const apiKey = await resolveSecret(block.apiKey, "RESEND_API_KEY");
|
|
36
|
+
if (!apiKey) return null;
|
|
37
|
+
|
|
38
|
+
const config: ResendConfig = {
|
|
39
|
+
apiKey,
|
|
40
|
+
emailFrom: block.emailFrom
|
|
41
|
+
? `${block.emailFrom.name || "Contact"} <${block.emailFrom.domain || "onboarding@resend.dev"}>`
|
|
42
|
+
: undefined,
|
|
43
|
+
emailTo: block.emailTo,
|
|
44
|
+
subject: block.subject,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Bridge: maintain global singleton for backward compat
|
|
48
|
+
configureResend(config);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
name: "resend",
|
|
52
|
+
manifest,
|
|
53
|
+
state: { config },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Invoke handlers registered under /deco/invoke/{key}.
|
|
59
|
+
* Both with and without .ts suffix for compatibility.
|
|
60
|
+
*/
|
|
61
|
+
export const handlers: Record<string, AppHandler> = {
|
|
62
|
+
"resend/actions/emails/send": (props) => sendEmail(props),
|
|
63
|
+
"resend/actions/emails/send.ts": (props) => sendEmail(props),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Placeholder preview for CMS editor — evolves when admin supports it. */
|
|
67
|
+
export const preview = undefined;
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AppRegistryEntry } from "@decocms/apps-commerce/registry";
|
|
2
|
+
|
|
3
|
+
export const RESEND_REGISTRY_ENTRY: AppRegistryEntry = {
|
|
4
|
+
blockKey: "deco-resend",
|
|
5
|
+
module: () => import("./mod"),
|
|
6
|
+
displayName: "Resend",
|
|
7
|
+
category: "email",
|
|
8
|
+
description: "Transactional email via Resend",
|
|
9
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const RESEND_ERROR_CODES_BY_KEY = {
|
|
2
|
+
missing_required_field: 422,
|
|
3
|
+
invalid_access: 422,
|
|
4
|
+
invalid_parameter: 422,
|
|
5
|
+
invalid_region: 422,
|
|
6
|
+
rate_limit_exceeded: 429,
|
|
7
|
+
missing_api_key: 401,
|
|
8
|
+
invalid_api_Key: 403,
|
|
9
|
+
invalid_from_address: 403,
|
|
10
|
+
validation_error: 403,
|
|
11
|
+
not_found: 404,
|
|
12
|
+
method_not_allowed: 405,
|
|
13
|
+
application_error: 500,
|
|
14
|
+
internal_server_error: 500,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export type ResendErrorCodeKey = keyof typeof RESEND_ERROR_CODES_BY_KEY;
|
|
18
|
+
|
|
19
|
+
export interface ErrorResponse {
|
|
20
|
+
message: string;
|
|
21
|
+
name: ResendErrorCodeKey;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CreateEmailResponseSuccess {
|
|
25
|
+
/** The ID of the newly created email. */
|
|
26
|
+
id: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CreateEmailResponse {
|
|
30
|
+
data: CreateEmailResponseSuccess | null;
|
|
31
|
+
error: ErrorResponse | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CreateEmailOptions {
|
|
35
|
+
from?: string;
|
|
36
|
+
to: string | string[];
|
|
37
|
+
subject: string;
|
|
38
|
+
bcc?: string | string[];
|
|
39
|
+
cc?: string | string[];
|
|
40
|
+
reply_to?: string | string[];
|
|
41
|
+
html?: string;
|
|
42
|
+
text?: string;
|
|
43
|
+
headers?: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ResendConfig {
|
|
47
|
+
apiKey: string;
|
|
48
|
+
/** Default sender — e.g. "Contact <onboarding@resend.dev>" */
|
|
49
|
+
emailFrom?: string;
|
|
50
|
+
/** Default recipients */
|
|
51
|
+
emailTo?: string[];
|
|
52
|
+
/** Default subject */
|
|
53
|
+
subject?: string;
|
|
54
|
+
}
|