Skip to content

Commit fd1a03b

Browse files
authored
Merge pull request #2592 from mroderick/feature/delayed-email-async
feat: add configurable async email delivery for workshop invitations
2 parents 117eb23 + 6285351 commit fd1a03b

7 files changed

Lines changed: 155 additions & 13 deletions

File tree

app/jobs/application_job.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
class ApplicationJob < ActiveJob::Base
2+
rescue_from(Exception) do |exception|
3+
Rails.error.report(exception)
4+
raise
5+
end
26
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module AsyncEmailConcern
2+
extend ActiveSupport::Concern
3+
4+
private
5+
6+
def async_email_enabled?(chapter)
7+
return false if chapter.nil?
8+
return false if Rails.application.config.async_email_chapter_ids.empty?
9+
Rails.application.config.async_email_chapter_ids.include?(chapter.id)
10+
end
11+
end

app/models/concerns/workshop_invitation_manager_concerns.rb

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ module WorkshopInvitationManagerConcerns
22
extend ActiveSupport::Concern
33

44
included do
5+
include AsyncEmailConcern
56
include InstanceMethods
67
end
78

89
module InstanceMethods
910
def send_workshop_attendance_reminders(workshop)
1011
workshop_mailer = workshop.virtual? ? VirtualWorkshopInvitationMailer : WorkshopInvitationMailer
1112
workshop.attendances.not_reminded.each do |invitation|
12-
workshop_mailer.send(:attending_reminder, workshop, invitation.member, invitation).deliver_now
13+
deliver_method = async_email_enabled?(workshop.chapter) ? :deliver_later : :deliver_now
14+
workshop_mailer.send(:attending_reminder, workshop, invitation.member, invitation).public_send(deliver_method)
1315
invitation.update(reminded_at: Time.zone.now)
1416
end
1517
end
@@ -86,7 +88,8 @@ def send_waiting_list_emails(workshop)
8688
def send_workshop_waiting_list_reminders(workshop)
8789
workshop_mailer = workshop.virtual? ? VirtualWorkshopInvitationMailer : WorkshopInvitationMailer
8890
workshop.invitations.on_waiting_list.not_reminded.each do |invitation|
89-
workshop_mailer.send(:waiting_list_reminder, workshop, invitation.member, invitation).deliver_now
91+
deliver_method = async_email_enabled?(workshop.chapter) ? :deliver_later : :deliver_now
92+
workshop_mailer.send(:waiting_list_reminder, workshop, invitation.member, invitation).public_send(deliver_method)
9093
invitation.update(reminded_at: Time.zone.now)
9194
end
9295
end
@@ -113,26 +116,30 @@ def log_invitation_failure(workshop, member, role, error)
113116
end
114117

115118
def invite_coaches_to_virtual_workshop(workshop, logger = nil)
119+
deliver_method = async_email_enabled?(workshop.chapter) ? :deliver_later : :deliver_now
116120
invite_members(workshop, logger, chapter_coaches(workshop.chapter)) do |coach, invitation|
117-
VirtualWorkshopInvitationMailer.invite_coach(workshop, coach, invitation).deliver_now
121+
VirtualWorkshopInvitationMailer.invite_coach(workshop, coach, invitation).public_send(deliver_method)
118122
end
119123
end
120124

121125
def invite_coaches_to_workshop(workshop, logger = nil)
126+
deliver_method = async_email_enabled?(workshop.chapter) ? :deliver_later : :deliver_now
122127
invite_members(workshop, logger, chapter_coaches(workshop.chapter)) do |coach, invitation|
123-
WorkshopInvitationMailer.invite_coach(workshop, coach, invitation).deliver_now
128+
WorkshopInvitationMailer.invite_coach(workshop, coach, invitation).public_send(deliver_method)
124129
end
125130
end
126131

127132
def invite_students_to_virtual_workshop(workshop, logger = nil)
133+
deliver_method = async_email_enabled?(workshop.chapter) ? :deliver_later : :deliver_now
128134
invite_members(workshop, logger, chapter_students(workshop.chapter), 'Student') do |student, invitation|
129-
VirtualWorkshopInvitationMailer.invite_student(workshop, student, invitation).deliver_now
135+
VirtualWorkshopInvitationMailer.invite_student(workshop, student, invitation).public_send(deliver_method)
130136
end
131137
end
132138

133139
def invite_students_to_workshop(workshop, logger = nil)
140+
deliver_method = async_email_enabled?(workshop.chapter) ? :deliver_later : :deliver_now
134141
invite_members(workshop, logger, chapter_students(workshop.chapter), 'Student') do |member, invitation|
135-
WorkshopInvitationMailer.invite_student(workshop, member, invitation).deliver_now
142+
WorkshopInvitationMailer.invite_student(workshop, member, invitation).public_send(deliver_method)
136143
end
137144
end
138145

@@ -169,7 +176,8 @@ def send_email_with_logging(logger, member, invitation)
169176

170177
def retrieve_and_notify_waitlisted(workshop, role:)
171178
WaitingList.by_workshop(workshop).where_role(role).each do |waiting_list|
172-
WorkshopInvitationMailer.notify_waiting_list(waiting_list.invitation).deliver_now
179+
deliver_method = async_email_enabled?(waiting_list.invitation.workshop.chapter) ? :deliver_later : :deliver_now
180+
WorkshopInvitationMailer.notify_waiting_list(waiting_list.invitation).public_send(deliver_method)
173181
waiting_list.destroy
174182
end
175183
end

config/application.rb

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,18 @@ class Application < Rails::Application
3131
# and https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
3232
config.active_record.yaml_column_permitted_classes = [Symbol, Date, Time, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone, ActiveSupport::HashWithIndifferentAccess]
3333

34-
config.active_record.belongs_to_required_by_default = true
34+
config.active_record.belongs_to_required_by_default = true
3535

36-
if ENV["RAILS_LOG_TO_STDOUT"].present?
36+
# ActiveJob adapter for async email delivery
37+
config.active_job.queue_adapter = :delayed_job
38+
39+
# Feature flag: chapters that use async email delivery
40+
# Empty = no chapters use async (all sync)
41+
# "1" = only chapter 1 uses async
42+
# "1,7" = chapters 1 and 7 use async
43+
config.async_email_chapter_ids = ENV['ASYNC_EMAIL_CHAPTER_IDS']&.split(',')&.map(&:to_i) || []
44+
45+
if ENV["RAILS_LOG_TO_STDOUT"].present?
3746
$stdout.sync = true
3847
config.rails_semantic_logger.add_file_appender = false
3948
config.semantic_logger.add_appender(io: $stdout, formatter: config.rails_semantic_logger.format)

config/environments/test.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@
1111
# While tests run files are not watched, reloading is not necessary.
1212
config.enable_reloading = false
1313

14-
# Eager loading loads your entire application. When running a single test locally,
15-
# this is usually not necessary, and can slow down your test suite. However, it's
16-
# recommended that you enable it in continuous integration systems to ensure eager
17-
# loading is working properly before deploying your code.
14+
# Eager loading loads your entire application.
1815
config.eager_load = ENV['CI'].present?
1916

17+
# Use delayed_job adapter for async email tests
18+
config.active_job.queue_adapter = :delayed_job
19+
2020
# Configure public file server for tests with cache-control for performance.
2121
config.public_file_server.headers = { 'cache-control' => "public, max-age=#{1.hour.to_i}" }
2222

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
RSpec.describe AsyncEmailConcern do
2+
let(:concern_class) do
3+
Class.new { include AsyncEmailConcern }
4+
end
5+
let(:instance) { concern_class.new }
6+
7+
describe "#async_email_enabled?" do
8+
context "when ASYNC_EMAIL_CHAPTER_IDS is empty" do
9+
before { Rails.application.config.async_email_chapter_ids = [] }
10+
11+
it "returns false" do
12+
expect(instance.send(:async_email_enabled?, OpenStruct.new(id: 1))).to be false
13+
end
14+
end
15+
16+
context "when chapter is in async list" do
17+
before { Rails.application.config.async_email_chapter_ids = [1, 7] }
18+
19+
it "returns true for matching chapter" do
20+
expect(instance.send(:async_email_enabled?, OpenStruct.new(id: 1))).to be true
21+
end
22+
23+
it "returns false for non-matching chapter" do
24+
expect(instance.send(:async_email_enabled?, OpenStruct.new(id: 2))).to be false
25+
end
26+
end
27+
28+
context "when chapter is nil" do
29+
it "returns false" do
30+
expect(instance.send(:async_email_enabled?, nil)).to be false
31+
end
32+
end
33+
end
34+
end

spec/models/invitation_manager_spec.rb

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,4 +364,80 @@
364364
expect(log.skipped_count).to eq(students.count)
365365
end
366366
end
367+
368+
describe '#send_workshop_emails async behavior' do
369+
let!(:chapter) { Fabricate(:chapter, id: 1) }
370+
371+
context 'when chapter is in ASYNC_EMAIL_CHAPTER_IDS' do
372+
before { Rails.application.config.async_email_chapter_ids = [1] }
373+
374+
it 'sends invitation emails' do
375+
Fabricate(:students, chapter: chapter, members: students)
376+
Fabricate(:coaches, chapter: chapter, members: coaches)
377+
378+
expect(WorkshopInvitationMailer).to receive(:invite_student).at_least(:once).and_call_original
379+
expect(WorkshopInvitationMailer).to receive(:invite_coach).at_least(:once).and_call_original
380+
381+
manager.send_workshop_emails(workshop, 'everyone')
382+
end
383+
end
384+
385+
context 'when chapter is NOT in ASYNC_EMAIL_CHAPTER_IDS' do
386+
before { Rails.application.config.async_email_chapter_ids = [99] }
387+
388+
it 'sends emails synchronously' do
389+
Fabricate(:students, chapter: chapter, members: students)
390+
Fabricate(:coaches, chapter: chapter, members: coaches)
391+
392+
expect do
393+
manager.send_workshop_emails(workshop, 'everyone')
394+
end.to change { ActionMailer::Base.deliveries.count }.by(students.count + coaches.count)
395+
396+
expect(Delayed::Job.count).to eq(0)
397+
end
398+
end
399+
400+
context 'when ASYNC_EMAIL_CHAPTER_IDS is empty' do
401+
before { Rails.application.config.async_email_chapter_ids = [] }
402+
403+
it 'sends emails synchronously' do
404+
Fabricate(:students, chapter: chapter, members: students)
405+
Fabricate(:coaches, chapter: chapter, members: coaches)
406+
407+
expect do
408+
manager.send_workshop_emails(workshop, 'everyone')
409+
end.to change { ActionMailer::Base.deliveries.count }.by(students.count + coaches.count)
410+
411+
expect(Delayed::Job.count).to eq(0)
412+
end
413+
end
414+
end
415+
416+
describe '#send_workshop_attendance_reminders async behavior' do
417+
let!(:chapter) { Fabricate(:chapter, id: 1) }
418+
419+
context 'when chapter is in ASYNC_EMAIL_CHAPTER_IDS' do
420+
before { Rails.application.config.async_email_chapter_ids = [1] }
421+
422+
it 'sends attendance reminder emails' do
423+
invitation = Fabricate(:attending_workshop_invitation, workshop: workshop)
424+
425+
expect(WorkshopInvitationMailer).to receive(:attending_reminder).at_least(:once).and_call_original
426+
427+
manager.send_workshop_attendance_reminders(workshop)
428+
end
429+
end
430+
431+
context 'when chapter is NOT in ASYNC_EMAIL_CHAPTER_IDS' do
432+
before { Rails.application.config.async_email_chapter_ids = [99] }
433+
434+
it 'uses deliver_now' do
435+
invitation = Fabricate(:attending_workshop_invitation, workshop: workshop)
436+
437+
expect do
438+
manager.send_workshop_attendance_reminders(workshop)
439+
end.to change { ActionMailer::Base.deliveries.count }.by(1)
440+
end
441+
end
442+
end
367443
end

0 commit comments

Comments
 (0)