cf-send-email 0.0.0-stage → 0.0.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.
- package/LICENSE +21 -0
- package/README.md +92 -2
- package/dist/index.d.mts +62 -0
- package/dist/index.mjs +78 -0
- package/package.json +62 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 binochoi
|
|
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,93 @@
|
|
|
1
|
-
#
|
|
1
|
+
# cf-send-email
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Send **transactional emails from Cloudflare Workers** with the `send_email` binding (Cloudflare Email Service / Email Sending), and render **email-client-safe HTML + plain-text** bodies with inline styles and table layout. Covers the common auth mails (password reset, OTP / verification code, email verification, magic link, invites) in a tiny, zero-dependency TypeScript package that works with plain Workers or Hono.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Sending mail from a Cloudflare Worker means passing a structured `{ to, from, subject, html, text }` object to the `send_email` binding. Email clients can't be trusted with `<style>` tags, external CSS, or modern layout, so the body has to be inline styles and simple tables. That "binding wrapper + HTML shell" skeleton is the same in every project, so it lives here.
|
|
8
|
+
|
|
9
|
+
- **`sendEmail`** builds the `From` address from env and sends through the binding. Failures are logged and re-thrown, so an auth flow never reports "email sent" when nothing was delivered.
|
|
10
|
+
- **`renderEmail`** turns a structured `EmailBody` (heading, paragraphs, code, button, footnote) into `{ subject, html, text }`. All values are HTML-escaped.
|
|
11
|
+
- No dependency on the types generated by `wrangler types`: the package defines the minimal binding interface itself.
|
|
12
|
+
|
|
13
|
+
Copy (subject, wording, language) stays in your own templates. This package only renders and sends.
|
|
14
|
+
|
|
15
|
+
## Use cases
|
|
16
|
+
|
|
17
|
+
- Password reset emails with a button and a fallback URL
|
|
18
|
+
- Sign-up / sign-in OTP and verification code emails (the code is shown in a large box)
|
|
19
|
+
- Email address verification, team invites, and system notifications
|
|
20
|
+
- Auth callbacks (e.g. better-auth) in a Cloudflare Workers + Hono API
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install cf-send-email
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
Add the binding to `wrangler.jsonc`:
|
|
31
|
+
|
|
32
|
+
```jsonc
|
|
33
|
+
{
|
|
34
|
+
"vars": {
|
|
35
|
+
"EMAIL_FROM": "no-reply@mail.example.com",
|
|
36
|
+
"EMAIL_FROM_NAME": "My App"
|
|
37
|
+
},
|
|
38
|
+
"send_email": [
|
|
39
|
+
{ "name": "EMAIL", "allowed_sender_addresses": ["no-reply@mail.example.com"] }
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Render and send:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { renderEmail, sendEmail, type EmailBody, type EmailEnv } from "cf-send-email";
|
|
48
|
+
|
|
49
|
+
const body: EmailBody = {
|
|
50
|
+
subject: "Reset your password",
|
|
51
|
+
heading: "Reset your password",
|
|
52
|
+
paragraphs: ["We received a request to reset your password."],
|
|
53
|
+
action: { label: "Reset", url },
|
|
54
|
+
actionFallbackNote: "If the button doesn't work, open this link:",
|
|
55
|
+
footnote: "If you didn't request this, ignore this email.",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// With brandName, a top label and a text signature are rendered (omitted otherwise).
|
|
59
|
+
const rendered = renderEmail(body, "My App");
|
|
60
|
+
|
|
61
|
+
// env must provide the { EMAIL, EMAIL_FROM, EMAIL_FROM_NAME } binding and vars.
|
|
62
|
+
await sendEmail(env, { to: "user@example.com", ...rendered });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
For an OTP email, pass `code` instead of (or along with) `action`:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
renderEmail({
|
|
69
|
+
subject: "Your verification code",
|
|
70
|
+
heading: "Verify your email",
|
|
71
|
+
paragraphs: ["Enter this code to finish signing up."],
|
|
72
|
+
code: "482913",
|
|
73
|
+
footnote: "This code expires in 10 minutes.",
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## API
|
|
78
|
+
|
|
79
|
+
| Symbol | Description |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `renderEmail(body, brandName?)` | `EmailBody` → `{ subject, html, text }`. `brandName` is optional |
|
|
82
|
+
| `sendEmail(env, { to, ...rendered })` | Sends through the `send_email` binding. Logs and re-throws on failure |
|
|
83
|
+
| `EmailBody` | Structured body (`subject` / `heading` / `paragraphs` / `code` / `action` / `actionFallbackNote` / `footnote`) |
|
|
84
|
+
| `EmailEnv` | `{ EMAIL, EMAIL_FROM, EMAIL_FROM_NAME }` |
|
|
85
|
+
| `EmailSender` / `EmailSendMessage` / `EmailAddress` / `RenderedEmail` | Binding and message types |
|
|
86
|
+
|
|
87
|
+
## Build
|
|
88
|
+
|
|
89
|
+
Built with `obuild`. `prepare` runs `obuild --stub`, which writes a stub `dist` that points at the source, so workspace consumers can use it without a build step. Run `pnpm build:dist` for a real build before publishing.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** From/To 에 쓰는 이메일 주소. Email Sending 바인딩은 from 에 { email, name } 을 받는다. */
|
|
2
|
+
export interface EmailAddress {
|
|
3
|
+
email: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
}
|
|
6
|
+
/** Email Sending 바인딩 `.send()` 가 받는 구조화 메시지. */
|
|
7
|
+
export interface EmailSendMessage {
|
|
8
|
+
to: string;
|
|
9
|
+
from: EmailAddress;
|
|
10
|
+
subject: string;
|
|
11
|
+
html: string;
|
|
12
|
+
text: string;
|
|
13
|
+
}
|
|
14
|
+
/** `send_email` 바인딩 런타임 타입(필요한 부분만). */
|
|
15
|
+
export interface EmailSender {
|
|
16
|
+
send(message: EmailSendMessage): Promise<unknown>;
|
|
17
|
+
}
|
|
18
|
+
/** sendEmail 이 발신 정보를 조립하는 데 필요한 env 조각. */
|
|
19
|
+
export interface EmailEnv {
|
|
20
|
+
EMAIL: EmailSender;
|
|
21
|
+
/** 발신 주소(예: no-reply@mail.example.com). allowed_sender_addresses 와 일치해야 한다. */
|
|
22
|
+
EMAIL_FROM: string;
|
|
23
|
+
/** 발신 표시명. */
|
|
24
|
+
EMAIL_FROM_NAME: string;
|
|
25
|
+
}
|
|
26
|
+
/** 렌더된 이메일 한 통(수신자 제외). 템플릿 함수들이 반환한다. */
|
|
27
|
+
export interface RenderedEmail {
|
|
28
|
+
subject: string;
|
|
29
|
+
html: string;
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 트랜잭션 메일 한 통을 보낸다. From 은 env 에서 조립한다.
|
|
34
|
+
* 실패는 로깅 후 다시 던진다 — 인증 콜백(비밀번호 재설정 등)이 조용히 성공한 것처럼
|
|
35
|
+
* 보이지 않도록(사용자는 메일을 못 받는데 UI 는 "메일 보냈어요" 를 띄우는 상황 방지).
|
|
36
|
+
*/
|
|
37
|
+
export declare function sendEmail(env: EmailEnv, message: {
|
|
38
|
+
to: string;
|
|
39
|
+
} & RenderedEmail): Promise<void>;
|
|
40
|
+
/** 한 통의 본문 구조. 호출자가 언어별 문자열을 채워 만든다. */
|
|
41
|
+
export interface EmailBody {
|
|
42
|
+
/** 브라우저 제목이자 메일 제목(subject). */
|
|
43
|
+
subject: string;
|
|
44
|
+
/** 본문 상단 큰 제목 줄. */
|
|
45
|
+
heading: string;
|
|
46
|
+
/** 버튼 위 문단들. */
|
|
47
|
+
paragraphs: string[];
|
|
48
|
+
/** 크게 보여줄 인증 코드(있으면). 문단 아래, 버튼 위에 박스로 렌더한다. */
|
|
49
|
+
code?: string;
|
|
50
|
+
/** 주요 행동 버튼(있으면). */
|
|
51
|
+
action?: {
|
|
52
|
+
label: string;
|
|
53
|
+
url: string;
|
|
54
|
+
};
|
|
55
|
+
/** 버튼이 안 눌릴 때 붙이는 안내 문구(뒤에 URL 을 그대로 덧붙인다). */
|
|
56
|
+
actionFallbackNote?: string;
|
|
57
|
+
/** 맨 아래 회색 안내(예: 본인이 요청하지 않았다면 무시하세요). */
|
|
58
|
+
footnote: string;
|
|
59
|
+
}
|
|
60
|
+
/** EmailBody → 발송 가능한 { subject, html, text }. `brandName` 을 주면 상단 라벨과
|
|
61
|
+
* 텍스트 서명("— brand")으로 렌더하고, 없으면 브랜드 표기를 생략한다. */
|
|
62
|
+
export declare function renderEmail(body: EmailBody, brandName?: string): RenderedEmail;
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
async function sendEmail(env, message) {
|
|
2
|
+
try {
|
|
3
|
+
await env.EMAIL.send({
|
|
4
|
+
to: message.to,
|
|
5
|
+
from: {
|
|
6
|
+
email: env.EMAIL_FROM,
|
|
7
|
+
name: env.EMAIL_FROM_NAME
|
|
8
|
+
},
|
|
9
|
+
subject: message.subject,
|
|
10
|
+
html: message.html,
|
|
11
|
+
text: message.text
|
|
12
|
+
});
|
|
13
|
+
} catch (error) {
|
|
14
|
+
console.error("[email] send failed", error);
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const escapeHtml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
19
|
+
const escapeAttr = (value) => value.replace(/&/g, "&").replace(/"/g, """);
|
|
20
|
+
function renderHtml(body, brandName) {
|
|
21
|
+
const paragraphsHtml = body.paragraphs.map((line) => `<p style="margin:0 0 16px;font-size:14px;line-height:1.6;color:#1f2937;">${escapeHtml(line)}</p>`).join("");
|
|
22
|
+
const brandHtml = brandName ? `<p style="margin:0 0 24px;font-size:16px;font-weight:700;color:#111827;">${escapeHtml(brandName)}</p>` : "";
|
|
23
|
+
const codeHtml = body.code ? `<p style="margin:24px 0;padding:16px;border-radius:8px;background:#f3f4f6;text-align:center;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;font-size:28px;font-weight:700;letter-spacing:8px;color:#111827;">${escapeHtml(body.code)}</p>` : "";
|
|
24
|
+
const actionHtml = body.action ? `<p style="margin:24px 0;">
|
|
25
|
+
<a href="${escapeAttr(body.action.url)}"
|
|
26
|
+
style="display:inline-block;padding:10px 20px;border-radius:8px;background:#111827;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;">
|
|
27
|
+
${escapeHtml(body.action.label)}
|
|
28
|
+
</a>
|
|
29
|
+
</p>` : "";
|
|
30
|
+
const fallbackHtml = body.actionFallbackNote && body.action ? `<p style="margin:0 0 16px;font-size:12px;line-height:1.6;color:#6b7280;word-break:break-all;">${escapeHtml(body.actionFallbackNote)}<br/><a href="${escapeAttr(body.action.url)}" style="color:#2563eb;">${escapeHtml(body.action.url)}</a></p>` : "";
|
|
31
|
+
return `<!doctype html>
|
|
32
|
+
<html>
|
|
33
|
+
<body style="margin:0;padding:0;background:#f3f4f6;">
|
|
34
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:24px 0;">
|
|
35
|
+
<tr>
|
|
36
|
+
<td align="center">
|
|
37
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:440px;background:#ffffff;border-radius:12px;padding:32px;">
|
|
38
|
+
<tr>
|
|
39
|
+
<td>
|
|
40
|
+
${brandHtml}
|
|
41
|
+
<h1 style="margin:0 0 16px;font-size:18px;font-weight:600;color:#111827;">${escapeHtml(body.heading)}</h1>
|
|
42
|
+
${paragraphsHtml}
|
|
43
|
+
${codeHtml}
|
|
44
|
+
${actionHtml}
|
|
45
|
+
${fallbackHtml}
|
|
46
|
+
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#9ca3af;">${escapeHtml(body.footnote)}</p>
|
|
47
|
+
</td>
|
|
48
|
+
</tr>
|
|
49
|
+
</table>
|
|
50
|
+
</td>
|
|
51
|
+
</tr>
|
|
52
|
+
</table>
|
|
53
|
+
</body>
|
|
54
|
+
</html>`;
|
|
55
|
+
}
|
|
56
|
+
function renderText(body, brandName) {
|
|
57
|
+
const actionLines = body.action ? ["", `${body.action.label}: ${body.action.url}`] : [];
|
|
58
|
+
const codeLines = body.code ? ["", body.code] : [];
|
|
59
|
+
const brandLines = brandName ? ["", `— ${brandName}`] : [];
|
|
60
|
+
return [
|
|
61
|
+
body.heading,
|
|
62
|
+
"",
|
|
63
|
+
...body.paragraphs,
|
|
64
|
+
...codeLines,
|
|
65
|
+
...actionLines,
|
|
66
|
+
"",
|
|
67
|
+
body.footnote,
|
|
68
|
+
...brandLines
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
function renderEmail(body, brandName) {
|
|
72
|
+
return {
|
|
73
|
+
subject: body.subject,
|
|
74
|
+
html: renderHtml(body, brandName),
|
|
75
|
+
text: renderText(body, brandName)
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export { renderEmail, sendEmail };
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cf-send-email",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Send transactional emails from Cloudflare Workers via the send_email binding (Cloudflare Email Service), with a zero-dependency renderer for email-client-safe inline-styled HTML and plain-text bodies (password reset, OTP, verification, invites).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "binochoi",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/binochoi/cf-send-email.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/binochoi/cf-send-email#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/binochoi/cf-send-email/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"cf-send-email",
|
|
18
|
+
"cloudflare",
|
|
19
|
+
"cloudflare-workers",
|
|
20
|
+
"cloudflare-email",
|
|
21
|
+
"cloudflare-email-service",
|
|
22
|
+
"email-sending",
|
|
23
|
+
"send-email",
|
|
24
|
+
"send_email",
|
|
25
|
+
"workers",
|
|
26
|
+
"wrangler",
|
|
27
|
+
"edge",
|
|
28
|
+
"serverless",
|
|
29
|
+
"email",
|
|
30
|
+
"transactional-email",
|
|
31
|
+
"html-email",
|
|
32
|
+
"email-template",
|
|
33
|
+
"inline-css",
|
|
34
|
+
"plain-text-email",
|
|
35
|
+
"password-reset",
|
|
36
|
+
"otp",
|
|
37
|
+
"verification-code",
|
|
38
|
+
"email-verification",
|
|
39
|
+
"magic-link",
|
|
40
|
+
"hono",
|
|
41
|
+
"typescript",
|
|
42
|
+
"zero-dependency"
|
|
43
|
+
],
|
|
44
|
+
"exports": {
|
|
45
|
+
".": {
|
|
46
|
+
"types": "./dist/index.d.mts",
|
|
47
|
+
"import": "./dist/index.mjs"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"dist"
|
|
52
|
+
],
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^22.10.0",
|
|
55
|
+
"obuild": "^0.4.38",
|
|
56
|
+
"oxlint": "^1.67.0",
|
|
57
|
+
"typescript": "^7.0.2"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build:dist": "obuild",
|
|
61
|
+
"release": "pnpm build:dist && pnpm publish --ignore-scripts",
|
|
62
|
+
"clean": "rm -rf dist node_modules .turbo",
|
|
63
|
+
"lint": "oxlint ./src && tsc --noEmit"
|
|
64
|
+
}
|
|
6
65
|
}
|