cloudflare-email 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -1
- data/README.md +265 -388
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +62 -6
- data/docs/activerecord-tenanted.md +135 -0
- data/docs/architecture.md +95 -0
- data/docs/delivery-events.md +177 -0
- data/docs/features.md +107 -0
- data/docs/getting-started.md +298 -0
- data/docs/mailboxes.md +299 -0
- data/docs/outbox.md +175 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +103 -0
- data/docs/upgrading-0.2.md +96 -0
- data/docs/verification/2026-09-10-followup.md +49 -0
- data/docs/verification/2026-09-10-install-upgrade.md +86 -0
- data/docs/verification/2026-09-10-live.md +102 -0
- data/docs/verification/2026-09-10.md +175 -0
- data/docs/verification/2026-09-11-extraction.md +32 -0
- data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
- data/docs/verification/2026-09-11-security.md +76 -0
- data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
- data/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +48 -0
- data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
- data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
- data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
- data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
- data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
- data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
- data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
- data/lib/cloudflare/email/active_record/outbox.rb +219 -0
- data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
- data/lib/cloudflare/email/active_record.rb +5 -0
- data/lib/cloudflare/email/client.rb +51 -11
- data/lib/cloudflare/email/consume_events_task.rb +22 -0
- data/lib/cloudflare/email/delivery_event.rb +70 -0
- data/lib/cloudflare/email/delivery_method.rb +3 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +3 -0
- data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
- data/lib/cloudflare/email/dev_tunnel.rb +31 -5
- data/lib/cloudflare/email/doctor.rb +16 -38
- data/lib/cloudflare/email/endpoint.rb +24 -0
- data/lib/cloudflare/email/engine.rb +28 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +86 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
- data/lib/cloudflare/email/mailboxes/events.rb +112 -0
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
- data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
- data/lib/cloudflare/email/mailboxes/models.rb +148 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +288 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- data/lib/cloudflare/email/message_id.rb +12 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
- data/lib/cloudflare/email/provision_route_task.rb +2 -1
- data/lib/cloudflare/email/replay_events_job.rb +18 -0
- data/lib/cloudflare/email/response.rb +21 -3
- data/lib/cloudflare/email/routing_provisioner.rb +71 -45
- data/lib/cloudflare/email/send_job.rb +41 -0
- data/lib/cloudflare/email/send_test.rb +5 -29
- data/lib/cloudflare/email/signing.rb +2 -2
- data/lib/cloudflare/email/tenancy.rb +78 -0
- data/lib/cloudflare/email/tenant_job_context.rb +90 -0
- data/lib/cloudflare/email/verification.rb +30 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +5 -3
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +48 -33
- data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +54 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
- data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
- data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
- data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
- data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
- data/lib/tasks/cloudflare_email.rake +41 -4
- data/templates/worker/README.md +61 -11
- data/templates/worker/package-lock.json +2627 -0
- data/templates/worker/package.json +8 -5
- data/templates/worker/scripts/wrangler.mjs +16 -0
- data/templates/worker/src/index.js +83 -4
- data/templates/worker/test/index.test.ts +117 -5
- data/templates/worker/wrangler.toml +13 -4
- metadata +107 -6
- data/lib/cloudflare/email/secure_message_id.rb +0 -89
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Preproduction cleanup and reusable event receipts — September 11, 2026
|
|
2
|
+
|
|
3
|
+
This pass removes v1 ingress and SecureMessageId, adds optional ActiveRecord event
|
|
4
|
+
receipts, and extracts provider acceptance, message-ID normalization and delivery
|
|
5
|
+
ordering helpers. See [architecture](../architecture.md) for the implemented
|
|
6
|
+
boundary and the next outbound-ledger extraction.
|
|
7
|
+
|
|
8
|
+
## Local checks
|
|
9
|
+
|
|
10
|
+
| Check | Result |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `bundle exec rake test`, Ruby 3.4.1 / Rails 8.1.3.1 | 189 tests, 635 assertions, no failures/errors/skips |
|
|
13
|
+
| Durable event adapter subprocess included in suite | 13 SQLite tests, 56 assertions |
|
|
14
|
+
| `bundle exec ruby script/verify_package.rb` | Isolated Ruby consumer and Rails package checks passed; 48 tests, 235 assertions |
|
|
15
|
+
| `script/verification/install_upgrade.rb` under Ruby 4 / Rails 8.1 | 16 checks passed, including v1 rejection and v2 inbox routing |
|
|
16
|
+
| Local actual workerd → Rails | 19 checks passed using Node 22.23.1 |
|
|
17
|
+
|
|
18
|
+
Receipt tests exercise the generated migration, account-scoped database uniqueness,
|
|
19
|
+
concurrent duplicate insertion, changed-payload rejection, commit-before-ACK,
|
|
20
|
+
failed persistence preventing ACK, replay, savepoint/handler rollback, and explicit
|
|
21
|
+
handler outcomes. Plain gem loading is checked without ActiveRecord.
|
|
22
|
+
|
|
23
|
+
The Worker run verifies authenticated SMTP metadata, unchanged MIME and all 256
|
|
24
|
+
binary byte values, same-recipient deduplication, separate Bcc recipient records,
|
|
25
|
+
mailbox-job execution, secret mismatch, redirect rejection, and a real 15-second
|
|
26
|
+
timeout. The first invocation found Node 20 on PATH and failed before Worker
|
|
27
|
+
startup; rerunning with explicit installed Node 22.23.1 passed.
|
|
28
|
+
|
|
29
|
+
These are local checks using synthetic mail, SQLite and controlled HTTP responses.
|
|
30
|
+
No email was sent, no Worker deployed, and no RubyGem published in this pass.
|
|
31
|
+
PostgreSQL locking and live delivery using this new adapter have not been verified.
|
|
32
|
+
Earlier live reports remain evidence for their dated revisions only.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Shared outbound ledger verification — September 11, 2026
|
|
2
|
+
|
|
3
|
+
The optional Rails layer now owns immutable MIME preparation, account-scoped
|
|
4
|
+
operation keys, committed send claims, recipient acceptance evidence, provider
|
|
5
|
+
event projection, and audited reconciliation. The reference inbox consumes these
|
|
6
|
+
tables and services rather than keeping its own transport ledger.
|
|
7
|
+
|
|
8
|
+
## Local results
|
|
9
|
+
|
|
10
|
+
| Check | Result |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| Gem suite, Ruby 3.4.1 / Rails 8.1.3.1 | 191 tests, 637 assertions, no failures/errors/skips |
|
|
13
|
+
| Outbox subprocess included in suite | 21 SQLite tests, 112 assertions |
|
|
14
|
+
| Mail snapshots, jobs, event projection subprocess | 9 SQLite tests, 45 assertions |
|
|
15
|
+
| Existing receipt subprocess | 13 SQLite tests, 56 assertions |
|
|
16
|
+
| Actual PostgreSQL 15 verifier | 9 tests, 45 assertions |
|
|
17
|
+
| Packaged isolated Ruby + Rails install/ingress + outbound checks | 57 tests, 280 assertions; all passed |
|
|
18
|
+
|
|
19
|
+
The top-level gem counts include subprocess assertions, not their individual
|
|
20
|
+
inner assertions. PostgreSQL 16 verification is added to CI. The PostgreSQL
|
|
21
|
+
driver remains an optional test dependency; the gem has no new runtime database
|
|
22
|
+
or job dependencies for plain Ruby users.
|
|
23
|
+
|
|
24
|
+
## Failure and concurrency coverage
|
|
25
|
+
|
|
26
|
+
- Eight concurrent identical operations produce one stored snapshot and one
|
|
27
|
+
provider request. Conflicting snapshots cannot overwrite the winner.
|
|
28
|
+
- Independent operations reach the controlled provider concurrently.
|
|
29
|
+
- A real SIGKILL after the durable claim leaves the operation blocked after
|
|
30
|
+
restart. A real PostgreSQL backend termination plus simulated provider timeout
|
|
31
|
+
also preserves the non-retryable claim.
|
|
32
|
+
- Acceptance followed by database failure does not permit another send.
|
|
33
|
+
- Partial acceptance never resends the whole batch. Unknown recipients can be
|
|
34
|
+
reconciled independently while known lifecycle states remain intact.
|
|
35
|
+
- Malformed IDs and conflicting per-recipient provider IDs become uncertainty;
|
|
36
|
+
a valid common ID on a later recipient entry is retained.
|
|
37
|
+
- Event-before-response, duplicate callbacks, concurrent out-of-order events,
|
|
38
|
+
terminal guards, account mismatch and ambiguous IDs are exercised.
|
|
39
|
+
- Application callback failure rolls back product and recipient writes while
|
|
40
|
+
retaining the receipt; replay applies it. A send-job retry repairs failed
|
|
41
|
+
product projection without another provider request.
|
|
42
|
+
- MIME includes a binary attachment containing all 256 byte values and a hidden
|
|
43
|
+
Bcc envelope recipient. Jobs serialize only the account and operation key.
|
|
44
|
+
- Reconciliation requires actor/reason/evidence and legal unresolved transitions.
|
|
45
|
+
Sending reconciliation requires a stopped sender confirmation and a minimum
|
|
46
|
+
claim age. Generated migrations refuse to delete safety evidence on rollback.
|
|
47
|
+
|
|
48
|
+
## Boundaries
|
|
49
|
+
|
|
50
|
+
HTTP acceptance and lifecycle payloads use controlled local fixtures. This pass
|
|
51
|
+
does not send live mail, deploy a Worker, publish RubyGems, or prove inbox placement.
|
|
52
|
+
Earlier Cloudflare live reports describe their own revisions. Provider-ID
|
|
53
|
+
correlation assumes Cloudflare assigns unique IDs; pre-existing duplicate matches
|
|
54
|
+
are retained as unmatched for review. The network and database remain separate
|
|
55
|
+
transactions, and the system does not claim exactly-once provider delivery.
|
|
56
|
+
|
|
57
|
+
The reference inbox has separate HTTP, browser, process-recovery and import
|
|
58
|
+
verification recorded in its `docs/verification/2026-09-11-outbox.md`. Historical
|
|
59
|
+
records preserve available content and audit data; reconstructed imported MIME
|
|
60
|
+
is explicitly marked and is not claimed to reproduce historical wire bytes.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Security review — 2026-09-11
|
|
2
|
+
|
|
3
|
+
Reviewed the 0.2.0 gem at baseline `bd83e067938d5c8df5a7a2ced2cf2a74c3f58e06`
|
|
4
|
+
and applied the hardening described below on `security/gem-hardening`.
|
|
5
|
+
This was a defensive source review, dependency audit and local verification.
|
|
6
|
+
No live penetration testing was performed and no production compromise was
|
|
7
|
+
demonstrated. Prior live test reports describe different revisions.
|
|
8
|
+
|
|
9
|
+
## Findings and fixes
|
|
10
|
+
|
|
11
|
+
Priorities are contextual review judgments, not CVSS scores.
|
|
12
|
+
|
|
13
|
+
| Priority | Finding and prerequisite | Remediation |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| High | The development tunnel exposed the full Rails origin when enabled, including routes unrelated to email. | Force a dedicated origin Host and install a first-position development middleware that permits only the ingress POST. Refuse to start the tunnel unless the running server has the guard. |
|
|
16
|
+
| High | The old Rails 7.1 test stack resolved dependencies with 12 advisory hits, including ActiveStorage CVE-2026-66066. Application exposure depends on its resolved dependencies and feature use. | Remove that unsupported matrix variant; raise Rails/SQLite test floors and audit resolved Ruby matrix locks in CI. Applications must update their own dependencies. |
|
|
17
|
+
| Medium | Inbound MIME was read without an application bound, allowing large requests/messages to consume memory. Upstream limits determine reachability. | Default 25 MiB bound in Rails and Worker, declared-size checks and bounded stream reads. Reject malformed/stale signing headers before Rails reads the body. Full HMAC verification still follows the read. |
|
|
18
|
+
| Medium | Operator-configured remote HTTP endpoints could transmit credentials or mail without TLS. | Require HTTPS for API and ingress endpoints, except literal loopback development hosts. Validate deploy configuration before remote mutations. Endpoint configuration remains trusted. |
|
|
19
|
+
| Medium | Delivery-event validation omitted important field types; original receipt identity/payload could be changed through ordinary model updates. Requires malformed queue input or application writes. | Validate identifiers, recipient, timestamp, terminal boolean and optional detail objects; mark receipt evidence fields read-only. Preserve unknown schema-1 future event types without projecting an unknown lifecycle state. |
|
|
20
|
+
| Low | Client inspection, retry warnings and generated mailbox logs could disclose tokens or private provider/mail details. | Redact client inspection and remove sensitive fields from those default logging paths. Raw errors/responses remain available to applications. |
|
|
21
|
+
| Low | Provider acceptance helpers treated some malformed success fields or message IDs permissively. | Require explicit boolean success when present, successful HTTP status, valid string IDs and array-shaped recipient outcomes. |
|
|
22
|
+
|
|
23
|
+
Reviewed HMAC v2/envelope handling, recipient-scoped deduplication, transport
|
|
24
|
+
retries, event account/domain checks and ACK ordering, durable outbox claims,
|
|
25
|
+
tenant/account correlation, evidence storage, deployment/generator behavior,
|
|
26
|
+
package contents and CI permissions. No additional concrete authentication or
|
|
27
|
+
claim-bypass finding was established in these paths; this is not proof that
|
|
28
|
+
all defects are absent.
|
|
29
|
+
|
|
30
|
+
## Verification
|
|
31
|
+
|
|
32
|
+
Final Ruby behavior checks used Ruby 3.4.1 and Rails 8.1.3.1:
|
|
33
|
+
|
|
34
|
+
- `bundle exec rake test`: **205 tests, 751 assertions**, no failures/errors/skips.
|
|
35
|
+
Includes real Rails subprocesses, SQLite receipts/outbox and development guard.
|
|
36
|
+
- Worker `npm test`: **28 tests passed**, Node 22.23.1.
|
|
37
|
+
- Worker `npm run check`: Wrangler deployment dry-run passed.
|
|
38
|
+
- `script/verify_package.rb`: isolated plain Ruby consumer, packaged Rails
|
|
39
|
+
installation/ingress and durable outbound checks passed.
|
|
40
|
+
- `script/verify_local_ingress.rb`: actual local workerd-to-Rails verification
|
|
41
|
+
passed, including exact MIME/binary attachment preservation, trusted envelope,
|
|
42
|
+
deduplication and separate recipients, routing, invalid/missing credentials,
|
|
43
|
+
refused redirects and a stalled upstream aborted after 15 seconds.
|
|
44
|
+
No real email was sent and no Worker was deployed by this run.
|
|
45
|
+
- `bundler-audit 0.9.3`: no known vulnerabilities in the six supported locally
|
|
46
|
+
resolved lockfiles (default, local ingress, PostgreSQL, Rails 7.2/8.0/8.1).
|
|
47
|
+
Advisory database commit: `93b32f641f84282183ce58ab1d7204bee50885bd`.
|
|
48
|
+
- `npm audit --audit-level=low`: **zero known vulnerabilities**.
|
|
49
|
+
- `git diff --check`: passed.
|
|
50
|
+
|
|
51
|
+
Ruby lockfiles are not committed; audits describe these resolutions and the
|
|
52
|
+
advisory database at review time. CI audits newly resolved Ruby matrix stacks.
|
|
53
|
+
PostgreSQL concurrency execution and the broader Ruby matrix are delegated to
|
|
54
|
+
the PR's CI; their results should be checked separately from the local results.
|
|
55
|
+
|
|
56
|
+
## Remaining boundaries and rollout
|
|
57
|
+
|
|
58
|
+
See [SECURITY.md](../../SECURITY.md) for application and deployment controls.
|
|
59
|
+
Upstream request buffering, rate limiting and read deadlines remain external.
|
|
60
|
+
Mail rendering, malware scanning, tenant authorization, encryption and retention
|
|
61
|
+
are application responsibilities. Queue write access is an authenticity boundary;
|
|
62
|
+
account labels are not signatures. Read-only ActiveRecord attributes do not
|
|
63
|
+
prevent privileged SQL changes. Inbound deduplication intentionally keys identical
|
|
64
|
+
MIME by SMTP recipient, retaining the first authenticated envelope sender.
|
|
65
|
+
|
|
66
|
+
The development Host guard restricts routes, but the allowed ingress still runs
|
|
67
|
+
inside the development application: safe error handling and test-only data are
|
|
68
|
+
required. CI action references use version tags rather than immutable commits;
|
|
69
|
+
pinning them is additional supply-chain hardening still available.
|
|
70
|
+
|
|
71
|
+
These changes are not yet a RubyGems release or a deployed Worker upgrade. The
|
|
72
|
+
dogfood inbox has not been repinned or live-tested against this security branch.
|
|
73
|
+
Rollout requires updating the gem revision, redeploying the Worker, aligning any
|
|
74
|
+
`MAX_EMAIL_BYTES` override, updating application dependencies and restarting Rails
|
|
75
|
+
before opening a development tunnel. Do not equate the earlier dogfood results
|
|
76
|
+
with verification of this revision in a deployed application.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Optional mailboxes and tenant support — 2026-09-11
|
|
2
|
+
|
|
3
|
+
Implementation source: `4273c5ac10cb87d5e5610acc6a4e69c55d94a42f`.
|
|
4
|
+
The guides and installation pin may receive subsequent documentation-only updates.
|
|
5
|
+
|
|
6
|
+
## Implemented
|
|
7
|
+
|
|
8
|
+
- Explicit optional tenant adapter and configurable model base for the existing
|
|
9
|
+
outbox/receipt models and new tenant mailbox models. Plain Ruby remains independent.
|
|
10
|
+
- Shared receiving-domain directory and tenant mailboxes, aliases, owner references,
|
|
11
|
+
lifecycle, inbound memberships, read/archive state and explicit purge.
|
|
12
|
+
- Full HMAC verification before recipient-to-tenant resolution, followed by
|
|
13
|
+
same-connection ActionMailbox/ActiveStorage persistence and membership storage.
|
|
14
|
+
- Scoped mailbox sender checks and identity-only send jobs using the existing
|
|
15
|
+
immutable outbox, callbacks, reconciliation and conservative retry behavior.
|
|
16
|
+
- Shared event intake before ACK, account/message/recipient correlation to a
|
|
17
|
+
tenant outbox, tenant commit before shared completion, and bounded recovery jobs.
|
|
18
|
+
- Tenant serialization before framework GlobalID lookup; missing/conflicting
|
|
19
|
+
tenant metadata rejected. Managed raw mail retained against normal incineration.
|
|
20
|
+
- Composed generator with separate tenant/shared migration paths and optional
|
|
21
|
+
`activerecord-tenanted` integration guide/bundle/CI job.
|
|
22
|
+
|
|
23
|
+
## Local verification
|
|
24
|
+
|
|
25
|
+
Ruby 3.4.1 / Rails 8.1.3.1 unless noted:
|
|
26
|
+
|
|
27
|
+
| Check | Result |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| Full `bundle exec rake test` | 212 tests, 758 assertions, no failures/errors/skips |
|
|
30
|
+
| Tenant connection and job subprocess | 11 tests, 54 assertions; physical SQLite shards with overlapping IDs, host-only framework job capture and context restoration |
|
|
31
|
+
| Default job-context subprocess | 3 tests, 7 assertions; ordinary untagged jobs preserved without database tenancy |
|
|
32
|
+
| Mailbox service subprocess | 16 tests, 92 assertions; two fixed SQLite tenant pools plus shared directory |
|
|
33
|
+
| Shared event subprocess | 8 tests, 28 assertions; also checked on Rails 7.2 |
|
|
34
|
+
| Real Rails mailbox ingress subprocess | 4 tests, 29 assertions; signed recipient routing, raw storage, jobs, aliases, deduplication, unavailable destinations, retention and purge |
|
|
35
|
+
| Actual `activerecord-tenanted` 0.8 integration | 3 tests, 23 assertions; real tenant provisioning/migrations, isolation, context and GlobalID checks |
|
|
36
|
+
| Generator checks | Fresh single-DB and separate shared/tenant SQLite installs, migration ordering and initializer syntax |
|
|
37
|
+
| Packaged gem | Isolated Ruby consumer; Rails install/ingress/outbox checks; new tenant ingress, jobs, retention and all mailbox migrations from extracted package |
|
|
38
|
+
| Local workerd → Rails | Existing real runtime fixture passed, including raw bytes, routing, duplicates, invalid credentials, redirects and timeout |
|
|
39
|
+
|
|
40
|
+
Top-level test totals include subprocess wrapper assertions; detailed subprocess
|
|
41
|
+
counts above describe their internal checks and are not additional top-level tests.
|
|
42
|
+
The service suite includes concurrent claims producing one provider request,
|
|
43
|
+
callback rollback, retry/recovery without resending, reconcile rollback, pagination,
|
|
44
|
+
suspension after enqueue, caller operation-key isolation and sender/account checks.
|
|
45
|
+
Event tests include event-before-response, conflicting identities, cross-tenant
|
|
46
|
+
correlation ambiguity, tenant-commit/shared-failure replay, and callback rollback.
|
|
47
|
+
|
|
48
|
+
The optional tenant dependency resolution passed bundler-audit. CI additionally
|
|
49
|
+
runs the supported Ruby/Rails matrix, PostgreSQL outbox checks and Worker checks.
|
|
50
|
+
Check the PR's CI for results on its latest revision.
|
|
51
|
+
|
|
52
|
+
## Operational boundaries
|
|
53
|
+
|
|
54
|
+
No production Worker, domain/DNS, inbox or Rebulk deployment changed. No real
|
|
55
|
+
email was sent by these checks. The new Worker-to-mailbox path uses the existing
|
|
56
|
+
v2 signature contract; live customer subdomain provisioning was not exercised.
|
|
57
|
+
Rebulk's existing ingest/communication records and sender-authentication policy
|
|
58
|
+
still require a separate, explicit application adoption project.
|
|
59
|
+
|
|
60
|
+
Directory activation records authorized operator evidence, not independent
|
|
61
|
+
domain verification. Application permissions select tenant and mailbox access;
|
|
62
|
+
raw ActiveRecord and administrative directory methods are privileged interfaces.
|
|
63
|
+
Database keys and queue payloads must come from trusted application state.
|
|
64
|
+
|
|
65
|
+
Framework records must share the configured tenant connection. Drain old
|
|
66
|
+
ActionMailbox/ActiveStorage jobs before enabling database tenancy because old
|
|
67
|
+
payloads without the new metadata cannot safely be guessed. Mailbox memberships
|
|
68
|
+
retain raw mail until explicit purge, so applications must define a retention
|
|
69
|
+
policy and monitor storage usage.
|
|
70
|
+
|
|
71
|
+
Queue intake and tenant projection use separate commits with idempotent recovery,
|
|
72
|
+
not a distributed transaction. Schedule polling, shared replay and per-tenant
|
|
73
|
+
recovery. Unknown provider outcomes stay blocked; no exactly-once guarantee or
|
|
74
|
+
automatic resend is added. Existing outbox PostgreSQL behavior remains covered;
|
|
75
|
+
the new mailbox/tenant integration's direct database evidence is SQLite.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Plain Ruby example — no Rails required.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... ruby examples/plain_ruby.rb
|
|
6
|
+
|
|
7
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
8
|
+
require "cloudflare-email"
|
|
9
|
+
|
|
10
|
+
client = Cloudflare::Email::Client.new(
|
|
11
|
+
account_id: ENV.fetch("CLOUDFLARE_ACCOUNT_ID"),
|
|
12
|
+
api_token: ENV.fetch("CLOUDFLARE_API_TOKEN"),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
response = client.send(
|
|
16
|
+
from: { address: "agent@example.com", name: "Demo Agent" },
|
|
17
|
+
to: "you@example.com",
|
|
18
|
+
subject: "Hello from cloudflare-email",
|
|
19
|
+
text: "It works!",
|
|
20
|
+
html: "<p><strong>It works!</strong></p>",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
puts "delivered: #{response.delivered.inspect}"
|
|
24
|
+
puts "message_id: #{response.message_id}"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
require "cloudflare/email/tenancy"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
module ActiveRecord
|
|
7
|
+
# Uses the host's abstract tenant connection owner when explicitly configured.
|
|
8
|
+
class Base < Tenancy.model_base(::ActiveRecord::Base)
|
|
9
|
+
self.abstract_class = true
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def connection_pool
|
|
13
|
+
Tenancy.require_context! if Tenancy.enabled?
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
before_validation :verify_cloudflare_email_tenant!
|
|
19
|
+
before_save :verify_cloudflare_email_tenant!
|
|
20
|
+
before_destroy :verify_cloudflare_email_tenant!
|
|
21
|
+
|
|
22
|
+
# These methods can bypass callbacks or load a different row with the same ID.
|
|
23
|
+
%i[reload update_columns delete touch increment! decrement! association].each do |method_name|
|
|
24
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
25
|
+
verify_cloudflare_email_tenant!
|
|
26
|
+
super(*args, **kwargs, &block)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Both new construction and persisted-row instantiation call this before
|
|
33
|
+
# assigning inverse associations (which precede after_initialize).
|
|
34
|
+
def init_internals
|
|
35
|
+
super
|
|
36
|
+
@cloudflare_email_tenant_key = Tenancy.require_context! if Tenancy.enabled?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def verify_cloudflare_email_tenant!
|
|
40
|
+
return unless Tenancy.enabled?
|
|
41
|
+
unless @cloudflare_email_tenant_key == Tenancy.require_context!
|
|
42
|
+
raise ConfigurationError, "record belongs to a different tenant context"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "cloudflare/email/active_record/event_inbox"
|
|
2
|
+
require "cloudflare/email/active_record/outbox"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Cloudflare
|
|
6
|
+
module Email
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
# Durable receipt + recipient projection. Application callbacks may update
|
|
9
|
+
# product records on this connection; external effects need their own outbox.
|
|
10
|
+
class DeliveryEvents
|
|
11
|
+
class << self
|
|
12
|
+
def record(event, &on_change)
|
|
13
|
+
validate!(event)
|
|
14
|
+
receipt = EventInbox.record(event)
|
|
15
|
+
EventInbox.apply(receipt) { |stored| project(stored, &on_change) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def replay(account_id:, message_id: nil, batch_size: 100, &on_change)
|
|
19
|
+
raise ArgumentError, "account_id is required" if account_id.to_s.empty?
|
|
20
|
+
EventInbox.replay(account_id: account_id, message_id: message_id,
|
|
21
|
+
batch_size: batch_size) { |event| project(event, &on_change) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def validate!(event)
|
|
27
|
+
raise ValidationError, "delivery event recipient is required" unless event.recipient.is_a?(String) && !event.recipient.strip.empty?
|
|
28
|
+
Time.iso8601(event.occurred_at.to_s)
|
|
29
|
+
rescue ArgumentError
|
|
30
|
+
raise ValidationError, "delivery event timestamp must be ISO8601"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def project(event)
|
|
34
|
+
validate!(event)
|
|
35
|
+
return :unmatched unless event.known?
|
|
36
|
+
|
|
37
|
+
# Refuse ambiguous correlation instead of picking the newest record.
|
|
38
|
+
ids = OutboundDelivery.where(account_id: event.account_id,
|
|
39
|
+
provider_message_id: MessageId.normalize(event.message_id),
|
|
40
|
+
state: %w[accepted partial]).limit(2).pluck(:id)
|
|
41
|
+
return :unmatched unless ids.length == 1
|
|
42
|
+
|
|
43
|
+
delivery = OutboundDelivery.find(ids.first)
|
|
44
|
+
outcome = :unmatched
|
|
45
|
+
delivery.with_lock do
|
|
46
|
+
next unless %w[accepted partial].include?(delivery.state)
|
|
47
|
+
recipient = delivery.outbound_recipients.find_by(
|
|
48
|
+
recipient: Outbox.normalize_recipient(event.recipient))
|
|
49
|
+
next unless recipient
|
|
50
|
+
|
|
51
|
+
if event.supersedes?(occurred_at: recipient.occurred_at, terminal: recipient.terminal?)
|
|
52
|
+
attributes = { state: event.status, occurred_at: Time.iso8601(event.occurred_at), terminal: event.terminal? }
|
|
53
|
+
# A correlated lifecycle event proves Cloudflare processed this
|
|
54
|
+
# recipient even if its initial response omitted the outcome.
|
|
55
|
+
if recipient.acceptance_state == "unknown"
|
|
56
|
+
attributes[:acceptance_state] = "accepted"
|
|
57
|
+
end
|
|
58
|
+
recipient.update!(attributes)
|
|
59
|
+
if attributes[:acceptance_state]
|
|
60
|
+
delivery.update!(state: Outbox.acceptance_state(delivery.outbound_recipients.pluck(:acceptance_state)))
|
|
61
|
+
end
|
|
62
|
+
yield delivery, recipient if block_given?
|
|
63
|
+
end
|
|
64
|
+
outcome = :applied
|
|
65
|
+
end
|
|
66
|
+
outcome
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
require "cloudflare/email/error"
|
|
3
|
+
require "cloudflare/email/delivery_event"
|
|
4
|
+
require "cloudflare/email/message_id"
|
|
5
|
+
require "cloudflare/email/active_record/event_receipt"
|
|
6
|
+
|
|
7
|
+
module Cloudflare
|
|
8
|
+
module Email
|
|
9
|
+
module ActiveRecord
|
|
10
|
+
# Optional durable boundary between queue acknowledgment and application work.
|
|
11
|
+
# Keep handler database writes on EventReceipt's connection; external effects
|
|
12
|
+
# cannot be rolled back and should use an application outbox.
|
|
13
|
+
class EventInbox
|
|
14
|
+
class << self
|
|
15
|
+
# Use directly in EventConsumer#poll. A returned receipt has committed,
|
|
16
|
+
# so the queue can ACK even when its message has not been matched yet.
|
|
17
|
+
def record(event)
|
|
18
|
+
if EventReceipt.connection.transaction_open?
|
|
19
|
+
raise ArgumentError, "record must run outside an existing database transaction before queue acknowledgment"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
event = DeliveryEvent.new(event.raw)
|
|
23
|
+
receipt = EventReceipt.create_or_find_by!(account_id: event.account_id, event_id: event.event_id) do |row|
|
|
24
|
+
row.message_id = MessageId.normalize(event.message_id)
|
|
25
|
+
row.payload_json = JSON.generate(event.raw)
|
|
26
|
+
row.state = "pending"
|
|
27
|
+
end
|
|
28
|
+
unless receipt.event.raw == event.raw
|
|
29
|
+
raise ValidationError, "event ID already recorded with a different payload"
|
|
30
|
+
end
|
|
31
|
+
receipt
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The handler must explicitly return :applied or :unmatched. Exceptions
|
|
35
|
+
# roll back both its database writes and the receipt transition.
|
|
36
|
+
def apply(receipt)
|
|
37
|
+
raise ArgumentError, "an event handler block is required" unless block_given?
|
|
38
|
+
raise ArgumentError, "expected a persisted EventReceipt" unless receipt.is_a?(EventReceipt) && receipt.persisted?
|
|
39
|
+
|
|
40
|
+
completed = false
|
|
41
|
+
receipt.with_lock(requires_new: true) do
|
|
42
|
+
if receipt.state == "applied"
|
|
43
|
+
completed = true
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
outcome = yield receipt.event
|
|
47
|
+
unless [:applied, :unmatched].include?(outcome)
|
|
48
|
+
raise ArgumentError, "event handler must return :applied or :unmatched"
|
|
49
|
+
end
|
|
50
|
+
receipt.update!(state: outcome.to_s, applied_at: outcome == :applied ? Time.now.utc : nil)
|
|
51
|
+
completed = true
|
|
52
|
+
end
|
|
53
|
+
raise ArgumentError, "event handler rolled back instead of returning an outcome" unless completed
|
|
54
|
+
receipt
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Replay pending/unmatched receipts after correlating provider IDs, or
|
|
58
|
+
# from a recurring job. Ordering policy belongs to the application.
|
|
59
|
+
def replay(account_id: nil, message_id: nil, batch_size: 100, &handler)
|
|
60
|
+
raise ArgumentError, "an event handler block is required" unless handler
|
|
61
|
+
raise ArgumentError, "batch_size must be positive" unless batch_size.is_a?(Integer) && batch_size.positive?
|
|
62
|
+
|
|
63
|
+
scope = EventReceipt.where(state: ["pending", "unmatched"])
|
|
64
|
+
scope = scope.where(account_id: account_id) if account_id
|
|
65
|
+
scope = scope.where(message_id: MessageId.normalize(message_id)) if message_id
|
|
66
|
+
count = 0
|
|
67
|
+
scope.find_each(batch_size: batch_size) do |receipt|
|
|
68
|
+
apply(receipt, &handler)
|
|
69
|
+
count += 1
|
|
70
|
+
end
|
|
71
|
+
count
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "cloudflare/email/active_record/base"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
class EventReceipt < Base
|
|
7
|
+
self.table_name = "cloudflare_email_event_receipts"
|
|
8
|
+
attr_readonly :account_id, :event_id, :message_id, :payload_json
|
|
9
|
+
|
|
10
|
+
# JSON text avoids a dependency on database-specific JSON column types.
|
|
11
|
+
def event
|
|
12
|
+
DeliveryEvent.new(JSON.parse(payload_json))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require "cloudflare/email/active_record/outbox"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
class Outbox
|
|
7
|
+
# Render before preparing. Delivery callbacks are intentionally not run:
|
|
8
|
+
# the worker sends the immutable snapshot, never a regenerated mailer.
|
|
9
|
+
def self.prepare_mail(account_id:, operation_key:, mail:)
|
|
10
|
+
mail = mail.message if mail.respond_to?(:message) && !mail.respond_to?(:encoded)
|
|
11
|
+
raise ValidationError, "mail must be a rendered Mail message" unless mail.respond_to?(:encoded)
|
|
12
|
+
if mail.respond_to?(:perform_deliveries) && !mail.perform_deliveries
|
|
13
|
+
raise ValidationError, "email delivery is disabled"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
from = mail.respond_to?(:smtp_envelope_from) ? mail.smtp_envelope_from : Array(mail.from).first
|
|
17
|
+
recipients = mail.respond_to?(:smtp_envelope_to) ? mail.smtp_envelope_to : [mail.to, mail.cc, mail.bcc].flatten.compact
|
|
18
|
+
prepare(account_id: account_id, operation_key: operation_key, from: from,
|
|
19
|
+
recipients: Array(recipients), mime_message: mail.encoded)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "cloudflare/email/active_record/base"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
class OutboundDelivery < Base
|
|
7
|
+
self.table_name = "cloudflare_email_outbound_deliveries"
|
|
8
|
+
has_many :outbound_recipients, class_name: "Cloudflare::Email::ActiveRecord::OutboundRecipient", dependent: :restrict_with_exception
|
|
9
|
+
has_many :outbound_reconciliations, class_name: "Cloudflare::Email::ActiveRecord::OutboundReconciliation", dependent: :restrict_with_exception
|
|
10
|
+
attr_readonly :account_id, :operation_key, :from_address, :recipients_json, :mime_message, :snapshot_digest
|
|
11
|
+
|
|
12
|
+
def recipients
|
|
13
|
+
JSON.parse(recipients_json)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def response
|
|
17
|
+
Response.new(JSON.parse(response_json)) if response_json
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require "cloudflare/email/active_record/base"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
class OutboundRecipient < Base
|
|
7
|
+
self.table_name = "cloudflare_email_outbound_recipients"
|
|
8
|
+
belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
|
|
9
|
+
attr_readonly :outbound_delivery_id, :recipient
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require "cloudflare/email/active_record/base"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
class OutboundReconciliation < Base
|
|
7
|
+
self.table_name = "cloudflare_email_outbound_reconciliations"
|
|
8
|
+
belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
|
|
9
|
+
def readonly?
|
|
10
|
+
persisted?
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|