From 187f51edfb590483de1e4bcc420e2f7b9e82baa3 Mon Sep 17 00:00:00 2001 From: NoRePercussions Date: Mon, 10 Jun 2024 15:27:29 -0400 Subject: [PATCH 1/3] Remove Unused 'Clean Backups' Reminder --- app/mailers/admin_mailer.rb | 5 ----- app/views/admin_mailer/cleanup_backups.text.erb | 3 --- lib/tasks/admin.rake | 4 ---- 3 files changed, 12 deletions(-) delete mode 100644 app/mailers/admin_mailer.rb delete mode 100644 app/views/admin_mailer/cleanup_backups.text.erb delete mode 100644 lib/tasks/admin.rake diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb deleted file mode 100644 index c4b77bdb..00000000 --- a/app/mailers/admin_mailer.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AdminMailer < ActionMailer::Base - def cleanup_backups - mail to: "abtech@andrew.cmu.edu" - end -end diff --git a/app/views/admin_mailer/cleanup_backups.text.erb b/app/views/admin_mailer/cleanup_backups.text.erb deleted file mode 100644 index fb601dd0..00000000 --- a/app/views/admin_mailer/cleanup_backups.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -This is a quarterly reminder to clean out the tracker database backups folder on AFS. - ---Automated Email from AB Tech Tracker, do not respond diff --git a/lib/tasks/admin.rake b/lib/tasks/admin.rake deleted file mode 100644 index ce26551e..00000000 --- a/lib/tasks/admin.rake +++ /dev/null @@ -1,4 +0,0 @@ -desc "Send a email reminder about cleaning up the database backups folder" -task :email_backup_reminder => :environment do - AdminMailer.cleanup_backups.deliver_now -end \ No newline at end of file From 4719e357ac8fe18d53be8940b2b92f945ab08023 Mon Sep 17 00:00:00 2001 From: NoRePercussions Date: Mon, 10 Jun 2024 16:02:29 -0400 Subject: [PATCH 2/3] Move Email Pulling to ActiveJobs This introduces substantial changes to email pulling. * Email pulling is no longer managed with systemd and rake. * Email pulling job no longer manages its own lifecycle. If we are no longer using systemd, we no longer have its ability to recover from unexpected failure by restarting the task. Additionally, we no longer need to manage an IMAP connection lifecycle, and avoid rate-limiting errors. * IMAP failures are now logged as errors, as we do not expect rate-limiting or dropped connection errors on a short-lived connection. --- app/jobs/application_job.rb | 7 ++ app/jobs/pull_email_job.rb | 86 ++++++++++++++ .../abtech-tracker-email-idle@.service | 44 ------- lib/tasks/email.rake | 107 ------------------ test/jobs/pull_email_job_test.rb | 7 ++ 5 files changed, 100 insertions(+), 151 deletions(-) create mode 100644 app/jobs/application_job.rb create mode 100644 app/jobs/pull_email_job.rb delete mode 100644 deploy/systemd/abtech-tracker-email-idle@.service delete mode 100644 lib/tasks/email.rake create mode 100644 test/jobs/pull_email_job_test.rb diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 00000000..d394c3d1 --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/jobs/pull_email_job.rb b/app/jobs/pull_email_job.rb new file mode 100644 index 00000000..e9235774 --- /dev/null +++ b/app/jobs/pull_email_job.rb @@ -0,0 +1,86 @@ +require 'logger' + +class PullEmailJob < ApplicationJob + queue_as :default + + def perform(*args) + STDOUT.sync = true + + logger = Logger.new(STDOUT) + logger.level = Logger::INFO + + Rails.application.credentials.fetch(:email) { raise 'Could not find `email` credentials!' } + Rails.application.credentials.email.fetch(:email) { raise 'Could not find `email` in `email` credentials!' } + Rails.application.credentials.email.fetch(:name) { raise 'Could not find `name` in `email` credentials!' } + Rails.application.credentials.email.fetch(:port) { raise 'Could not find `port` in `email` credentials!' } + Rails.application.credentials.email.fetch(:host) { raise 'Could not find `host` in `email` credentials!' } + Rails.application.credentials.email.fetch(:ssl) { raise 'Could not find `ssl` in `email` credentials!' } + # Cannot used nested hashes in credentials without [] in Rails 6 + # https://blog.saeloun.com/2021/06/02/rails-access-nested-secrects-by-method-call/ + if Rails.application.credentials.email[:oauth].nil? && Rails.application.credentials.email[:password].nil? + raise 'Could not find `oauth` or `password` in `email` credentials!' + elsif !Rails.application.credentials.email[:oauth].nil? && !Rails.application.credentials.email[:password].nil? + raise 'Found both `oauth` and `password` in `email` credentials!' + elsif !Rails.application.credentials.email[:oauth].nil? + Rails.application.credentials.email[:oauth].fetch(:site) { raise 'Could not find `site` in `email.oauth` credentials!' } + Rails.application.credentials.email[:oauth].fetch(:authorize_url) { raise 'Could not find `authorize_url` in `email.oauth` credentials!' } + Rails.application.credentials.email[:oauth].fetch(:token_url) { raise 'Could not find `token_url` in `email.oauth` credentials!' } + Rails.application.credentials.email[:oauth].fetch(:refresh_token) { raise 'Could not find `refresh_token` in `email.oauth` credentials!' } + Rails.application.credentials.email[:oauth].fetch(:client_id) { raise 'Could not find `client_id` in `email.oauth` credentials!' } + Rails.application.credentials.email[:oauth].fetch(:client_secret) { raise 'Could not find `client_secret` in `email.oauth` credentials!' } + end + config = Rails.application.credentials.email + + reconnectSleep = 1 + + logger.info("Logging in to mailbox #{config[:email]}") + + begin + imap = Net::IMAP.new(config[:host], port: config[:port], ssl: config[:ssl]) + imap.capable?(:IMAP4rev1) or raise "Not an IMAP4rev1 server" + if imap.auth_capable?("XOAUTH2") && !Rails.application.credentials.email[:oauth].nil? + oauth_client = OAuth2::Client.new(config[:oauth][:client_id], config[:oauth][:client_secret], {site: config[:oauth][:site], authorize_url: config[:oauth][:authorize_url], token_url: config[:oauth][:token_url]}) + access_token = OAuth2::AccessToken.from_hash(oauth_client, refresh_token: config[:oauth][:refresh_token]).refresh! + imap.authenticate('XOAUTH2', config[:email], access_token.token) + elsif imap.auth_capable?("PLAIN") + imap.authenticate("PLAIN", config[:email], config[:password]) + # Should not use deprecated LOGIN method + # elsif !imap.capability?("LOGINDISABLED") + # imap.login(config[:email], config[:password]) + else + raise "No acceptable authentication mechanisms" + end + rescue Net::IMAP::NoResponseError, SocketError, Faraday::ConnectionFailed => error + logger.error("Could not authenticate for #{config[:email]}, error: #{error.message}") + return + end + + begin + imap.select(config[:name]) + + while true + logger.info("Pulling emails for #{config[:email]}") + query = ["BEFORE", Net::IMAP.format_date(Time.now + 1.day)] + latest = Email.order("timestamp DESC").first + query = ["SINCE", Net::IMAP.format_date(latest.timestamp)] if latest + + ids = imap.search(query) + imap.fetch(ids, "BODY.PEEK[]").each do |msg| + mail = Mail.new(msg.attr["BODY[]"]) + + unless Email.where(message_id: mail.message_id).exists? + begin + unless Email.create_from_mail(mail) + logger.error("Could not pull message #{mail.message_id}") + end + rescue Exception => e + logger.error("Exception while loading message #{mail.message_id}: " + e.to_s + "\n" + e.backtrace.join("\n")) + end + end + end + end + rescue Net::IMAP::Error, EOFError, Errno::ECONNRESET => e + logger.error("Disconnected for mailbox #{config[:email]}") + end + end +end diff --git a/deploy/systemd/abtech-tracker-email-idle@.service b/deploy/systemd/abtech-tracker-email-idle@.service deleted file mode 100644 index af2fedd2..00000000 --- a/deploy/systemd/abtech-tracker-email-idle@.service +++ /dev/null @@ -1,44 +0,0 @@ -[Unit] -Description=AB Tech Tracker Email Idle Task -After=network.target - -# Only run if associated Rails is running, otherwise it might be down for -# development -After=abtech-tracker@.service - -[Service] -# * -# * BEGIN TRACKER EMAIL IDLE TASK CONFIG DEFAULTS -# * -# * Override options in this block by using the `systemctl edit` command. -# * Use this command; DO NOT EDIT THIS FILE ON THE DEPLOYED SYSTEM. This file -# * documents the options. -# * - -# Tracker Environment File -# ======================== -# The working directory should be the location of the repo. -EnvironmentFile=/srv/abtech-tracker/%i/tracker.env - -# Service Working Directory -# ========================= -# The working directory should be the location of the repo. -WorkingDirectory=/srv/abtech-tracker/%i/repo - -# Service User -# ============ -# This user will run Tracker. Preferably configure a non-privileged user that -# does not need edit access to the rbenv root. If you override this, then you -# also need to change the associated systemd socket file. -User=deploy-abtech-tracker - -# * -# * END TRACKER EMAIL IDLE TASK CONFIG DEFAULTS -# * - -Type=simple - -ExecStart=/srv/abtech-tracker/%i/rbenv/shims/bundle exec --keep-file-descriptors rails email:idle - -[Install] -WantedBy=multi-user.target diff --git a/lib/tasks/email.rake b/lib/tasks/email.rake deleted file mode 100644 index 59e875ce..00000000 --- a/lib/tasks/email.rake +++ /dev/null @@ -1,107 +0,0 @@ -require 'logger' - -namespace :email do - desc "Check for email continuously in the background" - task :idle => :environment do - STDOUT.sync = true - - logger = Logger.new(STDOUT) - logger.level = Logger::INFO - - Rails.application.credentials.fetch(:email) { raise 'Could not find `email` credentials!' } - Rails.application.credentials.email.fetch(:email) { raise 'Could not find `email` in `email` credentials!' } - Rails.application.credentials.email.fetch(:name) { raise 'Could not find `name` in `email` credentials!' } - Rails.application.credentials.email.fetch(:port) { raise 'Could not find `port` in `email` credentials!' } - Rails.application.credentials.email.fetch(:host) { raise 'Could not find `host` in `email` credentials!' } - Rails.application.credentials.email.fetch(:ssl) { raise 'Could not find `ssl` in `email` credentials!' } - # Cannot used nested hashes in credentials without [] in Rails 6 - # https://blog.saeloun.com/2021/06/02/rails-access-nested-secrects-by-method-call/ - if Rails.application.credentials.email[:oauth].nil? && Rails.application.credentials.email[:password].nil? - raise 'Could not find `oauth` or `password` in `email` credentials!' - elsif !Rails.application.credentials.email[:oauth].nil? && !Rails.application.credentials.email[:password].nil? - raise 'Found both `oauth` and `password` in `email` credentials!' - elsif !Rails.application.credentials.email[:oauth].nil? - Rails.application.credentials.email[:oauth].fetch(:site) { raise 'Could not find `site` in `email.oauth` credentials!' } - Rails.application.credentials.email[:oauth].fetch(:authorize_url) { raise 'Could not find `authorize_url` in `email.oauth` credentials!' } - Rails.application.credentials.email[:oauth].fetch(:token_url) { raise 'Could not find `token_url` in `email.oauth` credentials!' } - Rails.application.credentials.email[:oauth].fetch(:refresh_token) { raise 'Could not find `refresh_token` in `email.oauth` credentials!' } - Rails.application.credentials.email[:oauth].fetch(:client_id) { raise 'Could not find `client_id` in `email.oauth` credentials!' } - Rails.application.credentials.email[:oauth].fetch(:client_secret) { raise 'Could not find `client_secret` in `email.oauth` credentials!' } - end - config = Rails.application.credentials.email - - reconnectSleep = 1 - - logger.info("Logging in to mailbox #{config[:email]}") - while true - begin - imap = Net::IMAP.new(config[:host], port: config[:port], ssl: config[:ssl]) - imap.capable?(:IMAP4rev1) or raise "Not an IMAP4rev1 server" - if imap.auth_capable?("XOAUTH2") && !Rails.application.credentials.email[:oauth].nil? - oauth_client = OAuth2::Client.new(config[:oauth][:client_id], config[:oauth][:client_secret], {site: config[:oauth][:site], authorize_url: config[:oauth][:authorize_url], token_url: config[:oauth][:token_url]}) - access_token = OAuth2::AccessToken.from_hash(oauth_client, refresh_token: config[:oauth][:refresh_token]).refresh! - imap.authenticate('XOAUTH2', config[:email], access_token.token) - elsif imap.auth_capable?("PLAIN") - imap.authenticate("PLAIN", config[:email], config[:password]) - # Should not use deprecated LOGIN method - # elsif !imap.capability?("LOGINDISABLED") - # imap.login(config[:email], config[:password]) - else - raise "No acceptable authentication mechanisms" - end - rescue Net::IMAP::NoResponseError, SocketError, Faraday::ConnectionFailed => error - logger.info("Could not authenticate for #{config[:email]}, trying again in #{reconnectSleep} #{"second".pluralize(reconnectSleep)}, error: #{error.message}") - sleep reconnectSleep - reconnectSleep += 1 - - next - end - - reconnectSleep = 1 - - begin - imap.select(config[:name]) - - while true - logger.info("Pulling emails for #{config[:email]}") - query = ["BEFORE", Net::IMAP.format_date(Time.now + 1.day)] - latest = Email.order("timestamp DESC").first - query = ["SINCE", Net::IMAP.format_date(latest.timestamp)] if latest - - ids = imap.search(query) - imap.fetch(ids, "BODY.PEEK[]").each do |msg| - mail = Mail.new(msg.attr["BODY[]"]) - - unless Email.where(message_id: mail.message_id).exists? - begin - unless Email.create_from_mail(mail) - logger.error("Could not pull message #{mail.message_id}") - end - rescue Exception => e - logger.error("Exception while loading message #{mail.message_id}: " + e.to_s + "\n" + e.backtrace.join("\n")) - end - end - end - - logger.info("Idling for #{config[:email]}") - - waiting = Thread.start do - sleep(20.minutes) - - imap.idle_done - end - - imap.idle do |response| - if response.respond_to?(:name) && response.name == 'EXISTS' - waiting.kill - imap.idle_done - end - end - end - rescue Net::IMAP::Error, EOFError, Errno::ECONNRESET => e - logger.info("Disconnected for mailbox #{config[:email]}, reconnecting") - next - end - end - end -end diff --git a/test/jobs/pull_email_job_test.rb b/test/jobs/pull_email_job_test.rb new file mode 100644 index 00000000..d342cec3 --- /dev/null +++ b/test/jobs/pull_email_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class PullEmailJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end From 321df8e3ef20c4680780c88f2cb989ec2bbdf1a4 Mon Sep 17 00:00:00 2001 From: NoRePercussions Date: Mon, 10 Jun 2024 16:20:58 -0400 Subject: [PATCH 3/3] Move Slack Notifications to ActiveJobs --- .../send_event_slack_notifications_job.rb | 102 ++++++++++++++++++ .../abtech-tracker-slack-notify@.service | 44 -------- .../abtech-tracker-slack-notify@.timer | 25 ----- ...send_event_slack_notifications_job_test.rb | 7 ++ 4 files changed, 109 insertions(+), 69 deletions(-) create mode 100644 app/jobs/send_event_slack_notifications_job.rb delete mode 100644 deploy/systemd/abtech-tracker-slack-notify@.service delete mode 100644 deploy/systemd/abtech-tracker-slack-notify@.timer create mode 100644 test/jobs/send_event_slack_notifications_job_test.rb diff --git a/app/jobs/send_event_slack_notifications_job.rb b/app/jobs/send_event_slack_notifications_job.rb new file mode 100644 index 00000000..4e49048e --- /dev/null +++ b/app/jobs/send_event_slack_notifications_job.rb @@ -0,0 +1,102 @@ +class SendEventSlackNotificationsJob < ApplicationJob + queue_as :default + + def perform(*args) + Rails.application.routes.default_url_options = Rails.application.config.action_mailer.default_url_options + + STDOUT.sync = true + + logger = Logger.new(STDOUT) + + + Rails.application.credentials.fetch(:slack) { raise 'Could not find `slack` credentials!' } + Rails.application.credentials.slack.fetch(:token) { raise 'Could not find `token` in `slack` credentials!' } + env_config = Rails.application.credentials.slack + + logger.info("Logging into Slack") + Slack.configure do |config| + config.token = env_config[:token] + end + client = Slack::Web::Client.new + + channel = + if Rails.env.development? + "#bot-testing" + elsif Rails.env.staging? + "#bot-testing" + else + "#events" + end + channel_social = + if Rails.env.development? + "#bot-testing" + elsif Rails.env.staging? + "#bot-testing" + else + "#social" + end + + startdate = DateTime.now + enddate = 1.hour.from_now + + calls = Eventdate.where(events: {textable: true, status: Event::Event_Status_Group_Not_Cancelled}).call_between(startdate, enddate).includes(:event).references(:event) + strikes = Eventdate.where(events: {textable: true, status: Event::Event_Status_Group_Not_Cancelled}).strike_between(startdate, enddate).includes(:event).references(:event) + + def message_gen(msg, event_url, eventdate) + [ + msg, + { + type: "section", + text: { + type: "mrkdwn", + text: msg + "\n_" + eventdate.locations.join(", ") + "_" + }, + accessory: { + type: "button", + text: { + type: "plain_text", + text: "View on Tracker", + emoji: true, + }, + url: event_url + } + } + ] + end + + messages = [] + messages_social = [] + calls.each do |eventdate| + event_url = Rails.application.routes.url_helpers.url_for(eventdate.event).to_s + msg = "Call for <" + event_url + "|" + eventdate.event.title + "> - " + eventdate.description + " is at " + eventdate.effective_call.strftime("%H:%M") + messages.push(message_gen(msg, event_url, eventdate)) + messages_social.push(message_gen(msg, event_url, eventdate)) if eventdate.event.textable_social + end + strikes.each do |eventdate| + event_url = Rails.application.routes.url_helpers.url_for(eventdate.event).to_s + msg = "Strike for <" + event_url + "|" + eventdate.event.title + "> - " + eventdate.description + " is at " + eventdate.effective_strike.strftime("%H:%M") + messages.push(message_gen(msg, event_url, eventdate)) + messages_social.push(message_gen(msg, event_url, eventdate)) if eventdate.event.textable_social + end + + messages_text = messages.map { |msg| msg[0] } + messages_blocks = messages.map { |msg| msg[1] } + messages_social_text = messages_social.map { |msg| msg[0] } + messages_social_blocks = messages_social.map { |msg| msg[1] } + + unless messages.empty? + message_text = messages_text.join("\n") + + logger.info("Sending message") + client.chat_postMessage(channel: channel, text: message_text, as_user: true, blocks: messages_blocks) + end + + unless messages_social.empty? + message_text = messages_social_text.join("\n") + + logger.info("Sending social message") + client.chat_postMessage(channel: channel_social, text: message_text, as_user: true, blocks: messages_blocks) + end + + end +end diff --git a/deploy/systemd/abtech-tracker-slack-notify@.service b/deploy/systemd/abtech-tracker-slack-notify@.service deleted file mode 100644 index 9d77c67f..00000000 --- a/deploy/systemd/abtech-tracker-slack-notify@.service +++ /dev/null @@ -1,44 +0,0 @@ -[Unit] -Description=AB Tech Tracker Slack Notification Task -After=network.target - -# Only run if associated Rails is running, otherwise it might be down for -# development -After=abtech-tracker@.service - -[Service] -# * -# * BEGIN TRACKER SLACK NOTIFICATION TASK CONFIG DEFAULTS -# * -# * Override options in this block by using the `systemctl edit` command. -# * Use this command; DO NOT EDIT THIS FILE ON THE DEPLOYED SYSTEM. This file -# * documents the options. -# * - -# Tracker Environment File -# ======================== -# The working directory should be the location of the repo. -EnvironmentFile=/srv/abtech-tracker/%i/tracker.env - -# Service Working Directory -# ========================= -# The working directory should be the location of the repo. -WorkingDirectory=/srv/abtech-tracker/%i/repo - -# Service User -# ============ -# This user will run Tracker. Preferably configure a non-privileged user that -# does not need edit access to the rbenv root. If you override this, then you -# also need to change the associated systemd socket file. -User=deploy-abtech-tracker - -# * -# * END TRACKER SLACK NOTIFICATION TASK CONFIG DEFAULTS -# * - -Type=oneshot - -ExecStart=/srv/abtech-tracker/%i/rbenv/shims/bundle exec --keep-file-descriptors rails slack:notify - -[Install] -WantedBy=multi-user.target diff --git a/deploy/systemd/abtech-tracker-slack-notify@.timer b/deploy/systemd/abtech-tracker-slack-notify@.timer deleted file mode 100644 index 9ee3d39d..00000000 --- a/deploy/systemd/abtech-tracker-slack-notify@.timer +++ /dev/null @@ -1,25 +0,0 @@ -[Unit] -Description=AB Tech Tracker Slack Notification Task Timer - -[Timer] -# * -# * BEGIN TRACKER SLACK NOTIFICATION TASK TIMER CONFIG DEFAULTS -# * -# * Override options in this block by using the `systemctl edit` command. -# * Use this command; DO NOT EDIT THIS FILE ON THE DEPLOYED SYSTEM. This file -# * documents the options. -# * - -# Trigger datetime -# ================ -# Hourly -OnCalendar=*-*-* *:00:00 - -# * -# * END TRACKER SLACK NOTIFICATION TASK TIMER CONFIG DEFAULTS -# * - -Unit=abtech-tracker-slack-notify@.service - -[Install] -WantedBy=timers.target diff --git a/test/jobs/send_event_slack_notifications_job_test.rb b/test/jobs/send_event_slack_notifications_job_test.rb new file mode 100644 index 00000000..1c8dd116 --- /dev/null +++ b/test/jobs/send_event_slack_notifications_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class SendEventSlackNotificationsJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end