@premium-cms/plugin-cloudflare-email-byo 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 +84 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +30 -0
- package/dist/manifest.json +47 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.mjs +4 -0
- package/emdash-plugin.jsonc +33 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# cloudflare-email-byo
|
|
2
|
+
|
|
3
|
+
**Email from the site owner's own Cloudflare account.**
|
|
4
|
+
|
|
5
|
+
EmDash's built-in `cloudflareEmail` provider sends through the Worker's
|
|
6
|
+
`send_email` binding — which belongs to whichever Cloudflare account deployed
|
|
7
|
+
the Worker. On a fleet that means every project's mail leaves from the
|
|
8
|
+
platform's account, on the platform's sending domain and reputation, against
|
|
9
|
+
the platform's quota.
|
|
10
|
+
|
|
11
|
+
This provider calls the Cloudflare Email Sending **REST API** with an API token
|
|
12
|
+
the owner enters in the admin, so mail leaves from _their_ account and _their_
|
|
13
|
+
verified domain. The platform never holds the credential.
|
|
14
|
+
|
|
15
|
+
## Setup (for the site owner)
|
|
16
|
+
|
|
17
|
+
1. In your own Cloudflare dashboard, onboard a domain for **Email Sending**.
|
|
18
|
+
2. Create an **API token** with email sending permission.
|
|
19
|
+
3. Open the plugin's **Email (own Cloudflare)** settings page and fill in the
|
|
20
|
+
account ID, token, and sender address.
|
|
21
|
+
4. Click **Send test email**.
|
|
22
|
+
5. Under **Settings → Email**, select `cloudflare-email-byo` as the provider.
|
|
23
|
+
|
|
24
|
+
Step 5 matters: EmDash auto-selects a provider only when exactly one is active.
|
|
25
|
+
With both this and the built-in provider installed, the choice must be explicit.
|
|
26
|
+
|
|
27
|
+
## Settings
|
|
28
|
+
|
|
29
|
+
| Field | Notes |
|
|
30
|
+
| ----------- | ------------------------------------------------------------------------ |
|
|
31
|
+
| Account ID | 32 hex characters; validated before any request is made |
|
|
32
|
+
| API token | Stored in plugin KV, never returned by any route or rendered into a page |
|
|
33
|
+
| Send from | Must be on a domain onboarded for Email Sending in _your_ account |
|
|
34
|
+
| Sender name | Optional |
|
|
35
|
+
| Reply-To | Optional |
|
|
36
|
+
|
|
37
|
+
Leaving the token field blank on save **keeps** the stored token — Block Kit
|
|
38
|
+
submits an empty string for an untouched secret field, so writing it blindly
|
|
39
|
+
would wipe a working credential on every unrelated edit. Type `clear` to remove it.
|
|
40
|
+
|
|
41
|
+
## Trust contract
|
|
42
|
+
|
|
43
|
+
```jsonc
|
|
44
|
+
"capabilities": ["hooks.email-transport:register", "network:request"],
|
|
45
|
+
"allowedHosts": ["api.cloudflare.com"]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`hooks.email-transport:register` is what permits the exclusive `email:deliver`
|
|
49
|
+
hook — **without it the hook is silently skipped at registration** and the
|
|
50
|
+
plugin appears to do nothing at all.
|
|
51
|
+
|
|
52
|
+
`network:request` is the host-restricted form (not `:unrestricted`), pinned to
|
|
53
|
+
`api.cloudflare.com` via `allowedHosts`. The owner's API token is the most
|
|
54
|
+
sensitive thing here; restricting egress to the single host that token is valid
|
|
55
|
+
for means a bug or a bad setting cannot post it anywhere else.
|
|
56
|
+
|
|
57
|
+
## The REST API is not the Workers binding
|
|
58
|
+
|
|
59
|
+
Field names differ, and getting them wrong produces a 400 that reads like a
|
|
60
|
+
domain-verification problem:
|
|
61
|
+
|
|
62
|
+
| Workers binding | REST API |
|
|
63
|
+
| ----------------------- | ---------------------------------------------------- |
|
|
64
|
+
| `from: { email, name }` | `from: { address, name }` |
|
|
65
|
+
| `replyTo` | `reply_to` |
|
|
66
|
+
| returns `messageId` | returns `delivered` / `permanent_bounces` / `queued` |
|
|
67
|
+
|
|
68
|
+
A permanent bounce comes back on **HTTP 200** with the recipient listed in
|
|
69
|
+
`permanent_bounces`, so a naive status check reports success on a message that
|
|
70
|
+
was never delivered. This plugin treats that as a failure.
|
|
71
|
+
|
|
72
|
+
## Working on it
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
bun install # from the repo root
|
|
76
|
+
npx vitest run # 19 tests, no CMS and no network
|
|
77
|
+
npx tsc --noEmit
|
|
78
|
+
npx emdash-plugin build
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The tests assert the REST contract itself — endpoint, auth header, `address`
|
|
82
|
+
vs `email`, `reply_to` casing — plus that the token and message bodies never
|
|
83
|
+
reach the logs. Message text carries magic-link and invite tokens, so it must
|
|
84
|
+
never be logged.
|
package/dist/index.d.mts
ADDED
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Auto-generated by emdash-plugin build. Do not edit.
|
|
2
|
+
// Source: emdash-plugin.jsonc + package.json
|
|
3
|
+
//
|
|
4
|
+
// Default-exports a sandboxed plugin descriptor. Pass it directly into
|
|
5
|
+
// emdash's `plugins:` or `sandboxed:` array — no factory call needed.
|
|
6
|
+
|
|
7
|
+
/** @type {import("@premium-cms/emdash").PluginDescriptor} */
|
|
8
|
+
const descriptor = Object.freeze({
|
|
9
|
+
"id": "cloudflare-email-byo",
|
|
10
|
+
"version": "0.1.0",
|
|
11
|
+
"format": "standard",
|
|
12
|
+
"entrypoint": "@premium-cms/plugin-cloudflare-email-byo/sandbox",
|
|
13
|
+
"capabilities": [
|
|
14
|
+
"hooks.email-transport:register",
|
|
15
|
+
"network:request"
|
|
16
|
+
],
|
|
17
|
+
"allowedHosts": [
|
|
18
|
+
"api.cloudflare.com"
|
|
19
|
+
],
|
|
20
|
+
"storage": {},
|
|
21
|
+
"adminPages": [
|
|
22
|
+
{
|
|
23
|
+
"path": "/settings",
|
|
24
|
+
"label": "Email (own Cloudflare)",
|
|
25
|
+
"icon": "mail"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export default descriptor;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "cloudflare-email-byo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"declaredAccess": {
|
|
5
|
+
"network": {
|
|
6
|
+
"request": {
|
|
7
|
+
"allowedHosts": [
|
|
8
|
+
"api.cloudflare.com"
|
|
9
|
+
]
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"email": {
|
|
13
|
+
"transport": {}
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"capabilities": [
|
|
17
|
+
"hooks.email-transport:register",
|
|
18
|
+
"network:request"
|
|
19
|
+
],
|
|
20
|
+
"allowedHosts": [
|
|
21
|
+
"api.cloudflare.com"
|
|
22
|
+
],
|
|
23
|
+
"storage": {},
|
|
24
|
+
"hooks": [
|
|
25
|
+
"plugin:install",
|
|
26
|
+
{
|
|
27
|
+
"name": "email:deliver",
|
|
28
|
+
"exclusive": true
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"routes": [
|
|
32
|
+
"admin",
|
|
33
|
+
"settings",
|
|
34
|
+
"settings/save",
|
|
35
|
+
"test"
|
|
36
|
+
],
|
|
37
|
+
"admin": {
|
|
38
|
+
"pages": [
|
|
39
|
+
{
|
|
40
|
+
"path": "/settings",
|
|
41
|
+
"label": "Email (own Cloudflare)",
|
|
42
|
+
"icon": "mail"
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"widgets": []
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/plugin.mjs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
const e=`settings:`,t=/^[0-9a-f]{32}$/;function n(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function r(e,t=``){return typeof e==`string`?e:t}function i(e){let t=e.indexOf(`@`);return t>0&&t<e.length-1&&!/\s/.test(e)}async function a(t){let n={accountId:``,fromAddress:``,fromName:``,replyTo:``,apiToken:``};try{let n=await t.kv.list(e),i={};for(let t of n)i[t.key.replace(e,``)]=t.value;return{accountId:r(i.accountId).trim(),fromAddress:r(i.fromAddress).trim(),fromName:r(i.fromName).trim(),replyTo:r(i.replyTo).trim(),apiToken:r(i.apiToken)}}catch(e){return t.log.error(`Failed to read settings`,e),n}}function o(e){let n=[];return e.accountId?t.test(e.accountId)||n.push(`a valid account ID (32 hex characters)`):n.push(`account ID`),e.apiToken||n.push(`API token`),e.fromAddress?i(e.fromAddress)||n.push(`a valid sender address`):n.push(`sender address`),n.length===0?{ok:!0}:{ok:!1,missing:n}}function s(e){return e?e.length<=8?`••••••••`:`••••••••${e.slice(-4)}`:``}function c(e,t){if(e===200&&t?.success!==!1){let e=t?.result?.permanent_bounces??[];return e.length>0?`permanently bounced: ${e.join(`, `)}`:null}let n=(t?.errors??[]).map(e=>e.code?`${e.code}: ${e.message??``}`:e.message??``).filter(Boolean).join(`; `)||`HTTP ${e}`;switch(e){case 401:case 403:return`authentication rejected (${n}) — check the API token has email sending permission`;case 400:return`request rejected (${n}) — check the sender domain is onboarded for Email Sending`;case 429:return`rate limited (${n}) — retryable`;default:return e>=500?`Cloudflare error (${n}) — retryable`:n}}async function l(e,t,n){if(!e.http)throw Error("[cloudflare-email-byo] ctx.http is unavailable — the plugin needs the `network:request` capability.");let r={to:n.to,from:t.fromName?{address:t.fromAddress,name:t.fromName}:{address:t.fromAddress},subject:n.subject,text:n.text};n.html&&(r.html=n.html),t.replyTo&&(r.reply_to=t.replyTo);let i=await e.http.fetch(`https://api.cloudflare.com/client/v4/accounts/${t.accountId}/email/sending/send`,{method:`POST`,headers:{Authorization:`Bearer ${t.apiToken}`,"Content-Type":`application/json`},body:JSON.stringify(r)}),a=null;try{a=await i.json()}catch{}let o=c(i.status,a);if(o)throw Error(`[cloudflare-email-byo] send failed for ${n.to} — ${o}`);e.log.info(`email delivered via the site's own Cloudflare account`,{to:n.to,subject:n.subject,accountId:t.accountId})}const u={hooks:{"plugin:install":async(e,t)=>{t.log.info(`Cloudflare Email (own account) installed — add credentials on its settings page, then select it under Settings → Email`)},"email:deliver":{exclusive:!0,handler:async(e,t)=>{let n=await a(t),r=o(n);if(!r.ok)throw Error(`[cloudflare-email-byo] not configured — missing ${r.missing.join(`, `)}. Add them on the plugin's settings page.`);await l(t,n,e.message)}}},routes:{admin:{handler:async(e,t)=>{let n=e.input;return n.type===`page_load`&&n.page===`/settings`?p(t):n.type===`form_submit`&&n.action_id===`save_settings`?p(t,await d(t,n.values??{})):n.type===`block_action`&&n.action_id===`send_test`?p(t,await f(t)):{blocks:[]}}},settings:{handler:async(e,t)=>{let n=await a(t),r=o(n);return{accountId:n.accountId,fromAddress:n.fromAddress,fromName:n.fromName,replyTo:n.replyTo,apiToken:s(n.apiToken??``),configured:r.ok,missing:r.ok?[]:r.missing}}},"settings/save":{handler:async(e,t)=>{try{return{success:!0,note:await d(t,n(e.input)?e.input:{}),configured:o(await a(t)).ok}}catch(e){return t.log.error(`Failed to save settings`,e),{success:!1,error:String(e)}}}},test:{handler:async(e,t)=>({message:await f(t)})}}};async function d(t,n){if(typeof n.accountId==`string`&&await t.kv.set(`${e}accountId`,n.accountId.trim().toLowerCase()),typeof n.fromAddress==`string`&&await t.kv.set(`${e}fromAddress`,n.fromAddress.trim()),typeof n.fromName==`string`&&await t.kv.set(`${e}fromName`,n.fromName.trim()),typeof n.replyTo==`string`&&await t.kv.set(`${e}replyTo`,n.replyTo.trim()),typeof n.apiToken==`string`){let r=n.apiToken.trim();if(r===`clear`)return await t.kv.set(`${e}apiToken`,``),`API token cleared.`;if(r)return await t.kv.set(`${e}apiToken`,r),`API token updated.`}}async function f(e){let t=await a(e),n=o(t);if(!n.ok)return`Cannot send a test — missing ${n.missing.join(`, `)}.`;try{return await l(e,t,{to:t.fromAddress,subject:`Test email from your site`,text:`This is a test from the Cloudflare Email (own account) provider.
|
|
2
|
+
|
|
3
|
+
If you received it, transactional email is sending through your own Cloudflare account.`}),`Test sent to ${t.fromAddress}.`}catch(e){return String(e instanceof Error?e.message:e)}}async function p(e,t){try{let n=await a(e),r=o(n),i=!!n.apiToken,c=[{type:`header`,text:`Email from your own Cloudflare account`},{type:`context`,text:`Transactional email (sign-in links, invites, notifications) is sent through your Cloudflare account instead of the platform's.`},{type:`divider`},{type:`section`,text:r.ok?`Ready — sending as ${n.fromAddress} from account ${n.accountId}.`:`Not sending yet — missing ${r.missing.join(`, `)}.`}];return t&&c.push({type:`context`,text:t}),c.push({type:`divider`},{type:`form`,block_id:`cloudflare-email-byo-settings`,fields:[{type:`text_input`,action_id:`accountId`,label:`Cloudflare account ID`,placeholder:`32 hex characters, from the dashboard sidebar`,initial_value:n.accountId},{type:`secret_input`,action_id:`apiToken`,label:i?`API token (stored: ${s(n.apiToken??``)} — leave blank to keep, type "clear" to remove)`:`API token (needs email sending permission)`},{type:`text_input`,action_id:`fromAddress`,label:`Send from`,placeholder:`cms@mail.yourdomain.com`,initial_value:n.fromAddress},{type:`text_input`,action_id:`fromName`,label:`Sender name`,placeholder:`Your Site`,initial_value:n.fromName},{type:`text_input`,action_id:`replyTo`,label:`Reply-To (optional)`,initial_value:n.replyTo}],submit:{label:`Save`,action_id:`save_settings`}},{type:`actions`,elements:[{type:`button`,text:`Send test email`,action_id:`send_test`,style:`primary`}]},{type:`divider`},{type:`context`,text:`The sending domain must be onboarded for Email Sending in your own Cloudflare account, and the token needs email sending permission. Once saved, choose this provider under Settings → Email.`}),{blocks:c}}catch(t){return e.log.error(`Failed to build settings page`,t),{blocks:[{type:`context`,text:`Failed to load email settings.`}]}}}export{u as default};
|
|
4
|
+
//# sourceMappingURL=plugin.mjs.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",
|
|
3
|
+
|
|
4
|
+
"slug": "cloudflare-email-byo",
|
|
5
|
+
// See plugins/premium-starter — must resolve for real before `publish`.
|
|
6
|
+
"publisher": "plugins.premium-cms.com",
|
|
7
|
+
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"author": { "name": "PremiumCMS" },
|
|
10
|
+
"security": {
|
|
11
|
+
"url": "https://github.com/MahmoodKhalil57/premium-cms-plugins/security/advisories/new",
|
|
12
|
+
},
|
|
13
|
+
"description": "Send transactional email through the site owner's own Cloudflare account, using an API token entered in the admin.",
|
|
14
|
+
"keywords": ["premiumcms", "email", "cloudflare", "transactional", "byo-credentials"],
|
|
15
|
+
|
|
16
|
+
// ── Trust contract ────────────────────────────────────────────────
|
|
17
|
+
// `hooks.email-transport:register` is what allows registering the
|
|
18
|
+
// exclusive `email:deliver` hook — without it the hook is silently
|
|
19
|
+
// skipped at registration and the plugin appears to do nothing.
|
|
20
|
+
//
|
|
21
|
+
// `network:request` (rather than `network:request:unrestricted`)
|
|
22
|
+
// restricts ctx.http to `allowedHosts`. The owner's Cloudflare API
|
|
23
|
+
// token is the most sensitive value this plugin holds, so egress is
|
|
24
|
+
// pinned to the one host that token is valid for: a bug or a bad
|
|
25
|
+
// settings value cannot post it anywhere else.
|
|
26
|
+
"capabilities": ["hooks.email-transport:register", "network:request"],
|
|
27
|
+
"allowedHosts": ["api.cloudflare.com"],
|
|
28
|
+
"storage": {},
|
|
29
|
+
|
|
30
|
+
"admin": {
|
|
31
|
+
"pages": [{ "path": "/settings", "label": "Email (own Cloudflare)", "icon": "mail" }],
|
|
32
|
+
},
|
|
33
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@premium-cms/plugin-cloudflare-email-byo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Send email through the site owner's own Cloudflare account",
|
|
6
|
+
"main": "dist/index.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.mjs",
|
|
10
|
+
"types": "./dist/index.d.mts"
|
|
11
|
+
},
|
|
12
|
+
"./sandbox": "./dist/plugin.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"emdash-plugin.jsonc"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "emdash-plugin build",
|
|
20
|
+
"dev": "emdash-plugin dev",
|
|
21
|
+
"bundle": "emdash-plugin bundle",
|
|
22
|
+
"validate": "emdash-plugin validate .",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@premium-cms/emdash": ">=0.35.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@premium-cms/plugin-cli": "^0.8.1",
|
|
31
|
+
"@premium-cms/plugin-types": "^0.3.0",
|
|
32
|
+
"@premium-cms/emdash": "^0.35.0",
|
|
33
|
+
"typescript": "^6.0.3",
|
|
34
|
+
"vitest": "^4.1.5"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|