From d978c792263393113c335a01c2a6db3f744077ee Mon Sep 17 00:00:00 2001 From: Jonathan Gottfried Date: Tue, 22 Sep 2026 19:49:55 -0400 Subject: [PATCH 1/2] Stop minting email-keyed Customer.io people for unlinked users (#23860) Mail sent to a new DEV user before their mlh identity landed was keyed by email, which Customer.io stored as the person's id, leaving an orphan beside the Core-keyed person that nothing ever merges. - Hold Customer.io mail for users without an mlh identity and replay the same mailer call with backoff (~1h cap) until the link lands, then send keyed by the Core uid. Never falls back to email or SMTP. - Stop sending confirmation instructions to users confirmed at creation (OAuth signups): confirmation_required? dropped Devise's !confirmed?. - Data update script merging existing email-keyed orphans into their linked person. Claude-Session: https://claude.ai/code/session_01MprdoWHE671sCWu7kJfTda Entire-Checkpoint: 01M35QSR672XNCZS7AS5YJSBYF --- app/mailers/concerns/deliverable.rb | 46 +++++++++++++++++-- app/models/user.rb | 4 +- ...0_merge_email_keyed_customerio_profiles.rb | 31 +++++++++++++ ...ge_email_keyed_customerio_profiles_spec.rb | 39 ++++++++++++++++ spec/mailers/concerns/deliverable_spec.rb | 41 ++++++++++++----- spec/mailers/custom_mailer_spec.rb | 2 + spec/mailers/devise_mailer_spec.rb | 27 +++++++++++ spec/mailers/digest_mailer_spec.rb | 1 + spec/mailers/notify_mailer_spec.rb | 18 ++++++++ .../organization_invitation_mailer_spec.rb | 2 + ...ion_membership_notification_mailer_spec.rb | 2 + spec/mailers/survey_mailer_spec.rb | 4 ++ spec/mailers/verification_mailer_spec.rb | 1 + spec/models/user_spec.rb | 16 +++++++ spec/support/omniauth_helpers.rb | 7 +++ 15 files changed, 224 insertions(+), 17 deletions(-) create mode 100644 lib/data_update_scripts/20260922120000_merge_email_keyed_customerio_profiles.rb create mode 100644 spec/lib/data_update_scripts/merge_email_keyed_customerio_profiles_spec.rb diff --git a/app/mailers/concerns/deliverable.rb b/app/mailers/concerns/deliverable.rb index a27e7cdeaf52f..b0e302003def2 100644 --- a/app/mailers/concerns/deliverable.rb +++ b/app/mailers/concerns/deliverable.rb @@ -17,11 +17,20 @@ module Deliverable # stops every Forem-side broadcast anyway. CUSTOMERIO_BROADCAST_PASSTHROUGH_FLAG = :customerio_broadcast_passthrough + # Backoff is 2^n seconds capped at 5 minutes, so 20 holds span ~1 hour. + CUSTOMERIO_LINK_HOLD_ATTEMPTS = 20 + included do before_action :set_perform_deliveries after_action :set_delivery_options end + # Kept so hold_until_linked can replay the exact mailer call. + def process(method_name, *args) + @customerio_action_args = args + super + end + # Mailer actions call this before mail() to attach the Customer.io # transactional template id and its Liquid payload, e.g. # customerio_delivery_options(transactional_message_id: "dev_new_reply_email", @@ -49,6 +58,8 @@ def set_delivery_options return unless @_mail_was_called if deliver_via_customerio? + return hold_until_linked if customerio_recipient && customerio_mlh_uid.blank? + # Deliverable on a Customer.io-only instance (no SMTP creds) must still # send, so the per-message flag overrides the SMTP-based default above. message.perform_deliveries = true @@ -140,12 +151,37 @@ def customerio_recipient end # People in Customer.io are keyed by MLH Core user id (DEV profiles were - # stitched via the dev: anonymous id); fall back to email for - # recipients without a linked Core account. + # stitched via the dev: anonymous id). Users are never keyed by email -- + # hold_until_linked waits for the id instead -- so email only identifies + # recipients who are not users at all. def customerio_identifiers - mlh_uid = customerio_recipient&.identities&.where(provider: "mlh")&.pick(:uid) - return { id: mlh_uid } if mlh_uid.present? + customerio_recipient ? { id: customerio_mlh_uid } : { email: mail.to.first } + end + + def customerio_mlh_uid + return @customerio_mlh_uid if defined?(@customerio_mlh_uid) + + @customerio_mlh_uid = customerio_recipient&.identities&.where(provider: "mlh")&.pick(:uid) + end + + # A new user's mlh identity lands seconds after signup (76% within 5s, 97% + # within 2m), but signup-time mail (confirmation, magic link) goes out first. + # Keying that send by email would mint a Customer.io person Core's id-keyed + # person never merges with, so skip it and re-run the same mailer call once + # the link has had time to land. Users who never link are ones Core rejected + # (disposable domains, no email); after ~1h they are dropped, not mailed. + def hold_until_linked + message.perform_deliveries = false + attempt = params.to_h.fetch(:customerio_link_hold, 0) + 1 + if attempt > CUSTOMERIO_LINK_HOLD_ATTEMPTS + Rails.logger.warn( + "[Deliverable] dropped #{self.class.name}##{action_name} for unlinked user #{customerio_recipient.id}", + ) + return + end - { email: mail.to.first } + self.class.with(params.to_h.merge(customerio_link_hold: attempt)) + .public_send(action_name, *@customerio_action_args) + .deliver_later(wait: [2**attempt, 300].min.seconds) end end diff --git a/app/models/user.rb b/app/models/user.rb index 59e0b47ead267..3722018f3e450 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1211,8 +1211,10 @@ def create_password_change_note ) end + # super is Devise's !confirmed?: without it, users confirmed at creation + # (OAuth signups via skip_confirmation!) still get a confirmation email. def confirmation_required? - ForemInstance.smtp_enabled? + ForemInstance.smtp_enabled? && super end def update_user_roles_cache(role) diff --git a/lib/data_update_scripts/20260922120000_merge_email_keyed_customerio_profiles.rb b/lib/data_update_scripts/20260922120000_merge_email_keyed_customerio_profiles.rb new file mode 100644 index 0000000000000..3e399a403c61b --- /dev/null +++ b/lib/data_update_scripts/20260922120000_merge_email_keyed_customerio_profiles.rb @@ -0,0 +1,31 @@ +module DataUpdateScripts + # Before Deliverable keyed unlinked recipients as "dev:", mail sent to a + # new user before their mlh identity landed was keyed by email. Customer.io + # stored that address as the person's id, leaving an orphan profile beside + # the one keyed by the MLH uid that nothing ever merges. Merge each orphan + # into its linked person. + # + # Scoped to users linked after signup since Customer.io delivery shipped + # (2026-07-16) -- the only accounts that could have been mailed unlinked. + # Users never mailed before linking have no orphan; their merges come back as + # per-op errors in a 207, which the client treats as success, so they never + # fail the batch. + class MergeEmailKeyedCustomerioProfiles + ROLLOUT_DATE = Time.zone.parse("2026-07-16") + # /v2/batch caps a request at 500KB; a merge op is ~150 bytes. + BATCH_SIZE = 1_000 + + def run + return unless ForemInstance.customerio_track_enabled? + + User.where(created_at: ROLLOUT_DATE..).where.not(email: [nil, ""]).in_batches(of: BATCH_SIZE) do |users| + ops = users.joins(:identities) + .where(identities: { provider: "mlh" }) + .where("identities.created_at > users.created_at") + .pluck("identities.uid", "users.email") + .map { |uid, email| { type: "person", action: "merge", primary: { id: uid }, secondary: { id: email } } } + CUSTOMERIO_TRACK_API.batch(ops) if ops.any? + end + end + end +end diff --git a/spec/lib/data_update_scripts/merge_email_keyed_customerio_profiles_spec.rb b/spec/lib/data_update_scripts/merge_email_keyed_customerio_profiles_spec.rb new file mode 100644 index 0000000000000..b1e6ab9ee8edb --- /dev/null +++ b/spec/lib/data_update_scripts/merge_email_keyed_customerio_profiles_spec.rb @@ -0,0 +1,39 @@ +require "rails_helper" +require Rails.root.join( + "lib/data_update_scripts/20260922120000_merge_email_keyed_customerio_profiles.rb", +) + +describe DataUpdateScripts::MergeEmailKeyedCustomerioProfiles do + let(:track_client) { instance_double(Customerio::Client, batch: nil) } + + before do + stub_const("CUSTOMERIO_TRACK_API", track_client) + allow(ForemInstance).to receive(:customerio_track_enabled?).and_return(true) + omniauth_mock_mlh_payload + end + + def link(user, uid, at:) + create(:identity, provider: "mlh", user: user, uid: uid, created_at: at) + end + + it "merges the email-keyed profile of a user linked after signup into the linked person" do + user = create(:user, created_at: 1.day.ago) + link(user, "core-1", at: user.created_at + 30.seconds) + + described_class.new.run + + expect(track_client).to have_received(:batch).with( + [{ type: "person", action: "merge", primary: { id: "core-1" }, secondary: { id: user.email } }], + ) + end + + it "skips users linked before the rollout or not linked after signup" do + old_user = create(:user, created_at: Time.zone.parse("2026-07-01")) + link(old_user, "core-old", at: old_user.created_at + 30.seconds) + create(:user, created_at: 1.day.ago) + + described_class.new.run + + expect(track_client).not_to have_received(:batch) + end +end diff --git a/spec/mailers/concerns/deliverable_spec.rb b/spec/mailers/concerns/deliverable_spec.rb index 5021e6205c1a9..4af3cd3e84fc3 100644 --- a/spec/mailers/concerns/deliverable_spec.rb +++ b/spec/mailers/concerns/deliverable_spec.rb @@ -30,6 +30,11 @@ def built_message(to:, customerio_options: nil) before do allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") + # The :identity factory's auth_data_dump reads OmniAuth.config.mock_auth, + # which has no built-in :mlh payload; register one (as other mlh-identity + # specs do) so the factory doesn't raise KeyError. + omniauth_mock_mlh_payload + create(:identity, provider: "mlh", user: user, uid: "core-99") end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -40,21 +45,35 @@ def built_message(to:, customerio_options: nil) message = built_message(to: user.email) expect(message.delivery_method).to be_a(DeliveryMethods::CustomerIo) - expect(message.delivery_method.settings[:identifiers]).to eq(email: user.email) + expect(message.delivery_method.settings[:identifiers]).to eq(id: "core-99") expect(message.perform_deliveries).to be(true) end - it "identifies the recipient by MLH Core uid when an mlh identity exists" do - # The :identity factory's auth_data_dump reads OmniAuth.config.mock_auth, - # which has no built-in :mlh payload; register one (as other mlh-identity - # specs do) so the factory doesn't raise KeyError. - omniauth_mock_mlh_payload - create(:identity, provider: "mlh", user: user, uid: "core-99") - FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + describe "a user recipient without an mlh identity yet" do + let(:unlinked) { create(:user) } - message = built_message(to: user.email) + before { FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[unlinked]) } - expect(message.delivery_method.settings[:identifiers]).to eq(id: "core-99") + it "is not sent now, so no email-keyed Customer.io person is minted" do + message = built_message(to: unlinked.email) + + expect(message.perform_deliveries).to be(false) + expect(message.delivery_method).not_to be_a(DeliveryMethods::CustomerIo) + end + + it "re-enqueues the same mailer call to retry once the link lands" do + expect { built_message(to: unlinked.email) } + .to have_enqueued_mail(DeliverableTestMailer, :test_email) + .with(params: { to: unlinked.email, customerio_options: nil, customerio_link_hold: 1 }, args: []) + end + + it "drops the message once the hold attempts are exhausted" do + expect do + DeliverableTestMailer + .with(to: unlinked.email, customerio_link_hold: Deliverable::CUSTOMERIO_LINK_HOLD_ATTEMPTS) + .test_email.message + end.not_to have_enqueued_mail(DeliverableTestMailer, :test_email) + end end it "passes customerio_delivery_options through to the delivery method" do @@ -128,7 +147,7 @@ def built_message(to:, customerio_options: nil) expect(message.delivery_method).to be_a(DeliveryMethods::CustomerIoEvent) expect(message.delivery_method.settings[:customerio_event_name]).to eq("digest_ready") - expect(message.delivery_method.settings[:identifiers]).to eq(email: user.email) + expect(message.delivery_method.settings[:identifiers]).to eq(id: "core-99") expect(message.perform_deliveries).to be(true) end diff --git a/spec/mailers/custom_mailer_spec.rb b/spec/mailers/custom_mailer_spec.rb index ad59730efe3df..0fa9dcce6ad00 100644 --- a/spec/mailers/custom_mailer_spec.rb +++ b/spec/mailers/custom_mailer_spec.rb @@ -172,6 +172,7 @@ allow(ForemInstance).to receive_messages(smtp_enabled?: true, customerio_enabled?: true) allow(FeatureFlag).to receive(:enabled_for_user?) .with(Deliverable::CUSTOMERIO_FLAG, having_attributes(id: user.id)).and_return(true) + link_mlh_identity(user) end it "sends nothing -- Customer.io is already sending this broadcast" do @@ -220,6 +221,7 @@ ) allow(FeatureFlag).to receive(:enabled_for_user?) .with(Deliverable::CUSTOMERIO_FLAG, having_attributes(id: user.id)).and_return(true) + link_mlh_identity(user) stub_const("CUSTOMERIO_API", api_client) end diff --git a/spec/mailers/devise_mailer_spec.rb b/spec/mailers/devise_mailer_spec.rb index ef4d6284ac50c..fff4503092502 100644 --- a/spec/mailers/devise_mailer_spec.rb +++ b/spec/mailers/devise_mailer_spec.rb @@ -145,6 +145,31 @@ expect(email["from"].value).to eq(expected_from) end end + + # Signup-time confirmations race the mlh identity link; Deliverable holds + # them rather than mint an email-keyed Customer.io person. DeviseMailer is + # not parameterized, so the replay must carry its positional args. + context "when routed through Customer.io before the mlh identity exists" do + before do + allow(ApplicationConfig).to receive(:[]).and_call_original + allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") + FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + end + + after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } + + it "holds the email and sends it by MLH uid once linked" do + held = described_class.confirmation_instructions(user, "faketoken").message + expect(held.perform_deliveries).to be(false) + + omniauth_mock_mlh_payload + create(:identity, provider: "mlh", user: user, uid: "core-7") + replay = described_class.with(customerio_link_hold: 1).confirmation_instructions(user, "faketoken").message + + expect(replay.perform_deliveries).to be(true) + expect(replay.delivery_method.settings[:identifiers]).to eq(id: "core-7") + end + end end describe "#invitation_instructions" do @@ -255,6 +280,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -272,6 +298,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } diff --git a/spec/mailers/digest_mailer_spec.rb b/spec/mailers/digest_mailer_spec.rb index e57775e15575f..1d2c685ae2817 100644 --- a/spec/mailers/digest_mailer_spec.rb +++ b/spec/mailers/digest_mailer_spec.rb @@ -117,6 +117,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } diff --git a/spec/mailers/notify_mailer_spec.rb b/spec/mailers/notify_mailer_spec.rb index 7c6135ac79e74..12cd3f5eac098 100644 --- a/spec/mailers/notify_mailer_spec.rb +++ b/spec/mailers/notify_mailer_spec.rb @@ -37,6 +37,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[comment.user]) + link_mlh_identity(comment.user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -79,6 +80,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -134,6 +136,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user2]) + link_mlh_identity(user2) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -175,6 +178,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -234,6 +238,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[article.user]) + link_mlh_identity(article.user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -284,6 +289,7 @@ def create_badge_achievement(user, badge, rewarder) allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -451,6 +457,7 @@ def create_badge_achievement(user, badge, rewarder) allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -488,6 +495,7 @@ def create_badge_achievement(user, badge, rewarder) allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -528,6 +536,7 @@ def create_badge_achievement(user, badge, rewarder) allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -589,6 +598,7 @@ def create_badge_achievement(user, badge, rewarder) allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -621,6 +631,7 @@ def create_badge_achievement(user, badge, rewarder) allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -662,6 +673,7 @@ def create_badge_achievement(user, badge, rewarder) allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -711,6 +723,7 @@ def create_badge_achievement(user, badge, rewarder) allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -753,6 +766,7 @@ def create_badge_achievement(user, badge, rewarder) allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -792,6 +806,7 @@ def create_badge_achievement(user, badge, rewarder) allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -855,6 +870,7 @@ def create_badge_achievement(user, badge, rewarder) allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -874,6 +890,7 @@ def create_badge_achievement(user, badge, rewarder) allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -925,6 +942,7 @@ def create_badge_achievement(user, badge, rewarder) allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } diff --git a/spec/mailers/organization_invitation_mailer_spec.rb b/spec/mailers/organization_invitation_mailer_spec.rb index ea3c17b58c0a3..e0f7b58cd0ed7 100644 --- a/spec/mailers/organization_invitation_mailer_spec.rb +++ b/spec/mailers/organization_invitation_mailer_spec.rb @@ -47,6 +47,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -68,6 +69,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } diff --git a/spec/mailers/organization_membership_notification_mailer_spec.rb b/spec/mailers/organization_membership_notification_mailer_spec.rb index 219abe284c17c..4cb915558139d 100644 --- a/spec/mailers/organization_membership_notification_mailer_spec.rb +++ b/spec/mailers/organization_membership_notification_mailer_spec.rb @@ -51,6 +51,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -72,6 +73,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } diff --git a/spec/mailers/survey_mailer_spec.rb b/spec/mailers/survey_mailer_spec.rb index 602d51c822ad6..cbb84c997e51f 100644 --- a/spec/mailers/survey_mailer_spec.rb +++ b/spec/mailers/survey_mailer_spec.rb @@ -99,6 +99,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -140,6 +141,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -158,6 +160,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } @@ -176,6 +179,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } diff --git a/spec/mailers/verification_mailer_spec.rb b/spec/mailers/verification_mailer_spec.rb index e8c5f934e0ff8..04d964be34e98 100644 --- a/spec/mailers/verification_mailer_spec.rb +++ b/spec/mailers/verification_mailer_spec.rb @@ -58,6 +58,7 @@ allow(ApplicationConfig).to receive(:[]).and_call_original allow(ApplicationConfig).to receive(:[]).with("CUSTOMERIO_APP_KEY").and_return("app-key") FeatureFlag.enable(Deliverable::CUSTOMERIO_FLAG, FeatureFlag::Actor[user]) + link_mlh_identity(user) end after { FeatureFlag.remove(Deliverable::CUSTOMERIO_FLAG) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 05c318724bf28..e749cec08768e 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1905,4 +1905,20 @@ def enable_sync(actor: nil) expect(admin_curator.favorite_allowance_for_client).to be_nil end end + + describe "#confirmation_required?" do + before { allow(ForemInstance).to receive(:smtp_enabled?).and_return(true) } + + it "sends no confirmation email to a user confirmed at creation (OAuth signup)" do + user = build(:user, confirmed_at: nil) + user.skip_confirmation! + + expect { user.save! }.not_to have_enqueued_mail(DeviseMailer, :confirmation_instructions) + end + + it "sends a confirmation email to an unconfirmed signup" do + expect { create(:user, confirmed_at: nil) } + .to have_enqueued_mail(DeviseMailer, :confirmation_instructions) + end + end end diff --git a/spec/support/omniauth_helpers.rb b/spec/support/omniauth_helpers.rb index 4e8f64a769d23..2d30a2b673e8a 100644 --- a/spec/support/omniauth_helpers.rb +++ b/spec/support/omniauth_helpers.rb @@ -312,4 +312,11 @@ def omniauth_mock_twitter_payload def omniauth_mock_mlh_payload OmniAuth.config.mock_auth[:mlh] = OMNIAUTH_PAYLOAD_MLH.dup end + + # Customer.io delivery holds mail for users without an mlh identity, so a + # spec that expects a Customer.io send must link its recipient first. + def link_mlh_identity(user) + omniauth_mock_mlh_payload + create(:identity, provider: "mlh", user: user, uid: "core-#{user.id}") + end end From 9e1f815eb29fc8c9a7ad15f0567c88943560ed91 Mon Sep 17 00:00:00 2001 From: Jonathan Gottfried Date: Tue, 22 Sep 2026 20:14:00 -0400 Subject: [PATCH 2/2] Fix statement timeout in Customer.io orphan merge script (#23861) in_batches walks the users primary key, so filtering on created_at alone scanned ~4M older users before finding the first post-rollout one and hit the 2.5s statement timeout. Batch from the first post-rollout id instead. Renamed to a new timestamp so it runs on deploy: DataUpdateWorker only picks up enqueued scripts, and the original failed before sending anything. Claude-Session: https://claude.ai/code/session_01MprdoWHE671sCWu7kJfTda Entire-Checkpoint: 01M35SKSTR4NX3W6V6MKXHZR8T --- ...23010000_merge_email_keyed_customerio_profiles.rb} | 11 ++++++++--- .../merge_email_keyed_customerio_profiles_spec.rb | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) rename lib/data_update_scripts/{20260922120000_merge_email_keyed_customerio_profiles.rb => 20260923010000_merge_email_keyed_customerio_profiles.rb} (70%) diff --git a/lib/data_update_scripts/20260922120000_merge_email_keyed_customerio_profiles.rb b/lib/data_update_scripts/20260923010000_merge_email_keyed_customerio_profiles.rb similarity index 70% rename from lib/data_update_scripts/20260922120000_merge_email_keyed_customerio_profiles.rb rename to lib/data_update_scripts/20260923010000_merge_email_keyed_customerio_profiles.rb index 3e399a403c61b..542323daaa742 100644 --- a/lib/data_update_scripts/20260922120000_merge_email_keyed_customerio_profiles.rb +++ b/lib/data_update_scripts/20260923010000_merge_email_keyed_customerio_profiles.rb @@ -1,6 +1,6 @@ module DataUpdateScripts - # Before Deliverable keyed unlinked recipients as "dev:", mail sent to a - # new user before their mlh identity landed was keyed by email. Customer.io + # Before Deliverable held mail for users without an mlh identity, mail sent + # to a new user before that link landed was keyed by email. Customer.io # stored that address as the person's id, leaving an orphan profile beside # the one keyed by the MLH uid that nothing ever merges. Merge each orphan # into its linked person. @@ -18,7 +18,12 @@ class MergeEmailKeyedCustomerioProfiles def run return unless ForemInstance.customerio_track_enabled? - User.where(created_at: ROLLOUT_DATE..).where.not(email: [nil, ""]).in_batches(of: BATCH_SIZE) do |users| + # Batch from the first post-rollout id: in_batches walks the primary key, + # so a created_at filter alone scans every older user and times out. + start_id = User.where(created_at: ROLLOUT_DATE..).minimum(:id) + return unless start_id + + User.where(id: start_id..).where.not(email: [nil, ""]).in_batches(of: BATCH_SIZE) do |users| ops = users.joins(:identities) .where(identities: { provider: "mlh" }) .where("identities.created_at > users.created_at") diff --git a/spec/lib/data_update_scripts/merge_email_keyed_customerio_profiles_spec.rb b/spec/lib/data_update_scripts/merge_email_keyed_customerio_profiles_spec.rb index b1e6ab9ee8edb..4eff5e320a5ba 100644 --- a/spec/lib/data_update_scripts/merge_email_keyed_customerio_profiles_spec.rb +++ b/spec/lib/data_update_scripts/merge_email_keyed_customerio_profiles_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" require Rails.root.join( - "lib/data_update_scripts/20260922120000_merge_email_keyed_customerio_profiles.rb", + "lib/data_update_scripts/20260923010000_merge_email_keyed_customerio_profiles.rb", ) describe DataUpdateScripts::MergeEmailKeyedCustomerioProfiles do