diff --git a/.gitignore b/.gitignore index b22c5ea101..fb7c675d55 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,4 @@ jmeter_test/jmeter.log .env mise.toml +CLAUDE.md \ No newline at end of file diff --git a/app/jobs/campaign/send_campaign_email_job.rb b/app/jobs/campaign/send_campaign_email_job.rb index 890d51a4b4..18f531cf87 100644 --- a/app/jobs/campaign/send_campaign_email_job.rb +++ b/app/jobs/campaign/send_campaign_email_job.rb @@ -8,6 +8,7 @@ def perform(email_id) return unless email && contact return if email.mailgun_message_id.present? + return if max_sends_reached?(email) send_at = email.scheduled_send_at || Time.current if send_at > Time.current @@ -34,4 +35,22 @@ def perform(email_id) def priority PRIORITY_LOW end + + private + + def max_sends_reached?(email) + max_sends = campaign_message_class(email).max_sends_per_contact + return false if max_sends.nil? + + sent_count = CampaignEmail.where( + campaign_contact_id: email.campaign_contact_id, + message_name: email.message_name + ).where.not(sent_at: nil).count + + sent_count >= max_sends + end + + def campaign_message_class(email) + "CampaignMessage::#{email.message_name.camelize}".constantize + end end \ No newline at end of file diff --git a/app/models/campaign_email.rb b/app/models/campaign_email.rb index 6bec78b50a..4aafad18d9 100644 --- a/app/models/campaign_email.rb +++ b/app/models/campaign_email.rb @@ -20,7 +20,7 @@ # Indexes # # index_campaign_emails_on_campaign_contact_id (campaign_contact_id) -# index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) UNIQUE +# index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) # index_campaign_emails_on_mailgun_message_id (mailgun_message_id) UNIQUE # # Foreign Keys diff --git a/app/models/campaign_message/campaign_message.rb b/app/models/campaign_message/campaign_message.rb index bcbb9353d2..748d23a426 100644 --- a/app/models/campaign_message/campaign_message.rb +++ b/app/models/campaign_message/campaign_message.rb @@ -7,5 +7,9 @@ def vars(contact) locale: contact&.locale || :en, } end + + def self.max_sends_per_contact + 1 + end end end \ No newline at end of file diff --git a/app/models/campaign_message/diy_followup_survey.rb b/app/models/campaign_message/diy_followup_survey.rb index a6e59eb6f8..856263cbe0 100644 --- a/app/models/campaign_message/diy_followup_survey.rb +++ b/app/models/campaign_message/diy_followup_survey.rb @@ -19,5 +19,9 @@ def email_body(contact:, **args) I18n.t("campaign_messages.diy_followup_survey.email.body_html", **vars(contact), **args) end + + def self.max_sends_per_contact + 2 + end end end \ No newline at end of file diff --git a/app/models/campaign_message/start_of_season_outreach.rb b/app/models/campaign_message/start_of_season_outreach.rb index 69add9e1e6..1f30e42725 100644 --- a/app/models/campaign_message/start_of_season_outreach.rb +++ b/app/models/campaign_message/start_of_season_outreach.rb @@ -12,6 +12,9 @@ def email_subject(contact:, **args) I18n.t("campaign_messages.start_of_season_outreach.email.subject", **vars(contact), **args) end + def self.max_sends_per_contact + 1 + end def email_body(contact:, **args) I18n.t("campaign_messages.start_of_season_outreach.email.body_html", **vars(contact), **args) end diff --git a/db/migrate/20260320232943_remove_unique_index_from_campaign_emails.rb b/db/migrate/20260320232943_remove_unique_index_from_campaign_emails.rb new file mode 100644 index 0000000000..5770b23973 --- /dev/null +++ b/db/migrate/20260320232943_remove_unique_index_from_campaign_emails.rb @@ -0,0 +1,22 @@ +class RemoveUniqueIndexFromCampaignEmails < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def up + remove_index :campaign_emails, name: "index_campaign_emails_on_contact_id_and_message_name" + + add_index :campaign_emails, + [:campaign_contact_id, :message_name], + name: "index_campaign_emails_on_contact_id_and_message_name", + algorithm: :concurrently + end + + def down + remove_index :campaign_emails, name: "index_campaign_emails_on_contact_id_and_message_name" + + add_index :campaign_emails, + [:campaign_contact_id, :message_name], + name: "index_campaign_emails_on_contact_id_and_message_name", + unique: true, + algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index d3098f8c88..1cbca9452e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_03_03_001000) do +ActiveRecord::Schema[7.1].define(version: 2026_03_20_232943) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "plpgsql" @@ -615,7 +615,7 @@ t.text "subject" t.string "to_email" t.datetime "updated_at", null: false - t.index ["campaign_contact_id", "message_name"], name: "index_campaign_emails_on_contact_id_and_message_name", unique: true + t.index ["campaign_contact_id", "message_name"], name: "index_campaign_emails_on_contact_id_and_message_name" t.index ["campaign_contact_id"], name: "index_campaign_emails_on_campaign_contact_id" t.index ["mailgun_message_id"], name: "index_campaign_emails_on_mailgun_message_id", unique: true end diff --git a/spec/factories/campaign_emails.rb b/spec/factories/campaign_emails.rb index 67a0d11cc6..2d18c07a93 100644 --- a/spec/factories/campaign_emails.rb +++ b/spec/factories/campaign_emails.rb @@ -20,7 +20,7 @@ # Indexes # # index_campaign_emails_on_campaign_contact_id (campaign_contact_id) -# index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) UNIQUE +# index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) # index_campaign_emails_on_mailgun_message_id (mailgun_message_id) UNIQUE # # Foreign Keys diff --git a/spec/jobs/campaign/send_campaign_email_job_spec.rb b/spec/jobs/campaign/send_campaign_email_job_spec.rb index e469e049bf..0877ac9b57 100644 --- a/spec/jobs/campaign/send_campaign_email_job_spec.rb +++ b/spec/jobs/campaign/send_campaign_email_job_spec.rb @@ -32,6 +32,82 @@ end end + context "when max sends for the message has already been reached" do + let(:email) do + create( + :campaign_email, + campaign_contact: contact, + message_name: "start_of_season_outreach", + mailgun_message_id: nil, + scheduled_send_at: nil + ) + end + + before do + create( + :campaign_email, + campaign_contact: contact, + message_name: "start_of_season_outreach", + sent_at: 1.hour.ago, + mailgun_message_id: "" + ) + end + + it "does not send the email" do + expect(CampaignMailer).not_to receive(:email_message) + + perform_job + end + + it "does not update the email as sent" do + expect { perform_job }.not_to change { email.reload.mailgun_message_id } + expect(email.reload.sent_at).to be_nil + end + end + + context "when max sends for the message has not been reached" do + let(:email) do + create( + :campaign_email, + campaign_contact: contact, + message_name: "diy_followup_survey", + mailgun_message_id: nil, + scheduled_send_at: nil + ) + end + + before do + create( + :campaign_email, + campaign_contact: contact, + message_name: "diy_followup_survey", + sent_at: 1.hour.ago, + mailgun_message_id: "" + ) + end + + it "still sends the email" do + response = instance_double( + "Mail::Message", + message_id: "", + to: ["a@example.com"], + from: ["noreply@example.com"], + subject: "Hello!", + date: Time.current + ) + + mailer_delivery = instance_double("MailerDelivery", deliver_now: response) + + expect(CampaignMailer).to receive(:email_message).with( + campaign_email: email + ).and_return(mailer_delivery) + + perform_job + + expect(email.reload.mailgun_message_id).to eq("") + end + end + context "when scheduled_send_at is blank (send now)" do it "sends via CampaignMailer with locale fallback and updates email fields" do response = instance_double( @@ -143,4 +219,4 @@ expect(described_class.new.priority).to eq(100) end end -end +end \ No newline at end of file diff --git a/spec/models/campaign_email_spec.rb b/spec/models/campaign_email_spec.rb index 43a30ee29f..6e831a0d4f 100644 --- a/spec/models/campaign_email_spec.rb +++ b/spec/models/campaign_email_spec.rb @@ -20,7 +20,7 @@ # Indexes # # index_campaign_emails_on_campaign_contact_id (campaign_contact_id) -# index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) UNIQUE +# index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) # index_campaign_emails_on_mailgun_message_id (mailgun_message_id) UNIQUE # # Foreign Keys