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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/README.md +265 -388
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +62 -6
  6. data/docs/activerecord-tenanted.md +135 -0
  7. data/docs/architecture.md +95 -0
  8. data/docs/delivery-events.md +177 -0
  9. data/docs/features.md +107 -0
  10. data/docs/getting-started.md +298 -0
  11. data/docs/mailboxes.md +299 -0
  12. data/docs/outbox.md +175 -0
  13. data/docs/tenant-mailboxes-plan.md +149 -0
  14. data/docs/thread-correlation.md +27 -0
  15. data/docs/troubleshooting.md +103 -0
  16. data/docs/upgrading-0.2.md +96 -0
  17. data/docs/verification/2026-09-10-followup.md +49 -0
  18. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  19. data/docs/verification/2026-09-10-live.md +102 -0
  20. data/docs/verification/2026-09-10.md +175 -0
  21. data/docs/verification/2026-09-11-extraction.md +32 -0
  22. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  23. data/docs/verification/2026-09-11-security.md +76 -0
  24. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  25. data/examples/plain_ruby.rb +24 -0
  26. data/lib/cloudflare/email/active_record/base.rb +48 -0
  27. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  28. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  29. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  30. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  31. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  32. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  33. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  34. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  35. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  36. data/lib/cloudflare/email/active_record.rb +5 -0
  37. data/lib/cloudflare/email/client.rb +51 -11
  38. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  39. data/lib/cloudflare/email/delivery_event.rb +70 -0
  40. data/lib/cloudflare/email/delivery_method.rb +3 -0
  41. data/lib/cloudflare/email/deploy_worker_task.rb +3 -0
  42. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  43. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  44. data/lib/cloudflare/email/doctor.rb +16 -38
  45. data/lib/cloudflare/email/endpoint.rb +24 -0
  46. data/lib/cloudflare/email/engine.rb +28 -0
  47. data/lib/cloudflare/email/envelope.rb +58 -0
  48. data/lib/cloudflare/email/event_consumer.rb +86 -0
  49. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  50. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  51. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  52. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  53. data/lib/cloudflare/email/mailboxes/models.rb +148 -0
  54. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  55. data/lib/cloudflare/email/mailboxes/service.rb +288 -0
  56. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  57. data/lib/cloudflare/email/mailboxes.rb +11 -0
  58. data/lib/cloudflare/email/message_id.rb +12 -0
  59. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  60. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  61. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  62. data/lib/cloudflare/email/response.rb +21 -3
  63. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  64. data/lib/cloudflare/email/send_job.rb +41 -0
  65. data/lib/cloudflare/email/send_test.rb +5 -29
  66. data/lib/cloudflare/email/signing.rb +2 -2
  67. data/lib/cloudflare/email/tenancy.rb +78 -0
  68. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  69. data/lib/cloudflare/email/verification.rb +30 -8
  70. data/lib/cloudflare/email/version.rb +1 -1
  71. data/lib/cloudflare/email/worker_deployer.rb +5 -3
  72. data/lib/cloudflare-email.rb +4 -1
  73. data/lib/generators/cloudflare/email/install_generator.rb +48 -33
  74. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  75. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +54 -0
  76. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  77. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  78. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  79. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  80. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  81. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  82. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  83. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  84. data/lib/tasks/cloudflare_email.rake +41 -4
  85. data/templates/worker/README.md +61 -11
  86. data/templates/worker/package-lock.json +2627 -0
  87. data/templates/worker/package.json +8 -5
  88. data/templates/worker/scripts/wrangler.mjs +16 -0
  89. data/templates/worker/src/index.js +83 -4
  90. data/templates/worker/test/index.test.ts +117 -5
  91. data/templates/worker/wrangler.toml +13 -4
  92. metadata +107 -6
  93. 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