diff --git a/app/controllers/undeliverable_address_controller.rb b/app/controllers/undeliverable_address_controller.rb new file mode 100644 index 00000000000..915e438d809 --- /dev/null +++ b/app/controllers/undeliverable_address_controller.rb @@ -0,0 +1,26 @@ +class UndeliverableAddressController < ApplicationController + skip_before_action :verify_authenticity_token + + def create + authorize do + UndeliverableAddressNotifier.new.call + + render plain: 'ok' + end + end + + private + + def authorize + # Check for empty to make sure that the token is configured + if authorization_token && authorization_token == Figaro.env.usps_download_token + yield + else + head :unauthorized + end + end + + def authorization_token + request.headers['X-API-AUTH-TOKEN'] + end +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index f6461808faa..d1d6e0c453a 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -57,4 +57,8 @@ def account_reset_cancel(email_address) def please_reset_password(email_address) mail(to: email_address.email, subject: t('user_mailer.please_reset_password.subject')) end + + def undeliverable_address(email_address) + mail(to: email_address.email, subject: t('user_mailer.undeliverable_address.subject')) + end end diff --git a/app/models/usps_confirmation_code.rb b/app/models/usps_confirmation_code.rb index 0881adb5bf3..01c8e35822e 100644 --- a/app/models/usps_confirmation_code.rb +++ b/app/models/usps_confirmation_code.rb @@ -13,4 +13,23 @@ def self.first_with_otp(otp) def expired? code_sent_at < Figaro.env.usps_confirmation_max_days.to_i.days.ago end + + def safe_update_bounced_at_and_send_notification + with_lock do + return if bounced_at + update_bounced_at_and_send_notification + end + true + end + + def update_bounced_at_and_send_notification + update(bounced_at: Time.zone.now) + self.class.send_email(profile.user) + end + + def self.send_email(user) + user.confirmed_email_addresses.each do |email_address| + UserMailer.undeliverable_address(email_address).deliver_later + end + end end diff --git a/app/services/undeliverable_address_notifier.rb b/app/services/undeliverable_address_notifier.rb new file mode 100644 index 00000000000..85b0f9a0c43 --- /dev/null +++ b/app/services/undeliverable_address_notifier.rb @@ -0,0 +1,59 @@ +class UndeliverableAddressNotifier + TEMP_FILE_BASENAME = 'usps_bounced'.freeze + + def call + temp_file = download_file + notifications_sent = process_file(temp_file) + cleanup(temp_file) + notifications_sent + end + + private + + attr_accessor :ucc + + def download_file + file = Tempfile.new(TEMP_FILE_BASENAME) + Net::SFTP.start(*sftp_config) do |sftp| + sftp.download!(Figaro.env.usps_download_sftp_directory, file.path) + end + file + end + + def cleanup(file) + file.close + file.unlink + end + + def process_file(file) + notifications_sent = 0 + File.readlines(file.path).each do |line| + code = line.chomp + sent = process_code(code) + notifications_sent += 1 if sent + end + notifications_sent + end + + def process_code(otp) + ucc = usps_confirmation_code(otp) + ucc&.safe_update_bounced_at_and_send_notification + end + + def sftp_config + [ + env.usps_download_sftp_host, + env.usps_download_sftp_username, + password: env.usps_download_sftp_password, + timeout: env.usps_download_sftp_timeout.to_i, + ] + end + + def env + Figaro.env + end + + def usps_confirmation_code(otp) + @ucc ||= UspsConfirmationCode.find_by(otp_fingerprint: Pii::Fingerprinter.fingerprint(otp)) + end +end diff --git a/app/views/user_mailer/undeliverable_address.slim b/app/views/user_mailer/undeliverable_address.slim new file mode 100644 index 00000000000..e82e87c01d5 --- /dev/null +++ b/app/views/user_mailer/undeliverable_address.slim @@ -0,0 +1,18 @@ +p.lead == t('.intro', app: link_to(APP_NAME, Figaro.env.mailer_domain_name, class: 'gray')) + +p.lead == t('.call_to_action') + +table.spacer + tbody + tr + td.s10 height="10px" + |   +table.hr + tr + th + |   + +p == t('.help', + app: link_to(APP_NAME, Figaro.env.mailer_domain_name, class: 'gray'), + help_link: link_to(t('user_mailer.help_link_text'), MarketingSite.help_url), + contact_link: link_to(t('user_mailer.contact_link_text'), MarketingSite.contact_url)) diff --git a/config/application.yml.example b/config/application.yml.example index 845916b863c..87cd7e417c2 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -71,6 +71,7 @@ dashboard_url: 'https://dashboard.demo.login.gov' valid_authn_contexts: '["http://idmanagement.gov/ns/assurance/loa/1", "http://idmanagement.gov/ns/assurance/loa/3"]' twilio_timeout: '5' +usps_download_sftp_timeout: '5' usps_upload_sftp_timeout: '5' development: @@ -194,6 +195,11 @@ development: usps_confirmation_max_days: '10' enable_i18n_mode: 'false' enable_load_testing_mode: 'false' + usps_download_sftp_directory: '/undeliverable' + usps_download_sftp_host: 'localhost' + usps_download_sftp_username: 'brady' + usps_download_sftp_password: 'test' + usps_download_token: '123ABC' usps_upload_sftp_directory: '/gsa_order' usps_upload_sftp_host: 'localhost' usps_upload_sftp_username: 'brady' @@ -313,6 +319,11 @@ production: usps_confirmation_max_days: '30' enable_i18n_mode: 'false' enable_load_testing_mode: 'false' + usps_download_sftp_directory: + usps_download_sftp_host: + usps_download_sftp_username: + usps_download_sftp_password: + usps_download_token: usps_upload_sftp_directory: usps_upload_sftp_host: usps_upload_sftp_username: @@ -436,6 +447,11 @@ test: usps_confirmation_max_days: '10' enable_i18n_mode: 'false' enable_load_testing_mode: 'false' + usps_download_sftp_directory: '/undeliverable' + usps_download_sftp_host: + usps_download_sftp_username: + usps_download_sftp_password: + usps_download_token: 'test_token' usps_upload_sftp_directory: '/directory' usps_upload_sftp_host: 'example.com' usps_upload_sftp_username: 'user' diff --git a/config/locales/user_mailer/en.yml b/config/locales/user_mailer/en.yml index 55f0e59a478..da89ad01a18 100644 --- a/config/locales/user_mailer/en.yml +++ b/config/locales/user_mailer/en.yml @@ -102,3 +102,8 @@ en: you can ignore this message. link_text: Go to %{app} reset_password: If you can't remember your password, go to %{app} to reset it. + undeliverable_address: + call_to_action: Please sign in to login.gov and follow instructions. + help: '' + intro: We were unable to send mail to the address you provided. + subject: Mail sent to your address was undeliverable diff --git a/config/locales/user_mailer/es.yml b/config/locales/user_mailer/es.yml index 5cc4f6ba901..a67b3ddd1d8 100644 --- a/config/locales/user_mailer/es.yml +++ b/config/locales/user_mailer/es.yml @@ -106,3 +106,8 @@ es: este email, puede ignorar este mensaje. link_text: Ir a %{app} reset_password: Si no recuerda su contraseña, vaya a %{app} para restablecerla. + undeliverable_address: + call_to_action: Por favor, inicie sesión en login.gov y siga las instrucciones. + help: '' + intro: No hemos podido enviar el correo a la dirección que proporcionó. + subject: El correo enviado a su dirección no se pudo entregar diff --git a/config/locales/user_mailer/fr.yml b/config/locales/user_mailer/fr.yml index 5e6e73b709c..9fdeca9774e 100644 --- a/config/locales/user_mailer/fr.yml +++ b/config/locales/user_mailer/fr.yml @@ -111,3 +111,8 @@ fr: link_text: Allez à %{app} reset_password: Si vous ne vous souvenez plus de votre mot de passe, allez à %{app} pour le réinitialiser. + undeliverable_address: + call_to_action: Veuillez vous connecter à login.gov et suivre les instructions. + help: '' + intro: Nous n'avons pas pu envoyer de courrier à l'adresse que vous avez fournie. + subject: Le courrier envoyé à votre adresse était non distribuable. diff --git a/config/routes.rb b/config/routes.rb index 7392a243d92..284dc7b1b8c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,6 +33,7 @@ defaults: { format: :xml } post '/api/usps_upload' => 'usps_upload#create' + post '/api/usps_download' => 'undeliverable_address#create' get '/openid_connect/authorize' => 'openid_connect/authorization#index' get '/openid_connect/logout' => 'openid_connect/logout#index' diff --git a/db/migrate/20181122100307_add_bounced_at_to_usps_confirmation_code.rb b/db/migrate/20181122100307_add_bounced_at_to_usps_confirmation_code.rb new file mode 100644 index 00000000000..2fd5f637109 --- /dev/null +++ b/db/migrate/20181122100307_add_bounced_at_to_usps_confirmation_code.rb @@ -0,0 +1,8 @@ +class AddBouncedAtToUspsConfirmationCode < ActiveRecord::Migration[5.1] + disable_ddl_transaction! + + def change + add_column :usps_confirmation_codes, :bounced_at, :timestamp + add_index :usps_confirmation_codes, :otp_fingerprint, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 7c160838273..d20572e231e 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.define(version: 20181121223714) do +ActiveRecord::Schema.define(version: 20181122100307) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -268,6 +268,8 @@ t.datetime "code_sent_at", default: -> { "now()" }, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.datetime "bounced_at" + t.index ["otp_fingerprint"], name: "index_usps_confirmation_codes_on_otp_fingerprint" t.index ["profile_id"], name: "index_usps_confirmation_codes_on_profile_id" end diff --git a/spec/controllers/undeliverable_address_controller_spec.rb b/spec/controllers/undeliverable_address_controller_spec.rb new file mode 100644 index 00000000000..8df654b1844 --- /dev/null +++ b/spec/controllers/undeliverable_address_controller_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +describe UndeliverableAddressController do + describe '#create' do + context 'with no token' do + it 'returns unauthorized' do + post :create + + expect(response.status).to eq 401 + end + end + + context 'with an invalid token' do + before do + headers('foobar') + end + + it 'returns unauthorized' do + post :create + + expect(response.status).to eq 401 + end + end + + context 'with a valid token' do + before do + headers(Figaro.env.usps_download_token) + end + + it 'returns a good status' do + notifier = instance_double(UndeliverableAddressNotifier) + expect(notifier).to receive(:call) + expect(UndeliverableAddressNotifier).to receive(:new).and_return(notifier) + + post :create + + expect(response).to have_http_status(:ok) + end + end + end + + def headers(token) + request.headers['X-API-AUTH-TOKEN'] = token + end +end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 91a91a4fb4d..78f3bd915a3 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -256,6 +256,25 @@ def expect_email_body_to_have_help_and_contact_links end end + describe 'undeliverable_address' do + let(:mail) { UserMailer.undeliverable_address(email_address) } + + it_behaves_like 'a system email' + + it 'sends to the current email' do + expect(mail.to).to eq [email_address.email] + end + + it 'renders the subject' do + expect(mail.subject).to eq t('user_mailer.undeliverable_address.subject') + end + + it 'renders the body' do + expect(mail.html_part.body). + to have_content(strip_tags(t('user_mailer.undeliverable_address.intro'))) + end + end + def strip_tags(str) ActionController::Base.helpers.strip_tags(str) end diff --git a/spec/services/undeliverable_address_notifier_spec.rb b/spec/services/undeliverable_address_notifier_spec.rb new file mode 100644 index 00000000000..0f0bfd46caa --- /dev/null +++ b/spec/services/undeliverable_address_notifier_spec.rb @@ -0,0 +1,88 @@ +require 'rails_helper' + +RSpec.describe UndeliverableAddressNotifier do + let(:subject) { UndeliverableAddressNotifier.new } + let(:otp) { 'ABC123' } + let(:profile) do + create( + :profile, + deactivation_reason: :verification_pending, + pii: { ssn: '123-45-6789', dob: '1970-01-01' } + ) + end + let(:usps_confirmation_code) do + create( + :usps_confirmation_code, + profile: profile, + otp_fingerprint: Pii::Fingerprinter.fingerprint(otp) + ) + end + let(:user) { profile.user } + + it 'processes the file and sends out notifications' do + mock_data + notifications_sent = subject.call + + expect(notifications_sent).to eq(1) + expect(UspsConfirmationCode.first.bounced_at).to be_present + end + + it 'does not send out notifications to the same user twice after processing twice' do + mock_data + notifications_sent = subject.call + + expect(notifications_sent).to eq(1) + + mock_data + notifications_sent = subject.call + + expect(notifications_sent).to eq(0) + end + + describe '#download_file' do + let(:sftp_connection) { instance_double('Net::SFTP::Session') } + before do + allow(Net::SFTP).to receive(:start).and_yield(sftp_connection) + allow(sftp_connection).to receive(:download!) + end + + it 'downloads the file via sftp' do + expect(Net::SFTP).to receive(:start).with(*sftp_options).and_yield(sftp_connection) + expect(sftp_connection).to receive(:download!) + + subject.send(:download_file) + end + end + + def create_test_data + process_file_and_send_notifications + end + + def mock_data + usps_confirmation_code + user + temp_file = Tempfile.new('foo') + File.open(temp_file.path, 'w') do |file| + file.puts otp + end + allow_any_instance_of(UndeliverableAddressNotifier).to receive(:download_file). + and_return(temp_file) + end + + def download_folder + File.join(Figaro.env.usps_download_sftp_directory, 'batch.psv') + end + + def sftp_options + [ + env.usps_download_sftp_host, + env.usps_download_sftp_username, + password: env.usps_download_sftp_password, + timeout: env.usps_download_sftp_timeout.to_i, + ] + end + + def env + Figaro.env + end +end