Class: Cloudflare::Email::RoutingProvisioner

Inherits:
Object
  • Object
show all
Defined in:
lib/cloudflare/email/routing_provisioner.rb

Overview

Provision Cloudflare Email Routing rules via API — no dashboard clicks.

Looks up the zone ID for a domain, enables Email Routing on the zone (publishing the MX + SPF records Cloudflare needs), and creates/updates a rule sending mail for a specific address to a given Worker.

Required API token scopes:

Zone  Zone  Read          (to look up zone by name)
Zone  Email Routing  Edit (to enable routing and add rules)

Usage:

provisioner = Cloudflare::Email::RoutingProvisioner.new(
  api_token: ENV["CLOUDFLARE_API_TOKEN"],
)
provisioner.provision(
  address: "cole@in.example.com",
  worker_name: "cloudflare-email-ingress-production",
)

Constant Summary collapse

API_BASE =
"https://api.cloudflare.com/client/v4".freeze

Instance Method Summary collapse

Constructor Details

#initialize(api_token:, api_base: API_BASE) ⇒ RoutingProvisioner

Returns a new instance of RoutingProvisioner.

Raises:

  • (ArgumentError)


28
29
30
31
32
# File 'lib/cloudflare/email/routing_provisioner.rb', line 28

def initialize(api_token:, api_base: API_BASE)
  raise ArgumentError, "api_token is required" if api_token.to_s.empty?
  @api_token = api_token
  @api_base  = api_base
end

Instance Method Details

#enable_routing_if_needed(zone_id) ⇒ Object



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/cloudflare/email/routing_provisioner.rb', line 61

def enable_routing_if_needed(zone_id)
  # This endpoint requires the "Email Routing Settings" permission group,
  # which most scoped tokens don't carry. If we can read the setting,
  # enable when off. If we can't (403), assume the user enabled routing
  # via the dashboard when they added the subdomain — the subsequent
  # rule create will fail with a clear error if not.
  current = raw_api_request(:get, "/zones/#{zone_id}/email/routing")
  status  = current.code.to_i

  case status
  when 200
    body    = parse(current.body)
    enabled = body.dig("result", "enabled")
    api_request(:post, "/zones/#{zone_id}/email/routing/enable") unless enabled
  when 403, 404
    # Either the token can't read settings or routing isn't set up.
    # Try to enable optimistically; ignore failure (rule create will
    # surface a precise error if routing is actually off).
    attempt = raw_api_request(:post, "/zones/#{zone_id}/email/routing/enable")
    # Don't fail here even if this also 403s — move on to rule creation.
  else
    handle!(current, "GET /zones/#{zone_id}/email/routing")
  end
end

#find_rule_for(zone_id:, address:) ⇒ Object



112
113
114
115
116
117
118
119
120
# File 'lib/cloudflare/email/routing_provisioner.rb', line 112

def find_rule_for(zone_id:, address:)
  result = api_request(:get, "/zones/#{zone_id}/email/routing/rules?per_page=50")
  rules  = Array(result["result"])

  rules.find do |r|
    matchers = Array(r["matchers"])
    matchers.any? { |m| m["field"] == "to" && m["value"] == address }
  end
end

#find_zone_id_for(domain) ⇒ Object



46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/cloudflare/email/routing_provisioner.rb', line 46

def find_zone_id_for(domain)
  # Try the exact domain, then walk up parent domains until we find a
  # Cloudflare zone. Supports subdomains like "in.example.com" routing
  # to the "example.com" zone.
  candidates = expand_parent_domains(domain)

  candidates.each do |candidate|
    result = api_request(:get, "/zones?name=#{URI.encode_www_form_component(candidate)}")
    zones = Array(result["result"])
    return zones.first["id"] if zones.any?
  end

  nil
end

#list_rules(zone_id) ⇒ Object



122
123
124
125
# File 'lib/cloudflare/email/routing_provisioner.rb', line 122

def list_rules(zone_id)
  result = api_request(:get, "/zones/#{zone_id}/email/routing/rules?per_page=50")
  Array(result["result"])
end

#provision(address:, worker_name:) ⇒ Object

High-level: given an address + Worker name, do everything needed to make that address route to that Worker. Idempotent — running twice is safe and will update the existing rule rather than duplicate it.

Raises:



37
38
39
40
41
42
43
44
# File 'lib/cloudflare/email/routing_provisioner.rb', line 37

def provision(address:, worker_name:)
  domain   = extract_domain(address)
  zone_id  = find_zone_id_for(domain)
  raise Error.new("No Cloudflare zone found for #{domain} — add the domain to your account first") unless zone_id

  enable_routing_if_needed(zone_id)
  upsert_route(zone_id: zone_id, address: address, worker_name: worker_name)
end

#provision_catch_all(zone_id:, worker_name:) ⇒ Object

Point the zone’s catch-all rule at our Worker. Catch-all matches any address on the zone that isn’t covered by a more specific rule. Useful for bounce handling, dev subdomains, alias routing.



130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/cloudflare/email/routing_provisioner.rb', line 130

def provision_catch_all(zone_id:, worker_name:)
  api_request(
    :put,
    "/zones/#{zone_id}/email/routing/rules/catch_all",
    body: {
      name:     "cloudflare-email gem — catch-all",
      enabled:  true,
      matchers: [{ type: "all" }],
      actions:  [{ type: "worker", value: [worker_name] }],
    },
  )
end

#provision_catch_all_for_domain(domain:, worker_name:) ⇒ Object

Raises:



143
144
145
146
147
148
149
# File 'lib/cloudflare/email/routing_provisioner.rb', line 143

def provision_catch_all_for_domain(domain:, worker_name:)
  zone_id = find_zone_id_for(domain)
  raise Error.new("No Cloudflare zone found for #{domain}") unless zone_id

  enable_routing_if_needed(zone_id)
  provision_catch_all(zone_id: zone_id, worker_name: worker_name)
end

#upsert_route(zone_id:, address:, worker_name:) ⇒ Object



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/cloudflare/email/routing_provisioner.rb', line 86

def upsert_route(zone_id:, address:, worker_name:)
  existing = find_rule_for(zone_id: zone_id, address: address)

  rule = {
    name:     "cloudflare-email gem — #{address}",
    enabled:  true,
    priority: 0,
    matchers: [{ field: "to", type: "literal", value: address }],
    actions:  [{ type: "worker", value: [worker_name] }],
  }

  if existing
    api_request(
      :put,
      "/zones/#{zone_id}/email/routing/rules/#{existing['id']}",
      body: rule,
    )
  else
    api_request(
      :post,
      "/zones/#{zone_id}/email/routing/rules",
      body: rule,
    )
  end
end