@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 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.
@@ -0,0 +1,5 @@
1
+ // Auto-generated by emdash-plugin build. Do not edit.
2
+ import type { PluginDescriptor } from "@premium-cms/emdash";
3
+
4
+ declare const descriptor: PluginDescriptor;
5
+ export default descriptor;
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
+ }
@@ -0,0 +1,7 @@
1
+ import { SandboxedPlugin } from "@premium-cms/emdash/plugin";
2
+
3
+ //#region src/plugin.d.ts
4
+ declare const plugin: SandboxedPlugin;
5
+ //#endregion
6
+ export { plugin as default };
7
+ //# sourceMappingURL=plugin.d.mts.map
@@ -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
+ }