diff --git a/app/jobs/gpo_reminder_job.rb b/app/jobs/gpo_reminder_job.rb new file mode 100644 index 00000000000..5345a0a8bc9 --- /dev/null +++ b/app/jobs/gpo_reminder_job.rb @@ -0,0 +1,10 @@ +class GpoReminderJob < ApplicationJob + queue_as :low + + # Send email reminders to people with USPS proofing letters whose + # letters were sent a while ago, and haven't yet entered their code + def perform(cutoff_time_for_sending_reminders) + GpoReminderSender.new. + send_emails(cutoff_time_for_sending_reminders) + end +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 437e3e29414..43d2f59d80a 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -391,6 +391,16 @@ def suspended_reset_password end end + def gpo_reminder + with_user_locale(user) do + @gpo_verification_pending_at = I18n.l( + user.gpo_verification_pending_profile.gpo_verification_pending_at, + format: :event_date, + ) + mail(to: email_address.email, subject: t('idv.messages.gpo_reminder.subject')) + end + end + private def email_should_receive_nonessential_notifications?(email) diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 0f7f37c2a90..92a666a1584 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -1035,6 +1035,12 @@ def idv_gpo_confirm_start_over_visited track_event('IdV: gpo confirm start over visited') end + # A GPO reminder email was sent to the user + # @param [String] user_id UUID of user who we sent a reminder to + def idv_gpo_reminder_email_sent(user_id:, **extra) + track_event('IdV: gpo reminder email sent', user_id: user_id, **extra) + end + # @identity.idp.previous_event_name Account verification submitted # @param [Boolean] success # @param [Hash] errors diff --git a/app/services/gpo_reminder_sender.rb b/app/services/gpo_reminder_sender.rb new file mode 100644 index 00000000000..3c1b1a72a91 --- /dev/null +++ b/app/services/gpo_reminder_sender.rb @@ -0,0 +1,21 @@ +class GpoReminderSender + def send_emails(for_letters_sent_before) + profiles_due_for_reminder = Profile.joins(:gpo_confirmation_codes). + where( + gpo_verification_pending_at: ..for_letters_sent_before, + gpo_confirmation_codes: { reminder_sent_at: nil }, + ) + + profiles_due_for_reminder.each do |profile| + profile.user.send_email_to_all_addresses(:gpo_reminder) + profile.gpo_confirmation_codes.first.update(reminder_sent_at: Time.zone.now) + analytics.idv_gpo_reminder_email_sent(user_id: profile.user.uuid) + end + end + + private + + def analytics + Analytics.new(user: AnonymousUser.new, request: nil, session: {}, sp: nil) + end +end diff --git a/app/views/user_mailer/gpo_reminder.html.erb b/app/views/user_mailer/gpo_reminder.html.erb new file mode 100644 index 00000000000..ef04ff809b6 --- /dev/null +++ b/app/views/user_mailer/gpo_reminder.html.erb @@ -0,0 +1,54 @@ +

+ <%= help_link = link_to( + t('idv.troubleshooting.options.learn_more_verify_by_mail'), + help_center_redirect_url( + category: 'verify-your-identity', + article: 'verify-your-address-by-mail', + flow: :idv, + step: :gpo_send_letter, + ), + { style: "text-decoration: 'underline'" }, + ) + + t( + 'idv.messages.gpo_reminder.body_html', + date_letter_was_sent: @gpo_verification_pending_at, + app_name: APP_NAME, + help_link: help_link, + ) %> +

+ + + + + + + + +
+ + + + + + +
+ <%= link_to t('idv.messages.gpo_reminder.finish'), + idv_gpo_verify_url, + target: '_blank', + class: 'float-center', + align: 'center', + rel: 'noopener' %> +
+
+ +

+ <%= t( + 'idv.messages.gpo_reminder.did_not_get_a_letter_html', + another_letter_link_html: link_to( + t('idv.messages.gpo_reminder.sign_in_and_request_another_letter'), + idv_gpo_verify_url(did_not_receive_letter: 1), + { style: "text-decoration: 'underline'" }, + ), + ) %> +

diff --git a/config/initializers/job_configurations.rb b/config/initializers/job_configurations.rb index 8cf01a96956..1845e0dc250 100644 --- a/config/initializers/job_configurations.rb +++ b/config/initializers/job_configurations.rb @@ -182,6 +182,12 @@ cron: cron_24h, args: -> { [Time.zone.today] }, }, + # Send reminder letters for old, outstanding GPO verification codes + send_gpo_code_reminders: { + class: 'GpoReminderJob', + cron: cron_24h, + args: -> { [14.days.ago] }, + }, }.compact end # rubocop:enable Metrics/BlockLength diff --git a/config/locales/idv/en.yml b/config/locales/idv/en.yml index 53349bd78e9..4f4146cc122 100644 --- a/config/locales/idv/en.yml +++ b/config/locales/idv/en.yml @@ -233,6 +233,15 @@ en: resend_timeframe: Letters typically take 3 to 7 business days to arrive. timeframe_html: Letters are sent the next business day via USPS First Class Mail and typically take 3 to 7 business days to arrive. + gpo_reminder: + body_html: You requested a letter to verify your identity on + %{date_letter_was_sent}. You’ll need to enter the + code from the letter to finish verifying your identity. Sign back in + to %{app_name} to finish verifying your identity. %{help_link}. + did_not_get_a_letter_html: If you didn’t get this letter, %{another_letter_link_html}. + finish: Finish verifying your identity + sign_in_and_request_another_letter: sign in to request another letter + subject: Finish verifying your identity otp_delivery_method_description: If you entered a landline above, please select “Phone call” below. personal_key: This is your new personal key. Write it down and keep it in a safe place. You will need it if you ever lose your password. diff --git a/config/locales/idv/es.yml b/config/locales/idv/es.yml index 2f6cdba337f..753352e6088 100644 --- a/config/locales/idv/es.yml +++ b/config/locales/idv/es.yml @@ -242,6 +242,16 @@ es: timeframe_html: Las cartas se envían al día siguiente por First Class Mail de USPS y suelen tardar entre 3 y 7 días hábiles en llegar. + gpo_reminder: + body_html: El %{date_letter_was_sent} solicitaste una carta + para verificar tu identidad. Deberás ingresar el código que contiene + esa carta para completar la verificación por correo. Vuelve a iniciar + sesión en %{app_name} para terminar de verificar tu identidad. + %{help_link}. + did_not_get_a_letter_html: Si no recibiste dicha carta, %{another_letter_link_html}. + finish: Termina de verificar tu identidad + sign_in_and_request_another_letter: inicia sesión para solicitar otra + subject: Termina de verificar tu identidad otp_delivery_method_description: Si ha introducido un teléfono fijo más arriba, seleccione “Llamada telefónica” más abajo. personal_key: Esta es su nueva clave personal. Escríbala y guárdela en un lugar diff --git a/config/locales/idv/fr.yml b/config/locales/idv/fr.yml index 47316f25155..89989952b94 100644 --- a/config/locales/idv/fr.yml +++ b/config/locales/idv/fr.yml @@ -258,6 +258,16 @@ fr: timeframe_html: Les lettres sont envoyées les jours ouvrables par courriel de première classe de USPS et prennent généralement entre trois à sept jours ouvrables pour être reçues. + gpo_reminder: + body_html: Vous avez demandé une lettre pour vérifier votre identité le + %{date_letter_was_sent}. Vous devrez saisir le code + figurant dans la lettre pour terminer la vérification par courrier. + Reconnectez-vous à %{app_name} pour terminer la vérification de votre + identité. %{help_link}. + did_not_get_a_letter_html: Si vous n’avez pas reçu cette lettre, %{another_letter_link_html}. + finish: Terminer la vérification de votre identité + sign_in_and_request_another_letter: connectez-vous pour en demander une autre. + subject: Terminer la vérification de votre identité otp_delivery_method_description: Si vous avez saisi une ligne fixe ci-dessus, veuillez sélectionner « Appel téléphonique » ci-dessous. personal_key: Il s’agit de votre nouvelle clé personnelle. Notez-la et diff --git a/db/primary_migrate/20230809194211_add_reminder_sent_at_to_usps_confirmation_codes.rb b/db/primary_migrate/20230809194211_add_reminder_sent_at_to_usps_confirmation_codes.rb new file mode 100644 index 00000000000..79030dc87b5 --- /dev/null +++ b/db/primary_migrate/20230809194211_add_reminder_sent_at_to_usps_confirmation_codes.rb @@ -0,0 +1,8 @@ +class AddReminderSentAtToUspsConfirmationCodes < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + add_column :usps_confirmation_codes, :reminder_sent_at, :datetime, precision: nil + add_index :usps_confirmation_codes, :reminder_sent_at, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 7dd15040377..7df4d6dbe7f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -619,8 +619,10 @@ t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.datetime "bounced_at", precision: nil + t.datetime "reminder_sent_at", precision: nil t.index ["otp_fingerprint"], name: "index_usps_confirmation_codes_on_otp_fingerprint" t.index ["profile_id"], name: "index_usps_confirmation_codes_on_profile_id" + t.index ["reminder_sent_at"], name: "index_usps_confirmation_codes_on_reminder_sent_at" end create_table "usps_confirmations", id: :serial, force: :cascade do |t| diff --git a/spec/jobs/gpo_reminder_job_spec.rb b/spec/jobs/gpo_reminder_job_spec.rb new file mode 100644 index 00000000000..59a2d6eb75f --- /dev/null +++ b/spec/jobs/gpo_reminder_job_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe GpoReminderJob do + let(:wait_before_sending_reminder) { 14.days } + + describe '#perform' do + subject(:perform) { job.perform(wait_before_sending_reminder.ago) } + + let(:job) { GpoReminderJob.new } + let(:user) { create(:user, :with_pending_gpo_profile) } + let(:pending_profile) { user.pending_profile } + let(:job_analytics) { FakeAnalytics.new } + + before do + pending_profile.update( + gpo_verification_pending_at: wait_before_sending_reminder.ago, + ) + allow(Analytics).to receive(:new).and_return(job_analytics) + end + + it 'sends reminder emails' do + expect { perform }.to change { ActionMailer::Base.deliveries.count }.by(1) + expect(job_analytics).to have_logged_event( + 'IdV: gpo reminder email sent', + ) + end + end +end diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 72006555ea6..c94c93819db 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -179,12 +179,32 @@ def suspended_reset_password ).suspended_reset_password end + def gpo_reminder + UserMailer.with( + user: user_with_pending_gpo_letter, + email_address: email_address_record, + ).gpo_reminder + end + private def user unsaveable(User.new(email_addresses: [email_address_record])) end + def user_with_pending_gpo_letter + raw_user = user + gpo_pending_profile = unsaveable( + Profile.new( + user: raw_user, + active: false, + gpo_verification_pending_at: Time.zone.now, + ), + ) + raw_user.send(:instance_variable_set, :@pending_profile, gpo_pending_profile) + raw_user + end + def email_address 'email@example.com' end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 54cdee31305..7e2d1c0b7f7 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -872,4 +872,79 @@ def expect_email_body_to_have_help_and_contact_links ) end end + + describe '#gpo_reminder' do + let(:date_letter_was_sent) { Date.new(1969, 7, 20) } + + let(:user) do + user = create(:user, :with_pending_gpo_profile) + user.pending_profile.update(gpo_verification_pending_at: date_letter_was_sent) + user + end + + let(:mail) do + UserMailer.with(user: user, email_address: email_address).gpo_reminder + end + + it_behaves_like 'a system email' + it_behaves_like 'an email that respects user email locale preference' + + it 'sends to the specified email' do + expect(mail.to).to eq [email_address.email] + end + + it 'renders the subject' do + expect(mail.subject).to eq t('idv.messages.gpo_reminder.subject') + end + + it 'renders the body' do + expected_help_link = ActionController::Base.helpers.link_to( + t('idv.troubleshooting.options.learn_more_verify_by_mail'), + help_center_redirect_url( + category: 'verify-your-identity', + article: 'verify-your-address-by-mail', + flow: :idv, + step: :gpo_send_letter, + ), + { style: "text-decoration: 'underline'" }, + ) + + expected_body = strip_tags( + t( + 'idv.messages.gpo_reminder.body_html', + date_letter_was_sent: date_letter_was_sent.strftime(t('time.formats.event_date')), + app_name: APP_NAME, + help_link: expected_help_link, + ), + ) + + expect(mail.html_part.body).to have_content(expected_body) + end + + it 'renders the finish link' do + expect(mail.html_part.body).to have_link( + t('idv.messages.gpo_reminder.finish'), + href: idv_gpo_verify_url, + ) + end + + it 'renders the did not get it link' do + expect(mail.html_part.body).to have_link( + t('idv.messages.gpo_reminder.sign_in_and_request_another_letter'), + href: idv_gpo_verify_url(did_not_receive_letter: 1), + ) + end + + it 'renders the help link' do + expect(mail.html_part.body).to have_link( + t('idv.troubleshooting.options.learn_more_verify_by_mail'), + href: help_center_redirect_url( + category: 'verify-your-identity', + article: 'verify-your-address-by-mail', + flow: :idv, + step: :gpo_send_letter, + ), + ) + end + end end diff --git a/spec/services/gpo_reminder_sender_spec.rb b/spec/services/gpo_reminder_sender_spec.rb new file mode 100644 index 00000000000..1c5e328c7c9 --- /dev/null +++ b/spec/services/gpo_reminder_sender_spec.rb @@ -0,0 +1,141 @@ +require 'rails_helper' + +RSpec.describe GpoReminderSender do + describe '#send_emails' do + subject(:sender) { GpoReminderSender.new } + + let(:user) { create(:user, :with_pending_gpo_profile) } + let(:gpo_confirmation_code) do + user. + gpo_verification_pending_profile. + gpo_confirmation_codes. + first + end + + let(:fake_analytics) { FakeAnalytics.new } + let(:wait_for_reminder) { 14.days } + let(:time_due_for_reminder) { Time.zone.now - wait_for_reminder } + let(:time_not_yet_due) { time_due_for_reminder + 1.day } + let(:time_yesterday) { Time.zone.now - 1.day } + + def set_gpo_verification_pending_at(to_time) + user. + gpo_verification_pending_profile. + update(gpo_verification_pending_at: to_time) + end + + def set_reminder_sent_at(to_time) + gpo_confirmation_code.update( + reminder_sent_at: to_time, + ) + end + + before { allow(Analytics).to receive(:new).and_return(fake_analytics) } + + context 'when no users need a reminder' do + before { set_gpo_verification_pending_at(time_not_yet_due) } + + it 'sends no emails' do + expect { subject.send_emails(time_due_for_reminder) }. + to change { ActionMailer::Base.deliveries.size }.by(0) + end + + it 'logs no events' do + expect { subject.send_emails(time_due_for_reminder) }. + not_to change { fake_analytics.events.count } + end + end + + context 'when a user is due for a reminder' do + before { set_gpo_verification_pending_at(time_due_for_reminder) } + + it 'sends that user an email' do + expect { subject.send_emails(time_due_for_reminder) }. + to change { ActionMailer::Base.deliveries.size }.by(1) + end + + it 'logs an event' do + subject.send_emails(time_due_for_reminder) + + expect(fake_analytics).to have_logged_event('IdV: gpo reminder email sent') + end + + it 'updates the GPO verification code `reminder_sent_at`' do + subject.send_emails(time_due_for_reminder) + + expect(gpo_confirmation_code.reminder_sent_at).to be_within(1).of(Time.zone.now) + end + + context 'and the user has multiple emails' do + let(:user) { create(:user, :with_pending_gpo_profile, :with_multiple_emails) } + + it 'sends an email to all of them' do + expect { subject.send_emails(time_due_for_reminder) }. + to change { ActionMailer::Base.deliveries.size }.by(2) + end + end + + context 'but the user has cancelled gpo verification' do + before do + Idv::CancelVerificationAttempt.new(user: user).call + end + + it 'does not send that user an email' do + expect { subject.send_emails(time_due_for_reminder) }. + to change { ActionMailer::Base.deliveries.size }.by(0) + end + + it 'logs no events' do + expect { subject.send_emails(time_due_for_reminder) }. + not_to change { fake_analytics.events.count } + end + end + + context 'but a reminder has already been sent' do + before { set_reminder_sent_at(time_yesterday) } + + it 'does not send that user an email' do + expect { subject.send_emails(time_due_for_reminder) }. + to change { ActionMailer::Base.deliveries.size }.by(0) + end + + it 'logs no events' do + expect { subject.send_emails(time_due_for_reminder) }. + not_to change { fake_analytics.events.count } + end + end + + context 'but the user has completed gpo verification' do + before do + otp = 'ABC123' + pending_profile = user.gpo_verification_pending_profile + + pending_profile.gpo_confirmation_codes = [ + create( + :gpo_confirmation_code, + otp_fingerprint: Pii::Fingerprinter.fingerprint(otp), + code_sent_at: Time.zone.now, + profile: pending_profile, + ), + ] + + GpoVerifyForm.new( + user: user, + pii: Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE, + otp: otp, + ).submit + end + + it 'does not send that user an email' do + expect { subject.send_emails(time_due_for_reminder) }. + to change { ActionMailer::Base.deliveries.size }.by(0) + end + + it 'logs no events' do + expect { subject.send_emails(time_due_for_reminder) }. + not_to change { fake_analytics.events.count } + end + end + end + end +end