Skip to content

Commit 5c25f2f

Browse files
authored
Merge pull request #10593 from neinteractiveliterature/cloudflare-email-forwarder
CloudFlare email forwarding
2 parents ad39547 + e6f198c commit 5c25f2f

15 files changed

Lines changed: 6746 additions & 75 deletions

.env.development

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ DEVELOPMENT_DATABASE_URL=postgresql://localhost/intercode_development
3535
INTERCODE_HOST=intercode.test
3636
TEST_DATABASE_URL=postgresql://localhost/intercode_test
3737
UPLOADS_HOST=uploads.intercode.test:5050
38+
EMAIL_FORWARDERS_API_TOKEN=abc123

.github/workflows/release.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ jobs:
3838
- run: flyctl deploy --remote-only -a intercode
3939
env:
4040
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
41+
cloudflare-release:
42+
runs-on: ubuntu-latest
43+
steps:
44+
- name: Checkout code
45+
uses: actions/checkout@v4
46+
- name: Deploy to Cloudflare
47+
uses: cloudflare/wrangler-action@v3
48+
with:
49+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
50+
workingDirectory: ./cloudflare
4151
notify-slack:
4252
runs-on: ubuntu-latest
4353
needs:
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
class EmailForwardersController < ApplicationController
2+
before_action :enforce_authentication
3+
4+
def show
5+
render json: { forward_addresses: EmailForwardingRouter.new(params[:address]).forward_addresses }
6+
end
7+
8+
private
9+
10+
def enforce_authentication
11+
authenticate_or_request_with_http_token do |token|
12+
ActiveSupport::SecurityUtils.secure_compare(token, ENV.fetch("EMAIL_FORWARDERS_API_TOKEN"))
13+
end
14+
end
15+
end
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
class EmailForwardingRouter
2+
attr_reader :address
3+
4+
def initialize(address)
5+
@address = EmailRoute.normalize_address(address)
6+
end
7+
8+
def convention_by_domain
9+
return @convention_by_domain if defined?(@convention_by_domain)
10+
@convention_by_domain ||= Convention.find_by(domain: Mail::Address.new(address).domain)
11+
end
12+
13+
def convention_by_events_domain
14+
return @convention_by_events_domain if defined?(@convention_by_events_domain)
15+
@convention_by_events_domain ||= Convention.find_by(event_mailing_list_domain: Mail::Address.new(address).domain)
16+
end
17+
18+
def forward_addresses
19+
[
20+
*staff_positions_for_recipient.flat_map { |sp| sp.user_con_profiles.map(&:email) + sp.cc_addresses },
21+
*team_members_for_recipient.flat_map { |tm| tm.user_con_profile.email },
22+
*email_routes_for_recipient.flat_map(&:forward_addresses)
23+
].compact.uniq
24+
end
25+
26+
def staff_positions_for_recipient
27+
@staff_positions_for_recipient ||=
28+
if convention_by_domain
29+
if convention_by_domain.email_mode == "staff_emails_to_catch_all"
30+
[convention_by_domain.catch_all_staff_position].compact
31+
else
32+
matched_positions =
33+
convention_by_domain
34+
.staff_positions
35+
.includes(user_con_profiles: :user)
36+
.select do |sp|
37+
full_email_aliases = sp.email_aliases.map { |ea| "#{ea}@#{convention_by_domain.domain}" }
38+
destinations = [sp.email, *full_email_aliases].map { |dest| EmailRoute.normalize_address(dest) }
39+
destinations.include?(address)
40+
end
41+
42+
matched_positions.any? ? matched_positions : [convention_by_domain.catch_all_staff_position].compact
43+
end
44+
else
45+
[]
46+
end
47+
end
48+
49+
def team_members_for_recipient
50+
@team_members_for_recipient ||=
51+
if convention_by_events_domain
52+
events =
53+
convention_by_events_domain.events.select do |event|
54+
next if event.team_mailing_list_name.blank?
55+
full_alias = "#{event.team_mailing_list_name}@#{convention_by_events_domain.event_mailing_list_domain}"
56+
EmailRoute.normalize_address(full_alias) == address
57+
end
58+
TeamMember.where(event_id: events.map(&:id)).includes(user_con_profile: :user).to_a
59+
else
60+
[]
61+
end
62+
end
63+
64+
def email_routes_for_recipient
65+
@email_routes_for_recipient ||= EmailRoute.where(receiver_address: address)
66+
end
67+
end

app/services/receive_email_service.rb renamed to app/services/forward_email_via_ses_service.rb

Lines changed: 15 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
2-
class ReceiveEmailService < CivilService::Service
2+
class ForwardEmailViaSesService < CivilService::Service
33
def self.ses_client
44
@ses_client ||= Aws::SES::Client.new
55
end
@@ -27,7 +27,7 @@ def inner_call
2727
forward_addresses = forward_addresses_for_recipient(recipient)
2828

2929
if forward_addresses
30-
Rails.logger.debug { "Forwarding mail for #{recipient} -> #{forward_addresses.join(', ')}" }
30+
Rails.logger.debug { "Forwarding mail for #{recipient} -> #{forward_addresses.join(", ")}" }
3131
forward_email(recipient, forward_addresses)
3232
elsif intercode_address?(recipient)
3333
Rails.logger.warn("Could not find matching route for #{recipient}, sending bounce")
@@ -68,62 +68,20 @@ def intercode_domains
6868
end
6969

7070
def forward_addresses_for_recipient(recipient)
71-
address = EmailRoute.normalize_address(recipient)
72-
73-
staff_positions = staff_positions_for_recipient(address)
74-
return staff_positions.flat_map { |sp| sp.user_con_profiles.map(&:email) + sp.cc_addresses } if staff_positions
75-
76-
team_members = team_members_for_recipient(address)
77-
return team_members.flat_map { |tm| tm.user_con_profile.email } if team_members
78-
79-
route = EmailRoute.find_by(receiver_address: address)
80-
route&.forward_addresses
81-
end
82-
83-
def staff_positions_for_recipient(address)
84-
convention = Convention.find_by(domain: Mail::Address.new(address).domain)
85-
return nil unless convention
86-
87-
return [convention.catch_all_staff_position].compact if convention.email_mode == 'staff_emails_to_catch_all'
88-
89-
matched_positions =
90-
convention
91-
.staff_positions
92-
.includes(user_con_profiles: :user)
93-
.select do |sp|
94-
full_email_aliases = sp.email_aliases.map { |ea| "#{ea}@#{convention.domain}" }
95-
destinations = [sp.email, *full_email_aliases].map { |dest| EmailRoute.normalize_address(dest) }
96-
destinations.include?(address)
97-
end
98-
return matched_positions if matched_positions.any?
99-
100-
[convention.catch_all_staff_position].compact
101-
end
102-
103-
def team_members_for_recipient(address)
104-
convention = Convention.find_by(event_mailing_list_domain: Mail::Address.new(address).domain)
105-
return nil unless convention
106-
107-
events =
108-
convention.events.select do |event|
109-
next if event.team_mailing_list_name.blank?
110-
full_alias = "#{event.team_mailing_list_name}@#{convention.event_mailing_list_domain}"
111-
EmailRoute.normalize_address(full_alias) == address
112-
end
113-
TeamMember.where(event_id: events.map(&:id)).includes(user_con_profile: :user).to_a
71+
EmailForwardingRouter.new(recipient).forward_addresses
11472
end
11573

11674
def headers_for_forwarding(forward_message)
11775
{
118-
'X-Intercode-Original-Return-Path' => forward_message.header['Return-Path'],
119-
'Return-Path' => "bounces@#{mailer_host}",
120-
'X-Intercode-Original-Sender' => forward_message.header['Sender'],
121-
'Sender' => nil,
122-
'X-Intercode-Original-Source' => forward_message.header['Source'],
123-
'Source' => nil,
124-
'DKIM-Signature' => nil, # SES will re-sign the message for us
125-
'X-SES-CONFIGURATION-SET' => 'default',
126-
'X-Intercode-Message-ID' => message_id
76+
"X-Intercode-Original-Return-Path" => forward_message.header["Return-Path"],
77+
"Return-Path" => "bounces@#{mailer_host}",
78+
"X-Intercode-Original-Sender" => forward_message.header["Sender"],
79+
"Sender" => nil,
80+
"X-Intercode-Original-Source" => forward_message.header["Source"],
81+
"Source" => nil,
82+
"DKIM-Signature" => nil, # SES will re-sign the message for us
83+
"X-SES-CONFIGURATION-SET" => "default",
84+
"X-Intercode-Message-ID" => message_id
12785
}
12886
end
12987

@@ -166,8 +124,8 @@ def forward_email(original_recipient, new_recipients)
166124
begin
167125
self.class.ses_client.send_raw_email({ raw_message: { data: forward_message.to_s } })
168126
rescue Aws::SES::Errors::InvalidParameterValue => e
169-
if e.message.include?('Message length is more')
170-
send_bounce(original_recipient, bounce_type: 'MessageTooLarge')
127+
if e.message.include?("Message length is more")
128+
send_bounce(original_recipient, bounce_type: "MessageTooLarge")
171129
else
172130
send_bounce(original_recipient)
173131
end
@@ -178,7 +136,7 @@ def mailer_host
178136
Rails.application.config.action_mailer.default_url_options[:host]
179137
end
180138

181-
def send_bounce(recipient, bounce_type: 'DoesNotExist')
139+
def send_bounce(recipient, bounce_type: "DoesNotExist")
182140
self.class.ses_client.send_bounce(
183141
{
184142
original_message_id: message_id,

app/services/receive_sns_email_delivery_service.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def inner_call
3434
end
3535

3636
begin
37-
ReceiveEmailService.new(recipients:, load_email: -> { email }, message_id:).call
37+
ForwardEmailViaSesService.new(recipients:, load_email: -> { email }, message_id:).call
3838
rescue StandardError => e
3939
ErrorReporting.error(e, recipients:, message_id:)
4040
raise e

cloudflare/.dev.vars

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
EMAIL_FORWARDERS_API_TOKEN="abc123"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { EmailMessage } from 'cloudflare:email';
2+
3+
async function getDestinations(message: EmailMessage, env: Env): Promise<string[]> {
4+
const recipient = message.to.toLowerCase();
5+
const url = new URL(`/email_forwarders/${message.to}`, env.INTERCODE_URL);
6+
const response = await fetch(url, {
7+
method: 'GET',
8+
headers: {
9+
Authorization: `Token ${env.EMAIL_FORWARDERS_API_TOKEN}`,
10+
},
11+
});
12+
13+
if (!response.ok) {
14+
console.error(`Failed to fetch destinations for ${recipient}:`, response.statusText);
15+
return [];
16+
}
17+
18+
const json = (await response.json()) as { forward_addresses?: string[] };
19+
return json.forward_addresses || [];
20+
}
21+
22+
export default {
23+
async email(message, env) {
24+
const forwardList = await getDestinations(message, env);
25+
26+
if (forwardList.length > 0) {
27+
const promises = forwardList.map(async (email) => {
28+
try {
29+
await message.forward(email);
30+
console.log(`Forwarded email from ${message.from} to ${email}`);
31+
} catch (error) {
32+
console.error(`Failed to forward email from ${message.from} to ${email}:`, error);
33+
}
34+
});
35+
await Promise.all(promises);
36+
} else {
37+
message.setReject('Unknown destination address');
38+
}
39+
},
40+
} satisfies ExportedHandler<Env>;

cloudflare/tsconfig.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"compilerOptions": {
3+
"lib": ["ES2020"],
4+
"types": ["./worker-configuration.d.ts"]
5+
}
6+
}

0 commit comments

Comments
 (0)