@mccaskie-labs/workbench-config 0.1.8 → 0.1.9
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/CHANGELOG.md +6 -0
- package/capabilities/email-sending.json +38 -0
- package/capabilities/email-sending.md +9 -0
- package/capabilities/email.json +61 -0
- package/capabilities/email.md +23 -0
- package/dist/capabilities.gen.d.ts +310 -1
- package/dist/capabilities.gen.js +391 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @mccaskie-labs/workbench-config
|
|
2
2
|
|
|
3
|
+
## 0.1.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Ship the email and email-sending manifests (step 4.9): `capabilities.email` (POST /v1/email/send and workbench-email) and `capabilities["email-sending"]` (cf_email_sending_onboard and cf_email_sending_status), both built.
|
|
8
|
+
|
|
3
9
|
## 0.1.8
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./schema.json",
|
|
3
|
+
"format": 1,
|
|
4
|
+
"component": "email-sending",
|
|
5
|
+
"title": "Sending domains and sender addresses",
|
|
6
|
+
"summary": "Control onboards a domain in the project's namespace to Cloudflare Email Sending in the projects account, with its bounce, SPF, DKIM and DMARC records checked over DNS-over-HTTPS, and allows sender addresses on it. The Gateway sends from those addresses only.",
|
|
7
|
+
"status": "built",
|
|
8
|
+
"surfaces": {
|
|
9
|
+
"tools": ["cf_email_sending_onboard", "cf_email_sending_status", "gw_sender_add", "gw_sender_list"]
|
|
10
|
+
},
|
|
11
|
+
"limits": [
|
|
12
|
+
{ "id": "email-sending.domains-per-zone", "value": 30, "unit": "domains", "per": "zone, Email Sending and Email Routing together", "enforced_by": "control" }
|
|
13
|
+
],
|
|
14
|
+
"actions": [
|
|
15
|
+
{ "tool": "cf_email_sending_onboard", "gate": "free", "ships": "4" },
|
|
16
|
+
{ "tool": "cf_email_sending_status", "gate": "free", "ships": "4" },
|
|
17
|
+
{ "tool": "gw_sender_add", "gate": "free", "ships": "4" },
|
|
18
|
+
{ "tool": "gw_sender_list", "gate": "free", "ships": "4" }
|
|
19
|
+
],
|
|
20
|
+
"errors": [
|
|
21
|
+
{ "code": "out_of_scope", "http": 403, "retryable": false, "meaning": "A domain outside the project's namespace, or a sender on a domain the project hasn't onboarded" },
|
|
22
|
+
{ "code": "name_taken", "http": 409, "retryable": false, "meaning": "The domain or address is already onboarded or allowed outside this project; Control never adopts" },
|
|
23
|
+
{ "code": "record_conflict", "http": 409, "retryable": false, "meaning": "A DNS record in the way that Control won't change (another SPF, DMARC, MX or CNAME)" },
|
|
24
|
+
{ "code": "missing_permission", "http": 403, "retryable": false, "meaning": "Control's Cloudflare token lacks Email Sending (edit)" }
|
|
25
|
+
],
|
|
26
|
+
"rules": [
|
|
27
|
+
{ "id": "email-sending.namespace-only", "kind": "must", "text": "Onboard a domain in the project's own namespace, for example mail.<id>.mccaskielabs.app. Project-owned zones such as wbprobe.com follow cf_zone_attach (step 6.1).", "enforced_by": "control", "probe": "email-sending.onboard" },
|
|
28
|
+
{ "id": "email-sending.onboard-before-sender", "kind": "must", "text": "Onboard the domain before adding senders on it. gw_sender_add refuses a domain the project hasn't onboarded.", "enforced_by": "control", "probe": "email-sending.sender-add" },
|
|
29
|
+
{ "id": "email-sending.never-adopt", "kind": "must_not", "text": "Expect Control to take over a domain onboarded by hand. It refuses with name_taken.", "enforced_by": "control", "probe": "email-sending.onboard" },
|
|
30
|
+
{ "id": "email-sending.verified-over-doh", "kind": "must", "text": "Treat a domain as ready only when cf_email_sending_status says ready: every expected record is in the zone and visible over DNS-over-HTTPS.", "enforced_by": "control", "probe": "email-sending.status" }
|
|
31
|
+
],
|
|
32
|
+
"probes": [
|
|
33
|
+
{ "id": "email-sending.onboard", "half": "drill", "expect": "pass", "cost_hint_usd": 0, "note": "cf_email_sending_onboard on mail.<id>.mccaskielabs.app; a second call changes nothing" },
|
|
34
|
+
{ "id": "email-sending.status", "half": "drill", "expect": "pass", "cost_hint_usd": 0 },
|
|
35
|
+
{ "id": "email-sending.sender-add", "half": "drill", "expect": "pass", "cost_hint_usd": 0, "note": "Then a runtime send from the new address (email.send.transactional)" },
|
|
36
|
+
{ "id": "email-sending.out-of-scope", "half": "drill", "expect": "refused_as_expected", "refusal": "out_of_scope", "cost_hint_usd": 0, "note": "Another project's namespace" }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Email sending domains
|
|
2
|
+
|
|
3
|
+
A project sends from addresses on a domain Control onboarded for it. Today that is a domain in the project's namespace in `mccaskielabs.app`, usually `mail.<id>.mccaskielabs.app`. Project-owned zones such as `wbprobe.com` follow `cf_zone_attach` in step 6.1.
|
|
4
|
+
|
|
5
|
+
1. `cf_email_sending_onboard domain=mail.<id>.mccaskielabs.app` creates the Email Sending subdomain in the projects account, adds the records Cloudflare expects (the `cf-bounce` MX and SPF, and DKIM) and the domain's own DMARC record (`p=reject; adkim=s; aspf=r`), records them, and checks each over DNS-over-HTTPS. A record in the way stops it; it changes nothing it didn't make.
|
|
6
|
+
2. `cf_email_sending_status` says `ready` once every record is visible. Records can take a few minutes.
|
|
7
|
+
3. `gw_sender_add address=hello@mail.<id>.mccaskielabs.app` allows the address. The Gateway accepts it within a minute.
|
|
8
|
+
|
|
9
|
+
The DNS records are ordinary `dns_record` rows for the project, so removing one is `cf_dns_delete` (two-step).
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./schema.json",
|
|
3
|
+
"format": 1,
|
|
4
|
+
"component": "email",
|
|
5
|
+
"title": "Transactional email through the Gateway",
|
|
6
|
+
"summary": "Apps send transactional mail with workbench-email, or POST /v1/email/send directly. The Gateway checks the sender and recipients, keeps idempotency keys for 24 hours, meters every send, and hands the message to Cloudflare Email Sending. Apps hold no email credential.",
|
|
7
|
+
"status": "built",
|
|
8
|
+
"surfaces": {
|
|
9
|
+
"gateway": ["POST /v1/email/send"],
|
|
10
|
+
"package": ["createMailer", "selectTransport", "Mailer.send", "captureSink", "WorkbenchEmailError"]
|
|
11
|
+
},
|
|
12
|
+
"config": [
|
|
13
|
+
{ "name": "WORKBENCH_GATEWAY_KEY", "required": true, "secret": true, "environments": ["staging", "production"], "description": "Project key with the email scope, delivered as a Worker secret by gw_key_issue" },
|
|
14
|
+
{ "name": "WORKBENCH_ENV", "required": true, "secret": false, "environments": ["development", "staging", "production"], "description": "Chooses the transport: capture in development, the Gateway elsewhere" },
|
|
15
|
+
{ "name": "WORKBENCH_EMAIL_LIVE", "required": false, "secret": false, "default": "0", "environments": ["development"], "description": "1 sends real mail from development through the Gateway (a development key can only mail Robert)" }
|
|
16
|
+
],
|
|
17
|
+
"limits": [
|
|
18
|
+
{ "id": "email.recipients", "value": 50, "unit": "recipients", "per": "message (to, cc and bcc together)", "enforced_by": "gateway", "refusal": "invalid_request" },
|
|
19
|
+
{ "id": "email.attachments", "value": 32, "unit": "attachments", "per": "message", "enforced_by": "gateway", "refusal": "invalid_request" },
|
|
20
|
+
{ "id": "email.message-size", "value": 26214400, "unit": "bytes", "per": "message, attachments as base64", "enforced_by": "gateway", "refusal": "invalid_request" },
|
|
21
|
+
{ "id": "email.subject-length", "value": 998, "unit": "characters", "per": "message", "enforced_by": "gateway", "refusal": "invalid_request" },
|
|
22
|
+
{ "id": "email.idempotency-window", "value": 24, "unit": "hours", "per": "Idempotency-Key", "enforced_by": "database" },
|
|
23
|
+
{ "id": "email.upstream-timeout", "value": 30, "unit": "seconds", "per": "request", "enforced_by": "gateway", "refusal": "upstream_timeout" }
|
|
24
|
+
],
|
|
25
|
+
"errors": [
|
|
26
|
+
{ "code": "sender_not_allowed", "http": 403, "retryable": false, "meaning": "from isn't one of the project's senders (gw_sender_add)" },
|
|
27
|
+
{ "code": "recipient_not_allowed", "http": 403, "retryable": false, "meaning": "A development key sending to anyone but Workbench's allowlisted addresses" },
|
|
28
|
+
{ "code": "invalid_request", "http": 400, "retryable": false, "meaning": "A malformed message, a limit passed, or an Idempotency-Key reused with a different message (422)" },
|
|
29
|
+
{ "code": "not_available", "http": 501, "retryable": false, "meaning": "type bulk, until Amazon SES is built" },
|
|
30
|
+
{ "code": "budget_exceeded", "http": 402, "retryable": false, "meaning": "Enforced monthly budget reached" },
|
|
31
|
+
{ "code": "rate_limited", "http": 429, "retryable": true, "meaning": "Too many requests, Cloudflare's quota, or (409) a send with the same Idempotency-Key still in progress" },
|
|
32
|
+
{ "code": "upstream_error", "http": 502, "retryable": true, "meaning": "Cloudflare Email Sending failed; nothing was sent" },
|
|
33
|
+
{ "code": "upstream_timeout", "http": 504, "retryable": true, "meaning": "Cloudflare didn't answer within 30 seconds; the mail may or may not have gone" }
|
|
34
|
+
],
|
|
35
|
+
"rules": [
|
|
36
|
+
{ "id": "email.text-always", "kind": "must", "text": "Send a text part with every mail, even when there is HTML.", "enforced_by": "gateway", "probe": "email.send.transactional" },
|
|
37
|
+
{ "id": "email.idempotency-key", "kind": "must", "text": "Give an idempotencyKey to any send a retry could repeat (invites, receipts, resets). A repeat within 24 hours returns the first result and sends nothing.", "enforced_by": "review", "probe": "email.send.idempotent-repeat" },
|
|
38
|
+
{ "id": "email.attachments-as-bytes", "kind": "must", "text": "Pass attachments as bytes (Uint8Array), and give an inline image a contentId that the HTML references as cid:.", "enforced_by": "types", "probe": "email.send.inline-image" },
|
|
39
|
+
{ "id": "email.allowed-sender", "kind": "must", "text": "Send only from an address added with gw_sender_add on a domain onboarded with cf_email_sending_onboard.", "enforced_by": "gateway", "probe": "email.send.sender-not-allowed" },
|
|
40
|
+
{ "id": "email.no-capture-outside-dev", "kind": "must_not", "text": "Use the capture transport outside development. createMailer throws when WORKBENCH_ENV is staging or production.", "enforced_by": "types", "probe": "email.capture-refused-in-staging" },
|
|
41
|
+
{ "id": "email.dev-key-allowlist", "kind": "must_not", "text": "Expect a development key to mail anyone but Robert. The Gateway refuses other recipients before the budget.", "enforced_by": "gateway", "probe": "email.send.dev-recipient-refused" },
|
|
42
|
+
{ "id": "email.transactional-only", "kind": "must_not", "text": "Send bulk or marketing mail. type bulk is refused with not_available until Amazon SES is built.", "enforced_by": "gateway", "probe": "email.send.transactional" }
|
|
43
|
+
],
|
|
44
|
+
"examples": [
|
|
45
|
+
{
|
|
46
|
+
"id": "email.example.send",
|
|
47
|
+
"title": "Send a transactional mail with an inline logo",
|
|
48
|
+
"lang": "ts",
|
|
49
|
+
"probe": "email.send.inline-image",
|
|
50
|
+
"code": "const env = workbenchEnv(process.env)\nconst mailer = createMailer({ env, transport: selectTransport(env), from: { address: 'hello@mail.example.com', name: 'Example' }, gateway: { key: process.env.WORKBENCH_GATEWAY_KEY! } })\nawait mailer.send({ to: user.email, subject: 'Welcome', text, html: '<img src=\"cid:logo\"> ...', template: 'welcome', idempotencyKey: `welcome:${user.id}`, attachments: [{ filename: 'logo.png', mimeType: 'image/png', bytes: logo, contentId: 'logo' }] })"
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"probes": [
|
|
54
|
+
{ "id": "email.send.transactional", "half": "runtime", "expect": "pass", "smoke": true, "cost_hint_usd": 0.00035, "note": "To Robert; check that it arrives" },
|
|
55
|
+
{ "id": "email.send.inline-image", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.00035, "note": "Robert checks the image renders" },
|
|
56
|
+
{ "id": "email.send.idempotent-repeat", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.00035, "note": "The repeat answers replayed: true and no second mail arrives" },
|
|
57
|
+
{ "id": "email.send.dev-recipient-refused", "half": "runtime", "expect": "refused_as_expected", "refusal": "recipient_not_allowed", "cost_hint_usd": 0 },
|
|
58
|
+
{ "id": "email.send.sender-not-allowed", "half": "runtime", "expect": "refused_as_expected", "refusal": "sender_not_allowed", "cost_hint_usd": 0 },
|
|
59
|
+
{ "id": "email.capture-refused-in-staging", "half": "runtime", "expect": "pass", "cost_hint_usd": 0, "note": "createMailer with transport capture and WORKBENCH_ENV=staging throws invalid_config" }
|
|
60
|
+
]
|
|
61
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Email
|
|
2
|
+
|
|
3
|
+
Apps send transactional mail with `createMailer` from `@mccaskie-labs/workbench-email`, or `POST /v1/email/send` directly. The Gateway holds the sending credential; the app holds only its Gateway key, which needs the `email` scope.
|
|
4
|
+
|
|
5
|
+
## Transports
|
|
6
|
+
|
|
7
|
+
`selectTransport(env)` picks `capture` in development and `gateway` in staging and production. Captured mail is written to `.mail/` as `.eml` files (Node) or logged (workerd), and never sent. Set `WORKBENCH_EMAIL_LIVE=1` to send for real from development; a development key can still only mail Robert. Constructing the capture transport in staging or production throws.
|
|
8
|
+
|
|
9
|
+
## Senders
|
|
10
|
+
|
|
11
|
+
`from` must be an address added with `gw_sender_add`, on a domain onboarded to Cloudflare Email Sending with `cf_email_sending_onboard`. Anything else is refused with `sender_not_allowed` before a send.
|
|
12
|
+
|
|
13
|
+
## Idempotency
|
|
14
|
+
|
|
15
|
+
Send `idempotencyKey` for anything a retry could repeat. Within 24 hours the same key and message return the first result with `replayed: true`, and nothing is sent again. The same key with a different message is refused (422). A send that failed frees its key, so a retry with it goes out.
|
|
16
|
+
|
|
17
|
+
## Attachments
|
|
18
|
+
|
|
19
|
+
Bytes, not base64 strings. Give an inline image a `contentId` and reference it in the HTML as `cid:<id>`. At most 32 attachments and 25 MiB a message.
|
|
20
|
+
|
|
21
|
+
## What a send returns
|
|
22
|
+
|
|
23
|
+
`messageId`, `reachedTheInternet` (true only when Cloudflare took the mail), `requestId` (matches a usage row) and, from the Gateway, the recipients Cloudflare delivered, queued or bounced. Every error has `reachedTheInternet: false`. Each send is charged at US$0.35 per 1,000 recipients.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const MANIFEST_VERSION: "
|
|
1
|
+
export declare const MANIFEST_VERSION: "ffb5a8a16ad1";
|
|
2
2
|
export declare const capabilities: {
|
|
3
3
|
readonly ai: {
|
|
4
4
|
readonly $schema: "./schema.json";
|
|
@@ -410,6 +410,209 @@ export declare const capabilities: {
|
|
|
410
410
|
readonly note: "sb_query on production with a write returns a confirmation, not a result";
|
|
411
411
|
}];
|
|
412
412
|
};
|
|
413
|
+
readonly email: {
|
|
414
|
+
readonly $schema: "./schema.json";
|
|
415
|
+
readonly format: 1;
|
|
416
|
+
readonly component: "email";
|
|
417
|
+
readonly title: "Transactional email through the Gateway";
|
|
418
|
+
readonly summary: "Apps send transactional mail with workbench-email, or POST /v1/email/send directly. The Gateway checks the sender and recipients, keeps idempotency keys for 24 hours, meters every send, and hands the message to Cloudflare Email Sending. Apps hold no email credential.";
|
|
419
|
+
readonly status: "built";
|
|
420
|
+
readonly surfaces: {
|
|
421
|
+
readonly gateway: readonly ["POST /v1/email/send"];
|
|
422
|
+
readonly package: readonly ["createMailer", "selectTransport", "Mailer.send", "captureSink", "WorkbenchEmailError"];
|
|
423
|
+
};
|
|
424
|
+
readonly config: readonly [{
|
|
425
|
+
readonly name: "WORKBENCH_GATEWAY_KEY";
|
|
426
|
+
readonly required: true;
|
|
427
|
+
readonly secret: true;
|
|
428
|
+
readonly environments: readonly ["staging", "production"];
|
|
429
|
+
readonly description: "Project key with the email scope, delivered as a Worker secret by gw_key_issue";
|
|
430
|
+
}, {
|
|
431
|
+
readonly name: "WORKBENCH_ENV";
|
|
432
|
+
readonly required: true;
|
|
433
|
+
readonly secret: false;
|
|
434
|
+
readonly environments: readonly ["development", "staging", "production"];
|
|
435
|
+
readonly description: "Chooses the transport: capture in development, the Gateway elsewhere";
|
|
436
|
+
}, {
|
|
437
|
+
readonly name: "WORKBENCH_EMAIL_LIVE";
|
|
438
|
+
readonly required: false;
|
|
439
|
+
readonly secret: false;
|
|
440
|
+
readonly default: "0";
|
|
441
|
+
readonly environments: readonly ["development"];
|
|
442
|
+
readonly description: "1 sends real mail from development through the Gateway (a development key can only mail Robert)";
|
|
443
|
+
}];
|
|
444
|
+
readonly limits: readonly [{
|
|
445
|
+
readonly id: "email.recipients";
|
|
446
|
+
readonly value: 50;
|
|
447
|
+
readonly unit: "recipients";
|
|
448
|
+
readonly per: "message (to, cc and bcc together)";
|
|
449
|
+
readonly enforced_by: "gateway";
|
|
450
|
+
readonly refusal: "invalid_request";
|
|
451
|
+
}, {
|
|
452
|
+
readonly id: "email.attachments";
|
|
453
|
+
readonly value: 32;
|
|
454
|
+
readonly unit: "attachments";
|
|
455
|
+
readonly per: "message";
|
|
456
|
+
readonly enforced_by: "gateway";
|
|
457
|
+
readonly refusal: "invalid_request";
|
|
458
|
+
}, {
|
|
459
|
+
readonly id: "email.message-size";
|
|
460
|
+
readonly value: 26214400;
|
|
461
|
+
readonly unit: "bytes";
|
|
462
|
+
readonly per: "message, attachments as base64";
|
|
463
|
+
readonly enforced_by: "gateway";
|
|
464
|
+
readonly refusal: "invalid_request";
|
|
465
|
+
}, {
|
|
466
|
+
readonly id: "email.subject-length";
|
|
467
|
+
readonly value: 998;
|
|
468
|
+
readonly unit: "characters";
|
|
469
|
+
readonly per: "message";
|
|
470
|
+
readonly enforced_by: "gateway";
|
|
471
|
+
readonly refusal: "invalid_request";
|
|
472
|
+
}, {
|
|
473
|
+
readonly id: "email.idempotency-window";
|
|
474
|
+
readonly value: 24;
|
|
475
|
+
readonly unit: "hours";
|
|
476
|
+
readonly per: "Idempotency-Key";
|
|
477
|
+
readonly enforced_by: "database";
|
|
478
|
+
}, {
|
|
479
|
+
readonly id: "email.upstream-timeout";
|
|
480
|
+
readonly value: 30;
|
|
481
|
+
readonly unit: "seconds";
|
|
482
|
+
readonly per: "request";
|
|
483
|
+
readonly enforced_by: "gateway";
|
|
484
|
+
readonly refusal: "upstream_timeout";
|
|
485
|
+
}];
|
|
486
|
+
readonly errors: readonly [{
|
|
487
|
+
readonly code: "sender_not_allowed";
|
|
488
|
+
readonly http: 403;
|
|
489
|
+
readonly retryable: false;
|
|
490
|
+
readonly meaning: "from isn't one of the project's senders (gw_sender_add)";
|
|
491
|
+
}, {
|
|
492
|
+
readonly code: "recipient_not_allowed";
|
|
493
|
+
readonly http: 403;
|
|
494
|
+
readonly retryable: false;
|
|
495
|
+
readonly meaning: "A development key sending to anyone but Workbench's allowlisted addresses";
|
|
496
|
+
}, {
|
|
497
|
+
readonly code: "invalid_request";
|
|
498
|
+
readonly http: 400;
|
|
499
|
+
readonly retryable: false;
|
|
500
|
+
readonly meaning: "A malformed message, a limit passed, or an Idempotency-Key reused with a different message (422)";
|
|
501
|
+
}, {
|
|
502
|
+
readonly code: "not_available";
|
|
503
|
+
readonly http: 501;
|
|
504
|
+
readonly retryable: false;
|
|
505
|
+
readonly meaning: "type bulk, until Amazon SES is built";
|
|
506
|
+
}, {
|
|
507
|
+
readonly code: "budget_exceeded";
|
|
508
|
+
readonly http: 402;
|
|
509
|
+
readonly retryable: false;
|
|
510
|
+
readonly meaning: "Enforced monthly budget reached";
|
|
511
|
+
}, {
|
|
512
|
+
readonly code: "rate_limited";
|
|
513
|
+
readonly http: 429;
|
|
514
|
+
readonly retryable: true;
|
|
515
|
+
readonly meaning: "Too many requests, Cloudflare's quota, or (409) a send with the same Idempotency-Key still in progress";
|
|
516
|
+
}, {
|
|
517
|
+
readonly code: "upstream_error";
|
|
518
|
+
readonly http: 502;
|
|
519
|
+
readonly retryable: true;
|
|
520
|
+
readonly meaning: "Cloudflare Email Sending failed; nothing was sent";
|
|
521
|
+
}, {
|
|
522
|
+
readonly code: "upstream_timeout";
|
|
523
|
+
readonly http: 504;
|
|
524
|
+
readonly retryable: true;
|
|
525
|
+
readonly meaning: "Cloudflare didn't answer within 30 seconds; the mail may or may not have gone";
|
|
526
|
+
}];
|
|
527
|
+
readonly rules: readonly [{
|
|
528
|
+
readonly id: "email.text-always";
|
|
529
|
+
readonly kind: "must";
|
|
530
|
+
readonly text: "Send a text part with every mail, even when there is HTML.";
|
|
531
|
+
readonly enforced_by: "gateway";
|
|
532
|
+
readonly probe: "email.send.transactional";
|
|
533
|
+
}, {
|
|
534
|
+
readonly id: "email.idempotency-key";
|
|
535
|
+
readonly kind: "must";
|
|
536
|
+
readonly text: "Give an idempotencyKey to any send a retry could repeat (invites, receipts, resets). A repeat within 24 hours returns the first result and sends nothing.";
|
|
537
|
+
readonly enforced_by: "review";
|
|
538
|
+
readonly probe: "email.send.idempotent-repeat";
|
|
539
|
+
}, {
|
|
540
|
+
readonly id: "email.attachments-as-bytes";
|
|
541
|
+
readonly kind: "must";
|
|
542
|
+
readonly text: "Pass attachments as bytes (Uint8Array), and give an inline image a contentId that the HTML references as cid:.";
|
|
543
|
+
readonly enforced_by: "types";
|
|
544
|
+
readonly probe: "email.send.inline-image";
|
|
545
|
+
}, {
|
|
546
|
+
readonly id: "email.allowed-sender";
|
|
547
|
+
readonly kind: "must";
|
|
548
|
+
readonly text: "Send only from an address added with gw_sender_add on a domain onboarded with cf_email_sending_onboard.";
|
|
549
|
+
readonly enforced_by: "gateway";
|
|
550
|
+
readonly probe: "email.send.sender-not-allowed";
|
|
551
|
+
}, {
|
|
552
|
+
readonly id: "email.no-capture-outside-dev";
|
|
553
|
+
readonly kind: "must_not";
|
|
554
|
+
readonly text: "Use the capture transport outside development. createMailer throws when WORKBENCH_ENV is staging or production.";
|
|
555
|
+
readonly enforced_by: "types";
|
|
556
|
+
readonly probe: "email.capture-refused-in-staging";
|
|
557
|
+
}, {
|
|
558
|
+
readonly id: "email.dev-key-allowlist";
|
|
559
|
+
readonly kind: "must_not";
|
|
560
|
+
readonly text: "Expect a development key to mail anyone but Robert. The Gateway refuses other recipients before the budget.";
|
|
561
|
+
readonly enforced_by: "gateway";
|
|
562
|
+
readonly probe: "email.send.dev-recipient-refused";
|
|
563
|
+
}, {
|
|
564
|
+
readonly id: "email.transactional-only";
|
|
565
|
+
readonly kind: "must_not";
|
|
566
|
+
readonly text: "Send bulk or marketing mail. type bulk is refused with not_available until Amazon SES is built.";
|
|
567
|
+
readonly enforced_by: "gateway";
|
|
568
|
+
readonly probe: "email.send.transactional";
|
|
569
|
+
}];
|
|
570
|
+
readonly examples: readonly [{
|
|
571
|
+
readonly id: "email.example.send";
|
|
572
|
+
readonly title: "Send a transactional mail with an inline logo";
|
|
573
|
+
readonly lang: "ts";
|
|
574
|
+
readonly code: "const env = workbenchEnv(process.env)\nconst mailer = createMailer({ env, transport: selectTransport(env), from: { address: 'hello@mail.example.com', name: 'Example' }, gateway: { key: process.env.WORKBENCH_GATEWAY_KEY! } })\nawait mailer.send({ to: user.email, subject: 'Welcome', text, html: '<img src=\"cid:logo\"> ...', template: 'welcome', idempotencyKey: `welcome:${user.id}`, attachments: [{ filename: 'logo.png', mimeType: 'image/png', bytes: logo, contentId: 'logo' }] })";
|
|
575
|
+
readonly probe: "email.send.inline-image";
|
|
576
|
+
}];
|
|
577
|
+
readonly probes: readonly [{
|
|
578
|
+
readonly id: "email.send.transactional";
|
|
579
|
+
readonly half: "runtime";
|
|
580
|
+
readonly expect: "pass";
|
|
581
|
+
readonly smoke: true;
|
|
582
|
+
readonly cost_hint_usd: 0.00035;
|
|
583
|
+
readonly note: "To Robert; check that it arrives";
|
|
584
|
+
}, {
|
|
585
|
+
readonly id: "email.send.inline-image";
|
|
586
|
+
readonly half: "runtime";
|
|
587
|
+
readonly expect: "pass";
|
|
588
|
+
readonly cost_hint_usd: 0.00035;
|
|
589
|
+
readonly note: "Robert checks the image renders";
|
|
590
|
+
}, {
|
|
591
|
+
readonly id: "email.send.idempotent-repeat";
|
|
592
|
+
readonly half: "runtime";
|
|
593
|
+
readonly expect: "pass";
|
|
594
|
+
readonly cost_hint_usd: 0.00035;
|
|
595
|
+
readonly note: "The repeat answers replayed: true and no second mail arrives";
|
|
596
|
+
}, {
|
|
597
|
+
readonly id: "email.send.dev-recipient-refused";
|
|
598
|
+
readonly half: "runtime";
|
|
599
|
+
readonly expect: "refused_as_expected";
|
|
600
|
+
readonly refusal: "recipient_not_allowed";
|
|
601
|
+
readonly cost_hint_usd: 0;
|
|
602
|
+
}, {
|
|
603
|
+
readonly id: "email.send.sender-not-allowed";
|
|
604
|
+
readonly half: "runtime";
|
|
605
|
+
readonly expect: "refused_as_expected";
|
|
606
|
+
readonly refusal: "sender_not_allowed";
|
|
607
|
+
readonly cost_hint_usd: 0;
|
|
608
|
+
}, {
|
|
609
|
+
readonly id: "email.capture-refused-in-staging";
|
|
610
|
+
readonly half: "runtime";
|
|
611
|
+
readonly expect: "pass";
|
|
612
|
+
readonly cost_hint_usd: 0;
|
|
613
|
+
readonly note: "createMailer with transport capture and WORKBENCH_ENV=staging throws invalid_config";
|
|
614
|
+
}];
|
|
615
|
+
};
|
|
413
616
|
readonly "email-inbound": {
|
|
414
617
|
readonly $schema: "./schema.json";
|
|
415
618
|
readonly format: 1;
|
|
@@ -511,6 +714,112 @@ export declare const capabilities: {
|
|
|
511
714
|
readonly note: "cf_email_routing_rule_delete returns a confirmation and changes nothing until Robert approves";
|
|
512
715
|
}];
|
|
513
716
|
};
|
|
717
|
+
readonly "email-sending": {
|
|
718
|
+
readonly $schema: "./schema.json";
|
|
719
|
+
readonly format: 1;
|
|
720
|
+
readonly component: "email-sending";
|
|
721
|
+
readonly title: "Sending domains and sender addresses";
|
|
722
|
+
readonly summary: "Control onboards a domain in the project's namespace to Cloudflare Email Sending in the projects account, with its bounce, SPF, DKIM and DMARC records checked over DNS-over-HTTPS, and allows sender addresses on it. The Gateway sends from those addresses only.";
|
|
723
|
+
readonly status: "built";
|
|
724
|
+
readonly surfaces: {
|
|
725
|
+
readonly tools: readonly ["cf_email_sending_onboard", "cf_email_sending_status", "gw_sender_add", "gw_sender_list"];
|
|
726
|
+
};
|
|
727
|
+
readonly limits: readonly [{
|
|
728
|
+
readonly id: "email-sending.domains-per-zone";
|
|
729
|
+
readonly value: 30;
|
|
730
|
+
readonly unit: "domains";
|
|
731
|
+
readonly per: "zone, Email Sending and Email Routing together";
|
|
732
|
+
readonly enforced_by: "control";
|
|
733
|
+
}];
|
|
734
|
+
readonly actions: readonly [{
|
|
735
|
+
readonly tool: "cf_email_sending_onboard";
|
|
736
|
+
readonly gate: "free";
|
|
737
|
+
readonly ships: "4";
|
|
738
|
+
}, {
|
|
739
|
+
readonly tool: "cf_email_sending_status";
|
|
740
|
+
readonly gate: "free";
|
|
741
|
+
readonly ships: "4";
|
|
742
|
+
}, {
|
|
743
|
+
readonly tool: "gw_sender_add";
|
|
744
|
+
readonly gate: "free";
|
|
745
|
+
readonly ships: "4";
|
|
746
|
+
}, {
|
|
747
|
+
readonly tool: "gw_sender_list";
|
|
748
|
+
readonly gate: "free";
|
|
749
|
+
readonly ships: "4";
|
|
750
|
+
}];
|
|
751
|
+
readonly errors: readonly [{
|
|
752
|
+
readonly code: "out_of_scope";
|
|
753
|
+
readonly http: 403;
|
|
754
|
+
readonly retryable: false;
|
|
755
|
+
readonly meaning: "A domain outside the project's namespace, or a sender on a domain the project hasn't onboarded";
|
|
756
|
+
}, {
|
|
757
|
+
readonly code: "name_taken";
|
|
758
|
+
readonly http: 409;
|
|
759
|
+
readonly retryable: false;
|
|
760
|
+
readonly meaning: "The domain or address is already onboarded or allowed outside this project; Control never adopts";
|
|
761
|
+
}, {
|
|
762
|
+
readonly code: "record_conflict";
|
|
763
|
+
readonly http: 409;
|
|
764
|
+
readonly retryable: false;
|
|
765
|
+
readonly meaning: "A DNS record in the way that Control won't change (another SPF, DMARC, MX or CNAME)";
|
|
766
|
+
}, {
|
|
767
|
+
readonly code: "missing_permission";
|
|
768
|
+
readonly http: 403;
|
|
769
|
+
readonly retryable: false;
|
|
770
|
+
readonly meaning: "Control's Cloudflare token lacks Email Sending (edit)";
|
|
771
|
+
}];
|
|
772
|
+
readonly rules: readonly [{
|
|
773
|
+
readonly id: "email-sending.namespace-only";
|
|
774
|
+
readonly kind: "must";
|
|
775
|
+
readonly text: "Onboard a domain in the project's own namespace, for example mail.<id>.mccaskielabs.app. Project-owned zones such as wbprobe.com follow cf_zone_attach (step 6.1).";
|
|
776
|
+
readonly enforced_by: "control";
|
|
777
|
+
readonly probe: "email-sending.onboard";
|
|
778
|
+
}, {
|
|
779
|
+
readonly id: "email-sending.onboard-before-sender";
|
|
780
|
+
readonly kind: "must";
|
|
781
|
+
readonly text: "Onboard the domain before adding senders on it. gw_sender_add refuses a domain the project hasn't onboarded.";
|
|
782
|
+
readonly enforced_by: "control";
|
|
783
|
+
readonly probe: "email-sending.sender-add";
|
|
784
|
+
}, {
|
|
785
|
+
readonly id: "email-sending.never-adopt";
|
|
786
|
+
readonly kind: "must_not";
|
|
787
|
+
readonly text: "Expect Control to take over a domain onboarded by hand. It refuses with name_taken.";
|
|
788
|
+
readonly enforced_by: "control";
|
|
789
|
+
readonly probe: "email-sending.onboard";
|
|
790
|
+
}, {
|
|
791
|
+
readonly id: "email-sending.verified-over-doh";
|
|
792
|
+
readonly kind: "must";
|
|
793
|
+
readonly text: "Treat a domain as ready only when cf_email_sending_status says ready: every expected record is in the zone and visible over DNS-over-HTTPS.";
|
|
794
|
+
readonly enforced_by: "control";
|
|
795
|
+
readonly probe: "email-sending.status";
|
|
796
|
+
}];
|
|
797
|
+
readonly probes: readonly [{
|
|
798
|
+
readonly id: "email-sending.onboard";
|
|
799
|
+
readonly half: "drill";
|
|
800
|
+
readonly expect: "pass";
|
|
801
|
+
readonly cost_hint_usd: 0;
|
|
802
|
+
readonly note: "cf_email_sending_onboard on mail.<id>.mccaskielabs.app; a second call changes nothing";
|
|
803
|
+
}, {
|
|
804
|
+
readonly id: "email-sending.status";
|
|
805
|
+
readonly half: "drill";
|
|
806
|
+
readonly expect: "pass";
|
|
807
|
+
readonly cost_hint_usd: 0;
|
|
808
|
+
}, {
|
|
809
|
+
readonly id: "email-sending.sender-add";
|
|
810
|
+
readonly half: "drill";
|
|
811
|
+
readonly expect: "pass";
|
|
812
|
+
readonly cost_hint_usd: 0;
|
|
813
|
+
readonly note: "Then a runtime send from the new address (email.send.transactional)";
|
|
814
|
+
}, {
|
|
815
|
+
readonly id: "email-sending.out-of-scope";
|
|
816
|
+
readonly half: "drill";
|
|
817
|
+
readonly expect: "refused_as_expected";
|
|
818
|
+
readonly refusal: "out_of_scope";
|
|
819
|
+
readonly cost_hint_usd: 0;
|
|
820
|
+
readonly note: "Another project's namespace";
|
|
821
|
+
}];
|
|
822
|
+
};
|
|
514
823
|
readonly gateway: {
|
|
515
824
|
readonly $schema: "./schema.json";
|
|
516
825
|
readonly format: 1;
|
package/dist/capabilities.gen.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Generated by `npm run capabilities:build` from capabilities/*.json. Do not edit.
|
|
2
2
|
// check:capabilities fails in Checks when this file is stale.
|
|
3
|
-
export const MANIFEST_VERSION = "
|
|
3
|
+
export const MANIFEST_VERSION = "ffb5a8a16ad1";
|
|
4
4
|
export const capabilities = {
|
|
5
5
|
"ai": {
|
|
6
6
|
"$schema": "./schema.json",
|
|
@@ -570,6 +570,263 @@ export const capabilities = {
|
|
|
570
570
|
}
|
|
571
571
|
]
|
|
572
572
|
},
|
|
573
|
+
"email": {
|
|
574
|
+
"$schema": "./schema.json",
|
|
575
|
+
"format": 1,
|
|
576
|
+
"component": "email",
|
|
577
|
+
"title": "Transactional email through the Gateway",
|
|
578
|
+
"summary": "Apps send transactional mail with workbench-email, or POST /v1/email/send directly. The Gateway checks the sender and recipients, keeps idempotency keys for 24 hours, meters every send, and hands the message to Cloudflare Email Sending. Apps hold no email credential.",
|
|
579
|
+
"status": "built",
|
|
580
|
+
"surfaces": {
|
|
581
|
+
"gateway": [
|
|
582
|
+
"POST /v1/email/send"
|
|
583
|
+
],
|
|
584
|
+
"package": [
|
|
585
|
+
"createMailer",
|
|
586
|
+
"selectTransport",
|
|
587
|
+
"Mailer.send",
|
|
588
|
+
"captureSink",
|
|
589
|
+
"WorkbenchEmailError"
|
|
590
|
+
]
|
|
591
|
+
},
|
|
592
|
+
"config": [
|
|
593
|
+
{
|
|
594
|
+
"name": "WORKBENCH_GATEWAY_KEY",
|
|
595
|
+
"required": true,
|
|
596
|
+
"secret": true,
|
|
597
|
+
"environments": [
|
|
598
|
+
"staging",
|
|
599
|
+
"production"
|
|
600
|
+
],
|
|
601
|
+
"description": "Project key with the email scope, delivered as a Worker secret by gw_key_issue"
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
"name": "WORKBENCH_ENV",
|
|
605
|
+
"required": true,
|
|
606
|
+
"secret": false,
|
|
607
|
+
"environments": [
|
|
608
|
+
"development",
|
|
609
|
+
"staging",
|
|
610
|
+
"production"
|
|
611
|
+
],
|
|
612
|
+
"description": "Chooses the transport: capture in development, the Gateway elsewhere"
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
"name": "WORKBENCH_EMAIL_LIVE",
|
|
616
|
+
"required": false,
|
|
617
|
+
"secret": false,
|
|
618
|
+
"default": "0",
|
|
619
|
+
"environments": [
|
|
620
|
+
"development"
|
|
621
|
+
],
|
|
622
|
+
"description": "1 sends real mail from development through the Gateway (a development key can only mail Robert)"
|
|
623
|
+
}
|
|
624
|
+
],
|
|
625
|
+
"limits": [
|
|
626
|
+
{
|
|
627
|
+
"id": "email.recipients",
|
|
628
|
+
"value": 50,
|
|
629
|
+
"unit": "recipients",
|
|
630
|
+
"per": "message (to, cc and bcc together)",
|
|
631
|
+
"enforced_by": "gateway",
|
|
632
|
+
"refusal": "invalid_request"
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
"id": "email.attachments",
|
|
636
|
+
"value": 32,
|
|
637
|
+
"unit": "attachments",
|
|
638
|
+
"per": "message",
|
|
639
|
+
"enforced_by": "gateway",
|
|
640
|
+
"refusal": "invalid_request"
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
"id": "email.message-size",
|
|
644
|
+
"value": 26214400,
|
|
645
|
+
"unit": "bytes",
|
|
646
|
+
"per": "message, attachments as base64",
|
|
647
|
+
"enforced_by": "gateway",
|
|
648
|
+
"refusal": "invalid_request"
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
"id": "email.subject-length",
|
|
652
|
+
"value": 998,
|
|
653
|
+
"unit": "characters",
|
|
654
|
+
"per": "message",
|
|
655
|
+
"enforced_by": "gateway",
|
|
656
|
+
"refusal": "invalid_request"
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
"id": "email.idempotency-window",
|
|
660
|
+
"value": 24,
|
|
661
|
+
"unit": "hours",
|
|
662
|
+
"per": "Idempotency-Key",
|
|
663
|
+
"enforced_by": "database"
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
"id": "email.upstream-timeout",
|
|
667
|
+
"value": 30,
|
|
668
|
+
"unit": "seconds",
|
|
669
|
+
"per": "request",
|
|
670
|
+
"enforced_by": "gateway",
|
|
671
|
+
"refusal": "upstream_timeout"
|
|
672
|
+
}
|
|
673
|
+
],
|
|
674
|
+
"errors": [
|
|
675
|
+
{
|
|
676
|
+
"code": "sender_not_allowed",
|
|
677
|
+
"http": 403,
|
|
678
|
+
"retryable": false,
|
|
679
|
+
"meaning": "from isn't one of the project's senders (gw_sender_add)"
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
"code": "recipient_not_allowed",
|
|
683
|
+
"http": 403,
|
|
684
|
+
"retryable": false,
|
|
685
|
+
"meaning": "A development key sending to anyone but Workbench's allowlisted addresses"
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
"code": "invalid_request",
|
|
689
|
+
"http": 400,
|
|
690
|
+
"retryable": false,
|
|
691
|
+
"meaning": "A malformed message, a limit passed, or an Idempotency-Key reused with a different message (422)"
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
"code": "not_available",
|
|
695
|
+
"http": 501,
|
|
696
|
+
"retryable": false,
|
|
697
|
+
"meaning": "type bulk, until Amazon SES is built"
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
"code": "budget_exceeded",
|
|
701
|
+
"http": 402,
|
|
702
|
+
"retryable": false,
|
|
703
|
+
"meaning": "Enforced monthly budget reached"
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
"code": "rate_limited",
|
|
707
|
+
"http": 429,
|
|
708
|
+
"retryable": true,
|
|
709
|
+
"meaning": "Too many requests, Cloudflare's quota, or (409) a send with the same Idempotency-Key still in progress"
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
"code": "upstream_error",
|
|
713
|
+
"http": 502,
|
|
714
|
+
"retryable": true,
|
|
715
|
+
"meaning": "Cloudflare Email Sending failed; nothing was sent"
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
"code": "upstream_timeout",
|
|
719
|
+
"http": 504,
|
|
720
|
+
"retryable": true,
|
|
721
|
+
"meaning": "Cloudflare didn't answer within 30 seconds; the mail may or may not have gone"
|
|
722
|
+
}
|
|
723
|
+
],
|
|
724
|
+
"rules": [
|
|
725
|
+
{
|
|
726
|
+
"id": "email.text-always",
|
|
727
|
+
"kind": "must",
|
|
728
|
+
"text": "Send a text part with every mail, even when there is HTML.",
|
|
729
|
+
"enforced_by": "gateway",
|
|
730
|
+
"probe": "email.send.transactional"
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
"id": "email.idempotency-key",
|
|
734
|
+
"kind": "must",
|
|
735
|
+
"text": "Give an idempotencyKey to any send a retry could repeat (invites, receipts, resets). A repeat within 24 hours returns the first result and sends nothing.",
|
|
736
|
+
"enforced_by": "review",
|
|
737
|
+
"probe": "email.send.idempotent-repeat"
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
"id": "email.attachments-as-bytes",
|
|
741
|
+
"kind": "must",
|
|
742
|
+
"text": "Pass attachments as bytes (Uint8Array), and give an inline image a contentId that the HTML references as cid:.",
|
|
743
|
+
"enforced_by": "types",
|
|
744
|
+
"probe": "email.send.inline-image"
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
"id": "email.allowed-sender",
|
|
748
|
+
"kind": "must",
|
|
749
|
+
"text": "Send only from an address added with gw_sender_add on a domain onboarded with cf_email_sending_onboard.",
|
|
750
|
+
"enforced_by": "gateway",
|
|
751
|
+
"probe": "email.send.sender-not-allowed"
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
"id": "email.no-capture-outside-dev",
|
|
755
|
+
"kind": "must_not",
|
|
756
|
+
"text": "Use the capture transport outside development. createMailer throws when WORKBENCH_ENV is staging or production.",
|
|
757
|
+
"enforced_by": "types",
|
|
758
|
+
"probe": "email.capture-refused-in-staging"
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
"id": "email.dev-key-allowlist",
|
|
762
|
+
"kind": "must_not",
|
|
763
|
+
"text": "Expect a development key to mail anyone but Robert. The Gateway refuses other recipients before the budget.",
|
|
764
|
+
"enforced_by": "gateway",
|
|
765
|
+
"probe": "email.send.dev-recipient-refused"
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
"id": "email.transactional-only",
|
|
769
|
+
"kind": "must_not",
|
|
770
|
+
"text": "Send bulk or marketing mail. type bulk is refused with not_available until Amazon SES is built.",
|
|
771
|
+
"enforced_by": "gateway",
|
|
772
|
+
"probe": "email.send.transactional"
|
|
773
|
+
}
|
|
774
|
+
],
|
|
775
|
+
"examples": [
|
|
776
|
+
{
|
|
777
|
+
"id": "email.example.send",
|
|
778
|
+
"title": "Send a transactional mail with an inline logo",
|
|
779
|
+
"lang": "ts",
|
|
780
|
+
"code": "const env = workbenchEnv(process.env)\nconst mailer = createMailer({ env, transport: selectTransport(env), from: { address: 'hello@mail.example.com', name: 'Example' }, gateway: { key: process.env.WORKBENCH_GATEWAY_KEY! } })\nawait mailer.send({ to: user.email, subject: 'Welcome', text, html: '<img src=\"cid:logo\"> ...', template: 'welcome', idempotencyKey: `welcome:${user.id}`, attachments: [{ filename: 'logo.png', mimeType: 'image/png', bytes: logo, contentId: 'logo' }] })",
|
|
781
|
+
"probe": "email.send.inline-image"
|
|
782
|
+
}
|
|
783
|
+
],
|
|
784
|
+
"probes": [
|
|
785
|
+
{
|
|
786
|
+
"id": "email.send.transactional",
|
|
787
|
+
"half": "runtime",
|
|
788
|
+
"expect": "pass",
|
|
789
|
+
"smoke": true,
|
|
790
|
+
"cost_hint_usd": 0.00035,
|
|
791
|
+
"note": "To Robert; check that it arrives"
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
"id": "email.send.inline-image",
|
|
795
|
+
"half": "runtime",
|
|
796
|
+
"expect": "pass",
|
|
797
|
+
"cost_hint_usd": 0.00035,
|
|
798
|
+
"note": "Robert checks the image renders"
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
"id": "email.send.idempotent-repeat",
|
|
802
|
+
"half": "runtime",
|
|
803
|
+
"expect": "pass",
|
|
804
|
+
"cost_hint_usd": 0.00035,
|
|
805
|
+
"note": "The repeat answers replayed: true and no second mail arrives"
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
"id": "email.send.dev-recipient-refused",
|
|
809
|
+
"half": "runtime",
|
|
810
|
+
"expect": "refused_as_expected",
|
|
811
|
+
"refusal": "recipient_not_allowed",
|
|
812
|
+
"cost_hint_usd": 0
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
"id": "email.send.sender-not-allowed",
|
|
816
|
+
"half": "runtime",
|
|
817
|
+
"expect": "refused_as_expected",
|
|
818
|
+
"refusal": "sender_not_allowed",
|
|
819
|
+
"cost_hint_usd": 0
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
"id": "email.capture-refused-in-staging",
|
|
823
|
+
"half": "runtime",
|
|
824
|
+
"expect": "pass",
|
|
825
|
+
"cost_hint_usd": 0,
|
|
826
|
+
"note": "createMailer with transport capture and WORKBENCH_ENV=staging throws invalid_config"
|
|
827
|
+
}
|
|
828
|
+
]
|
|
829
|
+
},
|
|
573
830
|
"email-inbound": {
|
|
574
831
|
"$schema": "./schema.json",
|
|
575
832
|
"format": 1,
|
|
@@ -698,6 +955,139 @@ export const capabilities = {
|
|
|
698
955
|
}
|
|
699
956
|
]
|
|
700
957
|
},
|
|
958
|
+
"email-sending": {
|
|
959
|
+
"$schema": "./schema.json",
|
|
960
|
+
"format": 1,
|
|
961
|
+
"component": "email-sending",
|
|
962
|
+
"title": "Sending domains and sender addresses",
|
|
963
|
+
"summary": "Control onboards a domain in the project's namespace to Cloudflare Email Sending in the projects account, with its bounce, SPF, DKIM and DMARC records checked over DNS-over-HTTPS, and allows sender addresses on it. The Gateway sends from those addresses only.",
|
|
964
|
+
"status": "built",
|
|
965
|
+
"surfaces": {
|
|
966
|
+
"tools": [
|
|
967
|
+
"cf_email_sending_onboard",
|
|
968
|
+
"cf_email_sending_status",
|
|
969
|
+
"gw_sender_add",
|
|
970
|
+
"gw_sender_list"
|
|
971
|
+
]
|
|
972
|
+
},
|
|
973
|
+
"limits": [
|
|
974
|
+
{
|
|
975
|
+
"id": "email-sending.domains-per-zone",
|
|
976
|
+
"value": 30,
|
|
977
|
+
"unit": "domains",
|
|
978
|
+
"per": "zone, Email Sending and Email Routing together",
|
|
979
|
+
"enforced_by": "control"
|
|
980
|
+
}
|
|
981
|
+
],
|
|
982
|
+
"actions": [
|
|
983
|
+
{
|
|
984
|
+
"tool": "cf_email_sending_onboard",
|
|
985
|
+
"gate": "free",
|
|
986
|
+
"ships": "4"
|
|
987
|
+
},
|
|
988
|
+
{
|
|
989
|
+
"tool": "cf_email_sending_status",
|
|
990
|
+
"gate": "free",
|
|
991
|
+
"ships": "4"
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
"tool": "gw_sender_add",
|
|
995
|
+
"gate": "free",
|
|
996
|
+
"ships": "4"
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
"tool": "gw_sender_list",
|
|
1000
|
+
"gate": "free",
|
|
1001
|
+
"ships": "4"
|
|
1002
|
+
}
|
|
1003
|
+
],
|
|
1004
|
+
"errors": [
|
|
1005
|
+
{
|
|
1006
|
+
"code": "out_of_scope",
|
|
1007
|
+
"http": 403,
|
|
1008
|
+
"retryable": false,
|
|
1009
|
+
"meaning": "A domain outside the project's namespace, or a sender on a domain the project hasn't onboarded"
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
"code": "name_taken",
|
|
1013
|
+
"http": 409,
|
|
1014
|
+
"retryable": false,
|
|
1015
|
+
"meaning": "The domain or address is already onboarded or allowed outside this project; Control never adopts"
|
|
1016
|
+
},
|
|
1017
|
+
{
|
|
1018
|
+
"code": "record_conflict",
|
|
1019
|
+
"http": 409,
|
|
1020
|
+
"retryable": false,
|
|
1021
|
+
"meaning": "A DNS record in the way that Control won't change (another SPF, DMARC, MX or CNAME)"
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
"code": "missing_permission",
|
|
1025
|
+
"http": 403,
|
|
1026
|
+
"retryable": false,
|
|
1027
|
+
"meaning": "Control's Cloudflare token lacks Email Sending (edit)"
|
|
1028
|
+
}
|
|
1029
|
+
],
|
|
1030
|
+
"rules": [
|
|
1031
|
+
{
|
|
1032
|
+
"id": "email-sending.namespace-only",
|
|
1033
|
+
"kind": "must",
|
|
1034
|
+
"text": "Onboard a domain in the project's own namespace, for example mail.<id>.mccaskielabs.app. Project-owned zones such as wbprobe.com follow cf_zone_attach (step 6.1).",
|
|
1035
|
+
"enforced_by": "control",
|
|
1036
|
+
"probe": "email-sending.onboard"
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
"id": "email-sending.onboard-before-sender",
|
|
1040
|
+
"kind": "must",
|
|
1041
|
+
"text": "Onboard the domain before adding senders on it. gw_sender_add refuses a domain the project hasn't onboarded.",
|
|
1042
|
+
"enforced_by": "control",
|
|
1043
|
+
"probe": "email-sending.sender-add"
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
"id": "email-sending.never-adopt",
|
|
1047
|
+
"kind": "must_not",
|
|
1048
|
+
"text": "Expect Control to take over a domain onboarded by hand. It refuses with name_taken.",
|
|
1049
|
+
"enforced_by": "control",
|
|
1050
|
+
"probe": "email-sending.onboard"
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
"id": "email-sending.verified-over-doh",
|
|
1054
|
+
"kind": "must",
|
|
1055
|
+
"text": "Treat a domain as ready only when cf_email_sending_status says ready: every expected record is in the zone and visible over DNS-over-HTTPS.",
|
|
1056
|
+
"enforced_by": "control",
|
|
1057
|
+
"probe": "email-sending.status"
|
|
1058
|
+
}
|
|
1059
|
+
],
|
|
1060
|
+
"probes": [
|
|
1061
|
+
{
|
|
1062
|
+
"id": "email-sending.onboard",
|
|
1063
|
+
"half": "drill",
|
|
1064
|
+
"expect": "pass",
|
|
1065
|
+
"cost_hint_usd": 0,
|
|
1066
|
+
"note": "cf_email_sending_onboard on mail.<id>.mccaskielabs.app; a second call changes nothing"
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
"id": "email-sending.status",
|
|
1070
|
+
"half": "drill",
|
|
1071
|
+
"expect": "pass",
|
|
1072
|
+
"cost_hint_usd": 0
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
"id": "email-sending.sender-add",
|
|
1076
|
+
"half": "drill",
|
|
1077
|
+
"expect": "pass",
|
|
1078
|
+
"cost_hint_usd": 0,
|
|
1079
|
+
"note": "Then a runtime send from the new address (email.send.transactional)"
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
"id": "email-sending.out-of-scope",
|
|
1083
|
+
"half": "drill",
|
|
1084
|
+
"expect": "refused_as_expected",
|
|
1085
|
+
"refusal": "out_of_scope",
|
|
1086
|
+
"cost_hint_usd": 0,
|
|
1087
|
+
"note": "Another project's namespace"
|
|
1088
|
+
}
|
|
1089
|
+
]
|
|
1090
|
+
},
|
|
701
1091
|
"gateway": {
|
|
702
1092
|
"$schema": "./schema.json",
|
|
703
1093
|
"format": 1,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mccaskie-labs/workbench-config",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Workbench app configuration: WORKBENCH_ENV, typed variables that fail by name, secrets that are never printed, and the capabilities manifest.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|