better-auth-cloudflare-email 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 +21 -0
- package/README.md +244 -0
- package/dist/index.cjs +228 -0
- package/dist/index.d.cts +223 -0
- package/dist/index.d.ts +223 -0
- package/dist/index.js +201 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Stenhouse
|
|
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
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# better-auth-cloudflare-email-plugin
|
|
2
|
+
|
|
3
|
+
Send emails through [Cloudflare Email Service](https://developers.cloudflare.com/email-service/) from [Better Auth](https://better-auth.com) — with zero configuration for each callback.
|
|
4
|
+
|
|
5
|
+
Two transports, one interface:
|
|
6
|
+
|
|
7
|
+
- **`cloudflareEmail.workers()`** — inside a Cloudflare Worker, uses the `send_email` binding directly (no API key, zero network hop)
|
|
8
|
+
- **`cloudflareEmail.api()`** — from any runtime (Node.js, Bun, Deno, Vercel, etc.), uses the Cloudflare REST API
|
|
9
|
+
|
|
10
|
+
Both wire into all 6 Better Auth email callbacks automatically and ship with clean, responsive HTML templates out of the box.
|
|
11
|
+
|
|
12
|
+
## Callbacks handled
|
|
13
|
+
|
|
14
|
+
| Callback | Source | Triggered by |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `sendVerificationEmail` | Core config | Sign-up, manual verify |
|
|
17
|
+
| `sendResetPassword` | Core config | Password reset request |
|
|
18
|
+
| `sendChangeEmailConfirmation` | Core config | Email change request |
|
|
19
|
+
| `sendDeleteAccountVerification` | Core config | Account deletion request |
|
|
20
|
+
| `sendMagicLink` | `magicLink()` plugin | Magic link sign-in |
|
|
21
|
+
| `sendVerificationOTP` | `emailOTP()` plugin | OTP sign-in / verify / reset |
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install better-auth-cloudflare-email
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
### Cloudflare Workers (binding transport)
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { betterAuth } from "better-auth";
|
|
35
|
+
import { magicLink, emailOTP } from "better-auth/plugins";
|
|
36
|
+
import { cloudflareEmail } from "better-auth-cloudflare-email";
|
|
37
|
+
|
|
38
|
+
function createAuth(env: Env) {
|
|
39
|
+
const email = cloudflareEmail.workers({
|
|
40
|
+
binding: env.EMAIL,
|
|
41
|
+
from: "MyApp <noreply@myapp.com>",
|
|
42
|
+
appName: "MyApp",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return betterAuth({
|
|
46
|
+
...email.config,
|
|
47
|
+
plugins: [
|
|
48
|
+
magicLink({ sendMagicLink: email.sendMagicLink }),
|
|
49
|
+
emailOTP({ sendVerificationOTP: email.sendVerificationOTP }),
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Add the binding to your `wrangler.jsonc`:
|
|
56
|
+
|
|
57
|
+
```jsonc
|
|
58
|
+
{
|
|
59
|
+
"send_email": [{ "name": "EMAIL" }]
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Any runtime (REST API transport)
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { betterAuth } from "better-auth";
|
|
67
|
+
import { magicLink, emailOTP } from "better-auth/plugins";
|
|
68
|
+
import { cloudflareEmail } from "better-auth-cloudflare-email";
|
|
69
|
+
|
|
70
|
+
const email = cloudflareEmail.api({
|
|
71
|
+
accountId: process.env.CF_ACCOUNT_ID!,
|
|
72
|
+
apiToken: process.env.CF_API_TOKEN!,
|
|
73
|
+
from: "MyApp <noreply@myapp.com>",
|
|
74
|
+
appName: "MyApp",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const auth = betterAuth({
|
|
78
|
+
...email.config,
|
|
79
|
+
plugins: [
|
|
80
|
+
magicLink({ sendMagicLink: email.sendMagicLink }),
|
|
81
|
+
emailOTP({ sendVerificationOTP: email.sendVerificationOTP }),
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## How it works
|
|
87
|
+
|
|
88
|
+
`cloudflareEmail.workers()` and `cloudflareEmail.api()` both return the same object:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
{
|
|
92
|
+
// Spread into betterAuth() — wires verification, reset, change email, delete account
|
|
93
|
+
config: { emailVerification, emailAndPassword, user },
|
|
94
|
+
|
|
95
|
+
// Wire into plugins manually
|
|
96
|
+
sendMagicLink,
|
|
97
|
+
sendVerificationOTP,
|
|
98
|
+
|
|
99
|
+
// Individual callbacks (if you prefer manual wiring)
|
|
100
|
+
sendVerificationEmail,
|
|
101
|
+
sendResetPassword,
|
|
102
|
+
sendChangeEmailConfirmation,
|
|
103
|
+
sendDeleteAccountVerification,
|
|
104
|
+
|
|
105
|
+
// Send any arbitrary email
|
|
106
|
+
sendRaw(message: EmailMessage): Promise<EmailSendResult>,
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The `config` object is designed to be spread into your `betterAuth()` call. Override any defaults after spreading:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const email = cloudflareEmail.workers({ binding: env.EMAIL, from: "..." });
|
|
114
|
+
|
|
115
|
+
export const auth = betterAuth({
|
|
116
|
+
...email.config,
|
|
117
|
+
emailAndPassword: {
|
|
118
|
+
...email.config.emailAndPassword,
|
|
119
|
+
requireEmailVerification: true, // your override
|
|
120
|
+
minPasswordLength: 12, // your override
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Hono + Workers example
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { Hono } from "hono";
|
|
129
|
+
import { betterAuth } from "better-auth";
|
|
130
|
+
import { cloudflareEmail } from "better-auth-cloudflare-email";
|
|
131
|
+
|
|
132
|
+
interface Env {
|
|
133
|
+
EMAIL: import("./auth/cloudflare-email").EmailBinding;
|
|
134
|
+
DB: D1Database;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const app = new Hono<{ Bindings: Env }>();
|
|
138
|
+
|
|
139
|
+
app.on(["POST", "GET"], "/api/auth/*", (c) => {
|
|
140
|
+
const email = cloudflareEmail.workers({
|
|
141
|
+
binding: c.env.EMAIL,
|
|
142
|
+
from: "MyApp <noreply@myapp.com>",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const auth = betterAuth({
|
|
146
|
+
...email.config,
|
|
147
|
+
// database, plugins, etc.
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return auth.handler(c.req.raw);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export default app;
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Singleton auth with dynamic binding
|
|
157
|
+
|
|
158
|
+
If you don't want to create auth per-request, pass a function that resolves the binding at call time:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { getRequestContext } from "@cloudflare/next-on-pages";
|
|
162
|
+
import { cloudflareEmail } from "better-auth-cloudflare-email";
|
|
163
|
+
|
|
164
|
+
const email = cloudflareEmail.workers({
|
|
165
|
+
binding: () => getRequestContext().env.EMAIL,
|
|
166
|
+
from: "MyApp <noreply@myapp.com>",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
export const auth = betterAuth({ ...email.config });
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Custom templates
|
|
173
|
+
|
|
174
|
+
Override any template by passing a function that returns `{ subject, html, text }`:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
const email = cloudflareEmail.workers({
|
|
178
|
+
binding: env.EMAIL,
|
|
179
|
+
from: "MyApp <noreply@myapp.com>",
|
|
180
|
+
appName: "MyApp",
|
|
181
|
+
templates: {
|
|
182
|
+
verifyEmail: ({ appName, url, userName }) => ({
|
|
183
|
+
subject: `Welcome to ${appName}!`,
|
|
184
|
+
html: `<h1>Hey ${userName}!</h1><a href="${url}">Verify your email</a>`,
|
|
185
|
+
text: `Verify your email: ${url}`,
|
|
186
|
+
}),
|
|
187
|
+
// resetPassword, changeEmail, deleteAccount, magicLink, otp
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Template data available
|
|
193
|
+
|
|
194
|
+
| Field | Type | Available in |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| `appName` | `string` | All templates |
|
|
197
|
+
| `url` | `string` | All except `otp` |
|
|
198
|
+
| `token` | `string` | All except `otp` |
|
|
199
|
+
| `userName` | `string \| undefined` | `verifyEmail`, `resetPassword` |
|
|
200
|
+
| `otp` | `string` | `otp` only |
|
|
201
|
+
| `email` | `string` | `magicLink`, `otp` |
|
|
202
|
+
|
|
203
|
+
## Sending arbitrary emails
|
|
204
|
+
|
|
205
|
+
Use `sendRaw` to send any email outside of Better Auth's callbacks:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
await email.sendRaw({
|
|
209
|
+
to: "user@example.com",
|
|
210
|
+
from: "support@myapp.com",
|
|
211
|
+
subject: "Your invoice",
|
|
212
|
+
html: "<p>Thanks for your purchase.</p>",
|
|
213
|
+
text: "Thanks for your purchase.",
|
|
214
|
+
attachments: [{
|
|
215
|
+
filename: "invoice.pdf",
|
|
216
|
+
content: base64String,
|
|
217
|
+
contentType: "application/pdf",
|
|
218
|
+
disposition: "attachment",
|
|
219
|
+
}],
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Transport comparison
|
|
224
|
+
|
|
225
|
+
| | `cloudflareEmail.workers()` | `cloudflareEmail.api()` |
|
|
226
|
+
|---|---|---|
|
|
227
|
+
| Runtime | Cloudflare Workers only | Any (Node, Bun, Deno, Vercel...) |
|
|
228
|
+
| Auth | `send_email` binding (no key) | `CF_ACCOUNT_ID` + `CF_API_TOKEN` |
|
|
229
|
+
| Latency | In-process, zero hop | HTTP round-trip to Cloudflare API |
|
|
230
|
+
| Config | `binding: env.EMAIL` | `accountId` + `apiToken` |
|
|
231
|
+
| Output | Identical | Identical |
|
|
232
|
+
|
|
233
|
+
## Cloudflare Email Service setup
|
|
234
|
+
|
|
235
|
+
1. Your domain must use [Cloudflare DNS](https://developers.cloudflare.com/dns/)
|
|
236
|
+
2. Go to **Cloudflare Dashboard > Compute & AI > Email Service > Email Sending**
|
|
237
|
+
3. Select your domain and add the required DNS records (SPF, DKIM)
|
|
238
|
+
4. For the REST API transport, create an [API token](https://dash.cloudflare.com/profile/api-tokens) with email send permissions
|
|
239
|
+
|
|
240
|
+
> Cloudflare Email Service is currently in **private beta**. It requires a Workers Paid plan.
|
|
241
|
+
|
|
242
|
+
## License
|
|
243
|
+
|
|
244
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
cloudflareEmail: () => cloudflareEmail
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/auth/cloudflare-email.ts
|
|
28
|
+
function layout(title, body, appName) {
|
|
29
|
+
return `<!DOCTYPE html>
|
|
30
|
+
<html lang="en">
|
|
31
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
32
|
+
<title>${title}</title>
|
|
33
|
+
<style>
|
|
34
|
+
body{margin:0;padding:0;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
|
|
35
|
+
.wrap{max-width:480px;margin:40px auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
36
|
+
.header{background:#18181b;padding:24px 32px;color:#fff;font-size:18px;font-weight:600}
|
|
37
|
+
.body{padding:32px}
|
|
38
|
+
.body p{margin:0 0 16px;color:#3f3f46;line-height:1.6;font-size:15px}
|
|
39
|
+
.btn{display:inline-block;padding:12px 28px;background:#18181b;color:#fff!important;text-decoration:none;border-radius:8px;font-weight:500;font-size:15px}
|
|
40
|
+
.code{display:inline-block;padding:12px 24px;background:#f4f4f5;border-radius:8px;font-size:28px;font-weight:700;letter-spacing:6px;color:#18181b}
|
|
41
|
+
.footer{padding:16px 32px;font-size:12px;color:#a1a1aa;border-top:1px solid #f4f4f5}
|
|
42
|
+
</style></head>
|
|
43
|
+
<body><div class="wrap">
|
|
44
|
+
<div class="header">${appName}</div>
|
|
45
|
+
<div class="body">${body}</div>
|
|
46
|
+
<div class="footer">You received this email because an action was requested on your account. If you didn't request this, you can safely ignore it.</div>
|
|
47
|
+
</div></body></html>`;
|
|
48
|
+
}
|
|
49
|
+
var defaultTemplates = {
|
|
50
|
+
verifyEmail: ({ appName, url, userName }) => ({
|
|
51
|
+
subject: `Verify your email \u2013 ${appName}`,
|
|
52
|
+
html: layout(
|
|
53
|
+
"Verify Email",
|
|
54
|
+
`<p>Hi${userName ? ` ${userName}` : ""},</p>
|
|
55
|
+
<p>Thanks for signing up. Please verify your email address by clicking the button below.</p>
|
|
56
|
+
<p style="text-align:center;margin:24px 0"><a href="${url}" class="btn">Verify Email</a></p>
|
|
57
|
+
<p style="font-size:13px;color:#71717a">If the button doesn't work, copy and paste this link into your browser:<br>${url}</p>`,
|
|
58
|
+
appName
|
|
59
|
+
),
|
|
60
|
+
text: `Verify your email for ${appName}: ${url}`
|
|
61
|
+
}),
|
|
62
|
+
resetPassword: ({ appName, url, userName }) => ({
|
|
63
|
+
subject: `Reset your password \u2013 ${appName}`,
|
|
64
|
+
html: layout(
|
|
65
|
+
"Reset Password",
|
|
66
|
+
`<p>Hi${userName ? ` ${userName}` : ""},</p>
|
|
67
|
+
<p>We received a request to reset your password. Click the button below to choose a new one.</p>
|
|
68
|
+
<p style="text-align:center;margin:24px 0"><a href="${url}" class="btn">Reset Password</a></p>
|
|
69
|
+
<p style="font-size:13px;color:#71717a">This link expires in 1 hour. If you didn't request a password reset, you can ignore this email.</p>`,
|
|
70
|
+
appName
|
|
71
|
+
),
|
|
72
|
+
text: `Reset your password for ${appName}: ${url}`
|
|
73
|
+
}),
|
|
74
|
+
changeEmail: ({ appName, url }) => ({
|
|
75
|
+
subject: `Confirm email change \u2013 ${appName}`,
|
|
76
|
+
html: layout(
|
|
77
|
+
"Confirm Email Change",
|
|
78
|
+
`<p>You requested to change the email address on your ${appName} account.</p>
|
|
79
|
+
<p>Click the button below to confirm this change.</p>
|
|
80
|
+
<p style="text-align:center;margin:24px 0"><a href="${url}" class="btn">Confirm Change</a></p>
|
|
81
|
+
<p style="font-size:13px;color:#71717a">If you didn't request this, please secure your account immediately.</p>`,
|
|
82
|
+
appName
|
|
83
|
+
),
|
|
84
|
+
text: `Confirm email change for ${appName}: ${url}`
|
|
85
|
+
}),
|
|
86
|
+
deleteAccount: ({ appName, url }) => ({
|
|
87
|
+
subject: `Confirm account deletion \u2013 ${appName}`,
|
|
88
|
+
html: layout(
|
|
89
|
+
"Delete Account",
|
|
90
|
+
`<p>You requested to delete your ${appName} account. This action is permanent.</p>
|
|
91
|
+
<p>Click the button below to confirm deletion.</p>
|
|
92
|
+
<p style="text-align:center;margin:24px 0"><a href="${url}" class="btn" style="background:#dc2626">Delete Account</a></p>
|
|
93
|
+
<p style="font-size:13px;color:#71717a">If you didn't request this, please ignore this email and secure your account.</p>`,
|
|
94
|
+
appName
|
|
95
|
+
),
|
|
96
|
+
text: `Confirm account deletion for ${appName}: ${url}`
|
|
97
|
+
}),
|
|
98
|
+
magicLink: ({ appName, url }) => ({
|
|
99
|
+
subject: `Sign in to ${appName}`,
|
|
100
|
+
html: layout(
|
|
101
|
+
"Magic Link",
|
|
102
|
+
`<p>Click the button below to sign in to your ${appName} account. This link expires in 15 minutes.</p>
|
|
103
|
+
<p style="text-align:center;margin:24px 0"><a href="${url}" class="btn">Sign In</a></p>
|
|
104
|
+
<p style="font-size:13px;color:#71717a">If you didn't request this, you can safely ignore this email.</p>`,
|
|
105
|
+
appName
|
|
106
|
+
),
|
|
107
|
+
text: `Sign in to ${appName}: ${url}`
|
|
108
|
+
}),
|
|
109
|
+
otp: ({ appName, otp }) => ({
|
|
110
|
+
subject: `Your verification code \u2013 ${appName}`,
|
|
111
|
+
html: layout(
|
|
112
|
+
"Verification Code",
|
|
113
|
+
`<p>Use the following code to continue. It expires in 5 minutes.</p>
|
|
114
|
+
<p style="text-align:center;margin:24px 0"><span class="code">${otp}</span></p>
|
|
115
|
+
<p style="font-size:13px;color:#71717a">If you didn't request this code, you can safely ignore this email.</p>`,
|
|
116
|
+
appName
|
|
117
|
+
),
|
|
118
|
+
text: `Your ${appName} verification code: ${otp}`
|
|
119
|
+
})
|
|
120
|
+
};
|
|
121
|
+
function createWorkersTransport(opts) {
|
|
122
|
+
return {
|
|
123
|
+
async send(message) {
|
|
124
|
+
const binding = typeof opts.binding === "function" ? opts.binding() : opts.binding;
|
|
125
|
+
return binding.send(message);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function createApiTransport(opts) {
|
|
130
|
+
const baseUrl = opts.baseUrl ?? "https://api.cloudflare.com/client/v4";
|
|
131
|
+
const url = `${baseUrl}/accounts/${opts.accountId}/email-service/send`;
|
|
132
|
+
return {
|
|
133
|
+
async send(message) {
|
|
134
|
+
const res = await fetch(url, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: {
|
|
137
|
+
Authorization: `Bearer ${opts.apiToken}`,
|
|
138
|
+
"Content-Type": "application/json"
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify(message)
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
const body = await res.text();
|
|
144
|
+
throw new Error(`Cloudflare Email API error (${res.status}): ${body}`);
|
|
145
|
+
}
|
|
146
|
+
const json = await res.json();
|
|
147
|
+
return { messageId: json.result?.messageId ?? "" };
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function build(transport, shared) {
|
|
152
|
+
const appName = shared.appName ?? "Our App";
|
|
153
|
+
const from = shared.from;
|
|
154
|
+
const t = { ...defaultTemplates, ...shared.templates };
|
|
155
|
+
function fire(to, rendered) {
|
|
156
|
+
void transport.send({ to, from, subject: rendered.subject, html: rendered.html, text: rendered.text });
|
|
157
|
+
}
|
|
158
|
+
const sendVerificationEmail = async (data, _request) => {
|
|
159
|
+
fire(data.user.email, t.verifyEmail({ appName, url: data.url, token: data.token, userName: data.user.name }));
|
|
160
|
+
};
|
|
161
|
+
const sendResetPassword = async (data, _request) => {
|
|
162
|
+
fire(data.user.email, t.resetPassword({ appName, url: data.url, token: data.token, userName: data.user.name }));
|
|
163
|
+
};
|
|
164
|
+
const sendChangeEmailConfirmation = async (data, _request) => {
|
|
165
|
+
fire(data.newEmail, t.changeEmail({ appName, url: data.url, token: data.token }));
|
|
166
|
+
};
|
|
167
|
+
const sendDeleteAccountVerification = async (data, _request) => {
|
|
168
|
+
fire(data.user.email, t.deleteAccount({ appName, url: data.url, token: data.token }));
|
|
169
|
+
};
|
|
170
|
+
const sendMagicLink = async (data) => {
|
|
171
|
+
fire(data.email, t.magicLink({ appName, url: data.url, token: data.token, email: data.email }));
|
|
172
|
+
};
|
|
173
|
+
const sendVerificationOTP = async (data) => {
|
|
174
|
+
fire(data.email, t.otp({ appName, otp: data.otp, email: data.email }));
|
|
175
|
+
};
|
|
176
|
+
return {
|
|
177
|
+
config: {
|
|
178
|
+
emailVerification: { sendVerificationEmail, sendOnSignUp: true },
|
|
179
|
+
emailAndPassword: { enabled: true, sendResetPassword },
|
|
180
|
+
user: {
|
|
181
|
+
changeEmail: { enabled: true, sendChangeEmailConfirmation },
|
|
182
|
+
deleteUser: { enabled: true, sendDeleteAccountVerification }
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
sendVerificationEmail,
|
|
186
|
+
sendResetPassword,
|
|
187
|
+
sendChangeEmailConfirmation,
|
|
188
|
+
sendDeleteAccountVerification,
|
|
189
|
+
sendMagicLink,
|
|
190
|
+
sendVerificationOTP,
|
|
191
|
+
sendRaw: (message) => transport.send(message)
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
var cloudflareEmail = {
|
|
195
|
+
/**
|
|
196
|
+
* Use inside a Cloudflare Worker — sends via the `send_email` binding.
|
|
197
|
+
* Zero network hop, no API key needed.
|
|
198
|
+
*
|
|
199
|
+
* ```ts
|
|
200
|
+
* const email = cloudflareEmail.workers({
|
|
201
|
+
* binding: env.EMAIL,
|
|
202
|
+
* from: "App <noreply@app.com>",
|
|
203
|
+
* });
|
|
204
|
+
* ```
|
|
205
|
+
*/
|
|
206
|
+
workers(opts) {
|
|
207
|
+
return build(createWorkersTransport(opts), opts);
|
|
208
|
+
},
|
|
209
|
+
/**
|
|
210
|
+
* Use from any runtime (Node.js, Bun, Deno, Vercel, etc.) — sends via
|
|
211
|
+
* the Cloudflare Email Service REST API.
|
|
212
|
+
*
|
|
213
|
+
* ```ts
|
|
214
|
+
* const email = cloudflareEmail.api({
|
|
215
|
+
* accountId: process.env.CF_ACCOUNT_ID,
|
|
216
|
+
* apiToken: process.env.CF_API_TOKEN,
|
|
217
|
+
* from: "App <noreply@app.com>",
|
|
218
|
+
* });
|
|
219
|
+
* ```
|
|
220
|
+
*/
|
|
221
|
+
api(opts) {
|
|
222
|
+
return build(createApiTransport(opts), opts);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
226
|
+
0 && (module.exports = {
|
|
227
|
+
cloudflareEmail
|
|
228
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Better Auth + Cloudflare Email Service integration.
|
|
3
|
+
*
|
|
4
|
+
* Two transports, one interface:
|
|
5
|
+
*
|
|
6
|
+
* // Inside a Cloudflare Worker — uses the send_email binding (zero latency, no API key)
|
|
7
|
+
* import { cloudflareEmail } from "./cloudflare-email";
|
|
8
|
+
* const email = cloudflareEmail.workers({ binding: env.EMAIL, from: "..." });
|
|
9
|
+
*
|
|
10
|
+
* // Anywhere else — uses the Cloudflare REST API
|
|
11
|
+
* import { cloudflareEmail } from "./cloudflare-email";
|
|
12
|
+
* const email = cloudflareEmail.api({ accountId: "...", apiToken: "...", from: "..." });
|
|
13
|
+
*
|
|
14
|
+
* Both return the same object shape — spread `email.config` into betterAuth()
|
|
15
|
+
* and wire plugin callbacks identically.
|
|
16
|
+
*/
|
|
17
|
+
interface EmailMessage {
|
|
18
|
+
to: string | string[];
|
|
19
|
+
from: string;
|
|
20
|
+
subject: string;
|
|
21
|
+
html?: string;
|
|
22
|
+
text?: string;
|
|
23
|
+
cc?: string | string[];
|
|
24
|
+
bcc?: string | string[];
|
|
25
|
+
replyTo?: string;
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
attachments?: EmailAttachment[];
|
|
28
|
+
}
|
|
29
|
+
interface EmailAttachment {
|
|
30
|
+
filename: string;
|
|
31
|
+
content: string;
|
|
32
|
+
contentType: string;
|
|
33
|
+
disposition: "attachment" | "inline";
|
|
34
|
+
contentId?: string;
|
|
35
|
+
}
|
|
36
|
+
interface EmailSendResult {
|
|
37
|
+
messageId: string;
|
|
38
|
+
}
|
|
39
|
+
interface EmailTransport {
|
|
40
|
+
send(message: EmailMessage): Promise<EmailSendResult>;
|
|
41
|
+
}
|
|
42
|
+
interface TemplateData {
|
|
43
|
+
appName: string;
|
|
44
|
+
url?: string;
|
|
45
|
+
token?: string;
|
|
46
|
+
otp?: string;
|
|
47
|
+
email?: string;
|
|
48
|
+
userName?: string;
|
|
49
|
+
}
|
|
50
|
+
type TemplateFn = (data: TemplateData) => {
|
|
51
|
+
subject: string;
|
|
52
|
+
html: string;
|
|
53
|
+
text: string;
|
|
54
|
+
};
|
|
55
|
+
interface Templates {
|
|
56
|
+
verifyEmail?: TemplateFn;
|
|
57
|
+
resetPassword?: TemplateFn;
|
|
58
|
+
changeEmail?: TemplateFn;
|
|
59
|
+
deleteAccount?: TemplateFn;
|
|
60
|
+
magicLink?: TemplateFn;
|
|
61
|
+
otp?: TemplateFn;
|
|
62
|
+
}
|
|
63
|
+
interface SharedOptions {
|
|
64
|
+
/** Default "from" address, e.g. "MyApp <noreply@myapp.com>". */
|
|
65
|
+
from: string;
|
|
66
|
+
/** Application name shown in email templates. Default: "Our App". */
|
|
67
|
+
appName?: string;
|
|
68
|
+
/** Override default email templates per type. */
|
|
69
|
+
templates?: Templates;
|
|
70
|
+
}
|
|
71
|
+
/** Cloudflare Workers send_email binding. */
|
|
72
|
+
interface EmailBinding {
|
|
73
|
+
send(message: EmailMessage): Promise<EmailSendResult>;
|
|
74
|
+
sendBatch?(messages: EmailMessage[]): Promise<{
|
|
75
|
+
results: Array<{
|
|
76
|
+
success: boolean;
|
|
77
|
+
messageId?: string;
|
|
78
|
+
error?: string;
|
|
79
|
+
}>;
|
|
80
|
+
}>;
|
|
81
|
+
}
|
|
82
|
+
interface WorkersOptions extends SharedOptions {
|
|
83
|
+
/** The send_email binding, or a function that returns it (for singleton auth). */
|
|
84
|
+
binding: EmailBinding | (() => EmailBinding);
|
|
85
|
+
}
|
|
86
|
+
interface ApiOptions extends SharedOptions {
|
|
87
|
+
/** Cloudflare Account ID. */
|
|
88
|
+
accountId: string;
|
|
89
|
+
/** Cloudflare API Token with email send permissions. */
|
|
90
|
+
apiToken: string;
|
|
91
|
+
/** Override the base URL. Default: "https://api.cloudflare.com/client/v4" */
|
|
92
|
+
baseUrl?: string;
|
|
93
|
+
}
|
|
94
|
+
interface CloudflareEmailResult {
|
|
95
|
+
/** Spread into betterAuth() to wire up core email callbacks automatically. */
|
|
96
|
+
config: {
|
|
97
|
+
emailVerification: {
|
|
98
|
+
sendVerificationEmail: (data: {
|
|
99
|
+
user: {
|
|
100
|
+
email: string;
|
|
101
|
+
name?: string;
|
|
102
|
+
};
|
|
103
|
+
url: string;
|
|
104
|
+
token: string;
|
|
105
|
+
}, request?: Request) => Promise<void>;
|
|
106
|
+
sendOnSignUp: true;
|
|
107
|
+
};
|
|
108
|
+
emailAndPassword: {
|
|
109
|
+
enabled: true;
|
|
110
|
+
sendResetPassword: (data: {
|
|
111
|
+
user: {
|
|
112
|
+
email: string;
|
|
113
|
+
name?: string;
|
|
114
|
+
};
|
|
115
|
+
url: string;
|
|
116
|
+
token: string;
|
|
117
|
+
}, request?: Request) => Promise<void>;
|
|
118
|
+
};
|
|
119
|
+
user: {
|
|
120
|
+
changeEmail: {
|
|
121
|
+
enabled: true;
|
|
122
|
+
sendChangeEmailConfirmation: (data: {
|
|
123
|
+
user: {
|
|
124
|
+
email: string;
|
|
125
|
+
name?: string;
|
|
126
|
+
};
|
|
127
|
+
newEmail: string;
|
|
128
|
+
url: string;
|
|
129
|
+
token: string;
|
|
130
|
+
}, request?: Request) => Promise<void>;
|
|
131
|
+
};
|
|
132
|
+
deleteUser: {
|
|
133
|
+
enabled: true;
|
|
134
|
+
sendDeleteAccountVerification: (data: {
|
|
135
|
+
user: {
|
|
136
|
+
email: string;
|
|
137
|
+
name?: string;
|
|
138
|
+
};
|
|
139
|
+
url: string;
|
|
140
|
+
token: string;
|
|
141
|
+
}, request?: Request) => Promise<void>;
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
/** For magicLink() plugin. */
|
|
146
|
+
sendMagicLink: (data: {
|
|
147
|
+
email: string;
|
|
148
|
+
url: string;
|
|
149
|
+
token: string;
|
|
150
|
+
metadata?: Record<string, unknown>;
|
|
151
|
+
}) => Promise<void>;
|
|
152
|
+
/** For emailOTP() plugin. */
|
|
153
|
+
sendVerificationOTP: (data: {
|
|
154
|
+
email: string;
|
|
155
|
+
otp: string;
|
|
156
|
+
type: string;
|
|
157
|
+
}) => Promise<void>;
|
|
158
|
+
/** Individual callbacks if you prefer manual wiring. */
|
|
159
|
+
sendVerificationEmail: (data: {
|
|
160
|
+
user: {
|
|
161
|
+
email: string;
|
|
162
|
+
name?: string;
|
|
163
|
+
};
|
|
164
|
+
url: string;
|
|
165
|
+
token: string;
|
|
166
|
+
}, request?: Request) => Promise<void>;
|
|
167
|
+
sendResetPassword: (data: {
|
|
168
|
+
user: {
|
|
169
|
+
email: string;
|
|
170
|
+
name?: string;
|
|
171
|
+
};
|
|
172
|
+
url: string;
|
|
173
|
+
token: string;
|
|
174
|
+
}, request?: Request) => Promise<void>;
|
|
175
|
+
sendChangeEmailConfirmation: (data: {
|
|
176
|
+
user: {
|
|
177
|
+
email: string;
|
|
178
|
+
name?: string;
|
|
179
|
+
};
|
|
180
|
+
newEmail: string;
|
|
181
|
+
url: string;
|
|
182
|
+
token: string;
|
|
183
|
+
}, request?: Request) => Promise<void>;
|
|
184
|
+
sendDeleteAccountVerification: (data: {
|
|
185
|
+
user: {
|
|
186
|
+
email: string;
|
|
187
|
+
name?: string;
|
|
188
|
+
};
|
|
189
|
+
url: string;
|
|
190
|
+
token: string;
|
|
191
|
+
}, request?: Request) => Promise<void>;
|
|
192
|
+
/** Send an arbitrary email through the underlying transport. */
|
|
193
|
+
sendRaw: (message: EmailMessage) => Promise<EmailSendResult>;
|
|
194
|
+
}
|
|
195
|
+
declare const cloudflareEmail: {
|
|
196
|
+
/**
|
|
197
|
+
* Use inside a Cloudflare Worker — sends via the `send_email` binding.
|
|
198
|
+
* Zero network hop, no API key needed.
|
|
199
|
+
*
|
|
200
|
+
* ```ts
|
|
201
|
+
* const email = cloudflareEmail.workers({
|
|
202
|
+
* binding: env.EMAIL,
|
|
203
|
+
* from: "App <noreply@app.com>",
|
|
204
|
+
* });
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
workers(opts: WorkersOptions): CloudflareEmailResult;
|
|
208
|
+
/**
|
|
209
|
+
* Use from any runtime (Node.js, Bun, Deno, Vercel, etc.) — sends via
|
|
210
|
+
* the Cloudflare Email Service REST API.
|
|
211
|
+
*
|
|
212
|
+
* ```ts
|
|
213
|
+
* const email = cloudflareEmail.api({
|
|
214
|
+
* accountId: process.env.CF_ACCOUNT_ID,
|
|
215
|
+
* apiToken: process.env.CF_API_TOKEN,
|
|
216
|
+
* from: "App <noreply@app.com>",
|
|
217
|
+
* });
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
api(opts: ApiOptions): CloudflareEmailResult;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export { type ApiOptions, type EmailAttachment, type EmailBinding, type EmailMessage, type EmailSendResult, type EmailTransport, type TemplateData, type TemplateFn, type Templates, type WorkersOptions, cloudflareEmail };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Better Auth + Cloudflare Email Service integration.
|
|
3
|
+
*
|
|
4
|
+
* Two transports, one interface:
|
|
5
|
+
*
|
|
6
|
+
* // Inside a Cloudflare Worker — uses the send_email binding (zero latency, no API key)
|
|
7
|
+
* import { cloudflareEmail } from "./cloudflare-email";
|
|
8
|
+
* const email = cloudflareEmail.workers({ binding: env.EMAIL, from: "..." });
|
|
9
|
+
*
|
|
10
|
+
* // Anywhere else — uses the Cloudflare REST API
|
|
11
|
+
* import { cloudflareEmail } from "./cloudflare-email";
|
|
12
|
+
* const email = cloudflareEmail.api({ accountId: "...", apiToken: "...", from: "..." });
|
|
13
|
+
*
|
|
14
|
+
* Both return the same object shape — spread `email.config` into betterAuth()
|
|
15
|
+
* and wire plugin callbacks identically.
|
|
16
|
+
*/
|
|
17
|
+
interface EmailMessage {
|
|
18
|
+
to: string | string[];
|
|
19
|
+
from: string;
|
|
20
|
+
subject: string;
|
|
21
|
+
html?: string;
|
|
22
|
+
text?: string;
|
|
23
|
+
cc?: string | string[];
|
|
24
|
+
bcc?: string | string[];
|
|
25
|
+
replyTo?: string;
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
attachments?: EmailAttachment[];
|
|
28
|
+
}
|
|
29
|
+
interface EmailAttachment {
|
|
30
|
+
filename: string;
|
|
31
|
+
content: string;
|
|
32
|
+
contentType: string;
|
|
33
|
+
disposition: "attachment" | "inline";
|
|
34
|
+
contentId?: string;
|
|
35
|
+
}
|
|
36
|
+
interface EmailSendResult {
|
|
37
|
+
messageId: string;
|
|
38
|
+
}
|
|
39
|
+
interface EmailTransport {
|
|
40
|
+
send(message: EmailMessage): Promise<EmailSendResult>;
|
|
41
|
+
}
|
|
42
|
+
interface TemplateData {
|
|
43
|
+
appName: string;
|
|
44
|
+
url?: string;
|
|
45
|
+
token?: string;
|
|
46
|
+
otp?: string;
|
|
47
|
+
email?: string;
|
|
48
|
+
userName?: string;
|
|
49
|
+
}
|
|
50
|
+
type TemplateFn = (data: TemplateData) => {
|
|
51
|
+
subject: string;
|
|
52
|
+
html: string;
|
|
53
|
+
text: string;
|
|
54
|
+
};
|
|
55
|
+
interface Templates {
|
|
56
|
+
verifyEmail?: TemplateFn;
|
|
57
|
+
resetPassword?: TemplateFn;
|
|
58
|
+
changeEmail?: TemplateFn;
|
|
59
|
+
deleteAccount?: TemplateFn;
|
|
60
|
+
magicLink?: TemplateFn;
|
|
61
|
+
otp?: TemplateFn;
|
|
62
|
+
}
|
|
63
|
+
interface SharedOptions {
|
|
64
|
+
/** Default "from" address, e.g. "MyApp <noreply@myapp.com>". */
|
|
65
|
+
from: string;
|
|
66
|
+
/** Application name shown in email templates. Default: "Our App". */
|
|
67
|
+
appName?: string;
|
|
68
|
+
/** Override default email templates per type. */
|
|
69
|
+
templates?: Templates;
|
|
70
|
+
}
|
|
71
|
+
/** Cloudflare Workers send_email binding. */
|
|
72
|
+
interface EmailBinding {
|
|
73
|
+
send(message: EmailMessage): Promise<EmailSendResult>;
|
|
74
|
+
sendBatch?(messages: EmailMessage[]): Promise<{
|
|
75
|
+
results: Array<{
|
|
76
|
+
success: boolean;
|
|
77
|
+
messageId?: string;
|
|
78
|
+
error?: string;
|
|
79
|
+
}>;
|
|
80
|
+
}>;
|
|
81
|
+
}
|
|
82
|
+
interface WorkersOptions extends SharedOptions {
|
|
83
|
+
/** The send_email binding, or a function that returns it (for singleton auth). */
|
|
84
|
+
binding: EmailBinding | (() => EmailBinding);
|
|
85
|
+
}
|
|
86
|
+
interface ApiOptions extends SharedOptions {
|
|
87
|
+
/** Cloudflare Account ID. */
|
|
88
|
+
accountId: string;
|
|
89
|
+
/** Cloudflare API Token with email send permissions. */
|
|
90
|
+
apiToken: string;
|
|
91
|
+
/** Override the base URL. Default: "https://api.cloudflare.com/client/v4" */
|
|
92
|
+
baseUrl?: string;
|
|
93
|
+
}
|
|
94
|
+
interface CloudflareEmailResult {
|
|
95
|
+
/** Spread into betterAuth() to wire up core email callbacks automatically. */
|
|
96
|
+
config: {
|
|
97
|
+
emailVerification: {
|
|
98
|
+
sendVerificationEmail: (data: {
|
|
99
|
+
user: {
|
|
100
|
+
email: string;
|
|
101
|
+
name?: string;
|
|
102
|
+
};
|
|
103
|
+
url: string;
|
|
104
|
+
token: string;
|
|
105
|
+
}, request?: Request) => Promise<void>;
|
|
106
|
+
sendOnSignUp: true;
|
|
107
|
+
};
|
|
108
|
+
emailAndPassword: {
|
|
109
|
+
enabled: true;
|
|
110
|
+
sendResetPassword: (data: {
|
|
111
|
+
user: {
|
|
112
|
+
email: string;
|
|
113
|
+
name?: string;
|
|
114
|
+
};
|
|
115
|
+
url: string;
|
|
116
|
+
token: string;
|
|
117
|
+
}, request?: Request) => Promise<void>;
|
|
118
|
+
};
|
|
119
|
+
user: {
|
|
120
|
+
changeEmail: {
|
|
121
|
+
enabled: true;
|
|
122
|
+
sendChangeEmailConfirmation: (data: {
|
|
123
|
+
user: {
|
|
124
|
+
email: string;
|
|
125
|
+
name?: string;
|
|
126
|
+
};
|
|
127
|
+
newEmail: string;
|
|
128
|
+
url: string;
|
|
129
|
+
token: string;
|
|
130
|
+
}, request?: Request) => Promise<void>;
|
|
131
|
+
};
|
|
132
|
+
deleteUser: {
|
|
133
|
+
enabled: true;
|
|
134
|
+
sendDeleteAccountVerification: (data: {
|
|
135
|
+
user: {
|
|
136
|
+
email: string;
|
|
137
|
+
name?: string;
|
|
138
|
+
};
|
|
139
|
+
url: string;
|
|
140
|
+
token: string;
|
|
141
|
+
}, request?: Request) => Promise<void>;
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
/** For magicLink() plugin. */
|
|
146
|
+
sendMagicLink: (data: {
|
|
147
|
+
email: string;
|
|
148
|
+
url: string;
|
|
149
|
+
token: string;
|
|
150
|
+
metadata?: Record<string, unknown>;
|
|
151
|
+
}) => Promise<void>;
|
|
152
|
+
/** For emailOTP() plugin. */
|
|
153
|
+
sendVerificationOTP: (data: {
|
|
154
|
+
email: string;
|
|
155
|
+
otp: string;
|
|
156
|
+
type: string;
|
|
157
|
+
}) => Promise<void>;
|
|
158
|
+
/** Individual callbacks if you prefer manual wiring. */
|
|
159
|
+
sendVerificationEmail: (data: {
|
|
160
|
+
user: {
|
|
161
|
+
email: string;
|
|
162
|
+
name?: string;
|
|
163
|
+
};
|
|
164
|
+
url: string;
|
|
165
|
+
token: string;
|
|
166
|
+
}, request?: Request) => Promise<void>;
|
|
167
|
+
sendResetPassword: (data: {
|
|
168
|
+
user: {
|
|
169
|
+
email: string;
|
|
170
|
+
name?: string;
|
|
171
|
+
};
|
|
172
|
+
url: string;
|
|
173
|
+
token: string;
|
|
174
|
+
}, request?: Request) => Promise<void>;
|
|
175
|
+
sendChangeEmailConfirmation: (data: {
|
|
176
|
+
user: {
|
|
177
|
+
email: string;
|
|
178
|
+
name?: string;
|
|
179
|
+
};
|
|
180
|
+
newEmail: string;
|
|
181
|
+
url: string;
|
|
182
|
+
token: string;
|
|
183
|
+
}, request?: Request) => Promise<void>;
|
|
184
|
+
sendDeleteAccountVerification: (data: {
|
|
185
|
+
user: {
|
|
186
|
+
email: string;
|
|
187
|
+
name?: string;
|
|
188
|
+
};
|
|
189
|
+
url: string;
|
|
190
|
+
token: string;
|
|
191
|
+
}, request?: Request) => Promise<void>;
|
|
192
|
+
/** Send an arbitrary email through the underlying transport. */
|
|
193
|
+
sendRaw: (message: EmailMessage) => Promise<EmailSendResult>;
|
|
194
|
+
}
|
|
195
|
+
declare const cloudflareEmail: {
|
|
196
|
+
/**
|
|
197
|
+
* Use inside a Cloudflare Worker — sends via the `send_email` binding.
|
|
198
|
+
* Zero network hop, no API key needed.
|
|
199
|
+
*
|
|
200
|
+
* ```ts
|
|
201
|
+
* const email = cloudflareEmail.workers({
|
|
202
|
+
* binding: env.EMAIL,
|
|
203
|
+
* from: "App <noreply@app.com>",
|
|
204
|
+
* });
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
workers(opts: WorkersOptions): CloudflareEmailResult;
|
|
208
|
+
/**
|
|
209
|
+
* Use from any runtime (Node.js, Bun, Deno, Vercel, etc.) — sends via
|
|
210
|
+
* the Cloudflare Email Service REST API.
|
|
211
|
+
*
|
|
212
|
+
* ```ts
|
|
213
|
+
* const email = cloudflareEmail.api({
|
|
214
|
+
* accountId: process.env.CF_ACCOUNT_ID,
|
|
215
|
+
* apiToken: process.env.CF_API_TOKEN,
|
|
216
|
+
* from: "App <noreply@app.com>",
|
|
217
|
+
* });
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
api(opts: ApiOptions): CloudflareEmailResult;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export { type ApiOptions, type EmailAttachment, type EmailBinding, type EmailMessage, type EmailSendResult, type EmailTransport, type TemplateData, type TemplateFn, type Templates, type WorkersOptions, cloudflareEmail };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// src/auth/cloudflare-email.ts
|
|
2
|
+
function layout(title, body, appName) {
|
|
3
|
+
return `<!DOCTYPE html>
|
|
4
|
+
<html lang="en">
|
|
5
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>${title}</title>
|
|
7
|
+
<style>
|
|
8
|
+
body{margin:0;padding:0;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
|
|
9
|
+
.wrap{max-width:480px;margin:40px auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
10
|
+
.header{background:#18181b;padding:24px 32px;color:#fff;font-size:18px;font-weight:600}
|
|
11
|
+
.body{padding:32px}
|
|
12
|
+
.body p{margin:0 0 16px;color:#3f3f46;line-height:1.6;font-size:15px}
|
|
13
|
+
.btn{display:inline-block;padding:12px 28px;background:#18181b;color:#fff!important;text-decoration:none;border-radius:8px;font-weight:500;font-size:15px}
|
|
14
|
+
.code{display:inline-block;padding:12px 24px;background:#f4f4f5;border-radius:8px;font-size:28px;font-weight:700;letter-spacing:6px;color:#18181b}
|
|
15
|
+
.footer{padding:16px 32px;font-size:12px;color:#a1a1aa;border-top:1px solid #f4f4f5}
|
|
16
|
+
</style></head>
|
|
17
|
+
<body><div class="wrap">
|
|
18
|
+
<div class="header">${appName}</div>
|
|
19
|
+
<div class="body">${body}</div>
|
|
20
|
+
<div class="footer">You received this email because an action was requested on your account. If you didn't request this, you can safely ignore it.</div>
|
|
21
|
+
</div></body></html>`;
|
|
22
|
+
}
|
|
23
|
+
var defaultTemplates = {
|
|
24
|
+
verifyEmail: ({ appName, url, userName }) => ({
|
|
25
|
+
subject: `Verify your email \u2013 ${appName}`,
|
|
26
|
+
html: layout(
|
|
27
|
+
"Verify Email",
|
|
28
|
+
`<p>Hi${userName ? ` ${userName}` : ""},</p>
|
|
29
|
+
<p>Thanks for signing up. Please verify your email address by clicking the button below.</p>
|
|
30
|
+
<p style="text-align:center;margin:24px 0"><a href="${url}" class="btn">Verify Email</a></p>
|
|
31
|
+
<p style="font-size:13px;color:#71717a">If the button doesn't work, copy and paste this link into your browser:<br>${url}</p>`,
|
|
32
|
+
appName
|
|
33
|
+
),
|
|
34
|
+
text: `Verify your email for ${appName}: ${url}`
|
|
35
|
+
}),
|
|
36
|
+
resetPassword: ({ appName, url, userName }) => ({
|
|
37
|
+
subject: `Reset your password \u2013 ${appName}`,
|
|
38
|
+
html: layout(
|
|
39
|
+
"Reset Password",
|
|
40
|
+
`<p>Hi${userName ? ` ${userName}` : ""},</p>
|
|
41
|
+
<p>We received a request to reset your password. Click the button below to choose a new one.</p>
|
|
42
|
+
<p style="text-align:center;margin:24px 0"><a href="${url}" class="btn">Reset Password</a></p>
|
|
43
|
+
<p style="font-size:13px;color:#71717a">This link expires in 1 hour. If you didn't request a password reset, you can ignore this email.</p>`,
|
|
44
|
+
appName
|
|
45
|
+
),
|
|
46
|
+
text: `Reset your password for ${appName}: ${url}`
|
|
47
|
+
}),
|
|
48
|
+
changeEmail: ({ appName, url }) => ({
|
|
49
|
+
subject: `Confirm email change \u2013 ${appName}`,
|
|
50
|
+
html: layout(
|
|
51
|
+
"Confirm Email Change",
|
|
52
|
+
`<p>You requested to change the email address on your ${appName} account.</p>
|
|
53
|
+
<p>Click the button below to confirm this change.</p>
|
|
54
|
+
<p style="text-align:center;margin:24px 0"><a href="${url}" class="btn">Confirm Change</a></p>
|
|
55
|
+
<p style="font-size:13px;color:#71717a">If you didn't request this, please secure your account immediately.</p>`,
|
|
56
|
+
appName
|
|
57
|
+
),
|
|
58
|
+
text: `Confirm email change for ${appName}: ${url}`
|
|
59
|
+
}),
|
|
60
|
+
deleteAccount: ({ appName, url }) => ({
|
|
61
|
+
subject: `Confirm account deletion \u2013 ${appName}`,
|
|
62
|
+
html: layout(
|
|
63
|
+
"Delete Account",
|
|
64
|
+
`<p>You requested to delete your ${appName} account. This action is permanent.</p>
|
|
65
|
+
<p>Click the button below to confirm deletion.</p>
|
|
66
|
+
<p style="text-align:center;margin:24px 0"><a href="${url}" class="btn" style="background:#dc2626">Delete Account</a></p>
|
|
67
|
+
<p style="font-size:13px;color:#71717a">If you didn't request this, please ignore this email and secure your account.</p>`,
|
|
68
|
+
appName
|
|
69
|
+
),
|
|
70
|
+
text: `Confirm account deletion for ${appName}: ${url}`
|
|
71
|
+
}),
|
|
72
|
+
magicLink: ({ appName, url }) => ({
|
|
73
|
+
subject: `Sign in to ${appName}`,
|
|
74
|
+
html: layout(
|
|
75
|
+
"Magic Link",
|
|
76
|
+
`<p>Click the button below to sign in to your ${appName} account. This link expires in 15 minutes.</p>
|
|
77
|
+
<p style="text-align:center;margin:24px 0"><a href="${url}" class="btn">Sign In</a></p>
|
|
78
|
+
<p style="font-size:13px;color:#71717a">If you didn't request this, you can safely ignore this email.</p>`,
|
|
79
|
+
appName
|
|
80
|
+
),
|
|
81
|
+
text: `Sign in to ${appName}: ${url}`
|
|
82
|
+
}),
|
|
83
|
+
otp: ({ appName, otp }) => ({
|
|
84
|
+
subject: `Your verification code \u2013 ${appName}`,
|
|
85
|
+
html: layout(
|
|
86
|
+
"Verification Code",
|
|
87
|
+
`<p>Use the following code to continue. It expires in 5 minutes.</p>
|
|
88
|
+
<p style="text-align:center;margin:24px 0"><span class="code">${otp}</span></p>
|
|
89
|
+
<p style="font-size:13px;color:#71717a">If you didn't request this code, you can safely ignore this email.</p>`,
|
|
90
|
+
appName
|
|
91
|
+
),
|
|
92
|
+
text: `Your ${appName} verification code: ${otp}`
|
|
93
|
+
})
|
|
94
|
+
};
|
|
95
|
+
function createWorkersTransport(opts) {
|
|
96
|
+
return {
|
|
97
|
+
async send(message) {
|
|
98
|
+
const binding = typeof opts.binding === "function" ? opts.binding() : opts.binding;
|
|
99
|
+
return binding.send(message);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function createApiTransport(opts) {
|
|
104
|
+
const baseUrl = opts.baseUrl ?? "https://api.cloudflare.com/client/v4";
|
|
105
|
+
const url = `${baseUrl}/accounts/${opts.accountId}/email-service/send`;
|
|
106
|
+
return {
|
|
107
|
+
async send(message) {
|
|
108
|
+
const res = await fetch(url, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: {
|
|
111
|
+
Authorization: `Bearer ${opts.apiToken}`,
|
|
112
|
+
"Content-Type": "application/json"
|
|
113
|
+
},
|
|
114
|
+
body: JSON.stringify(message)
|
|
115
|
+
});
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
const body = await res.text();
|
|
118
|
+
throw new Error(`Cloudflare Email API error (${res.status}): ${body}`);
|
|
119
|
+
}
|
|
120
|
+
const json = await res.json();
|
|
121
|
+
return { messageId: json.result?.messageId ?? "" };
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function build(transport, shared) {
|
|
126
|
+
const appName = shared.appName ?? "Our App";
|
|
127
|
+
const from = shared.from;
|
|
128
|
+
const t = { ...defaultTemplates, ...shared.templates };
|
|
129
|
+
function fire(to, rendered) {
|
|
130
|
+
void transport.send({ to, from, subject: rendered.subject, html: rendered.html, text: rendered.text });
|
|
131
|
+
}
|
|
132
|
+
const sendVerificationEmail = async (data, _request) => {
|
|
133
|
+
fire(data.user.email, t.verifyEmail({ appName, url: data.url, token: data.token, userName: data.user.name }));
|
|
134
|
+
};
|
|
135
|
+
const sendResetPassword = async (data, _request) => {
|
|
136
|
+
fire(data.user.email, t.resetPassword({ appName, url: data.url, token: data.token, userName: data.user.name }));
|
|
137
|
+
};
|
|
138
|
+
const sendChangeEmailConfirmation = async (data, _request) => {
|
|
139
|
+
fire(data.newEmail, t.changeEmail({ appName, url: data.url, token: data.token }));
|
|
140
|
+
};
|
|
141
|
+
const sendDeleteAccountVerification = async (data, _request) => {
|
|
142
|
+
fire(data.user.email, t.deleteAccount({ appName, url: data.url, token: data.token }));
|
|
143
|
+
};
|
|
144
|
+
const sendMagicLink = async (data) => {
|
|
145
|
+
fire(data.email, t.magicLink({ appName, url: data.url, token: data.token, email: data.email }));
|
|
146
|
+
};
|
|
147
|
+
const sendVerificationOTP = async (data) => {
|
|
148
|
+
fire(data.email, t.otp({ appName, otp: data.otp, email: data.email }));
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
config: {
|
|
152
|
+
emailVerification: { sendVerificationEmail, sendOnSignUp: true },
|
|
153
|
+
emailAndPassword: { enabled: true, sendResetPassword },
|
|
154
|
+
user: {
|
|
155
|
+
changeEmail: { enabled: true, sendChangeEmailConfirmation },
|
|
156
|
+
deleteUser: { enabled: true, sendDeleteAccountVerification }
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
sendVerificationEmail,
|
|
160
|
+
sendResetPassword,
|
|
161
|
+
sendChangeEmailConfirmation,
|
|
162
|
+
sendDeleteAccountVerification,
|
|
163
|
+
sendMagicLink,
|
|
164
|
+
sendVerificationOTP,
|
|
165
|
+
sendRaw: (message) => transport.send(message)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
var cloudflareEmail = {
|
|
169
|
+
/**
|
|
170
|
+
* Use inside a Cloudflare Worker — sends via the `send_email` binding.
|
|
171
|
+
* Zero network hop, no API key needed.
|
|
172
|
+
*
|
|
173
|
+
* ```ts
|
|
174
|
+
* const email = cloudflareEmail.workers({
|
|
175
|
+
* binding: env.EMAIL,
|
|
176
|
+
* from: "App <noreply@app.com>",
|
|
177
|
+
* });
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
workers(opts) {
|
|
181
|
+
return build(createWorkersTransport(opts), opts);
|
|
182
|
+
},
|
|
183
|
+
/**
|
|
184
|
+
* Use from any runtime (Node.js, Bun, Deno, Vercel, etc.) — sends via
|
|
185
|
+
* the Cloudflare Email Service REST API.
|
|
186
|
+
*
|
|
187
|
+
* ```ts
|
|
188
|
+
* const email = cloudflareEmail.api({
|
|
189
|
+
* accountId: process.env.CF_ACCOUNT_ID,
|
|
190
|
+
* apiToken: process.env.CF_API_TOKEN,
|
|
191
|
+
* from: "App <noreply@app.com>",
|
|
192
|
+
* });
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
api(opts) {
|
|
196
|
+
return build(createApiTransport(opts), opts);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
export {
|
|
200
|
+
cloudflareEmail
|
|
201
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "better-auth-cloudflare-email",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Send emails through Cloudflare Email Service from Better Auth — Workers binding and REST API transports",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Paul Stenhouse",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/paulstenhouse/better-auth-cloudflare-email-plugin"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"better-auth",
|
|
13
|
+
"cloudflare",
|
|
14
|
+
"email",
|
|
15
|
+
"cloudflare-workers",
|
|
16
|
+
"cloudflare-email",
|
|
17
|
+
"authentication",
|
|
18
|
+
"transactional-email"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js",
|
|
25
|
+
"require": "./dist/index.cjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"main": "./dist/index.cjs",
|
|
29
|
+
"module": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup",
|
|
38
|
+
"prepublishOnly": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"tsup": "^8.0.0",
|
|
42
|
+
"typescript": "^5.0.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"better-auth": ">=1.0.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"better-auth": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|