discourse/lib/demon/email_sync.rb
Martin Brennan 2920988b3a
FIX: IMAP sync email update uniqueness across groups and minor improvements (#10332)
Adds a imap_group_id column to IncomingEmail to deal with an issue where we were trying to update emails in the mailbox, calling IncomingEmail.where(imap_sync: true). However UID and UIDVALIDITY could be the same across accounts. So if group A used IMAP details for Gmail account A, and group B used IMAP details for Gmail account B, and both tried to sync changes to an email with UID of 3 (e.g. changing Labels), one account could affect the other. This even applied to Archiving!

Also in this PR:

* Fix error occurring if we do a uid_fetch and no emails are returned
* Allow for creating labels within the target mailbox (previously we would not do this, only use existing labels)
* Improve consistency for log messages
* Add specs for generic IMAP provider (Gmail specs still to come)
* Add custom archiving support for Gmail
* Only use Message-ID for uniqueness of IncomingEmail if it was generated by us
* Various refactors and improvements
2020-08-03 13:10:17 +10:00

168 lines
4.8 KiB
Ruby

# frozen_string_literal: true
require "demon/base"
class Demon::EmailSync < ::Demon::Base
HEARTBEAT_KEY ||= "email_sync_heartbeat"
HEARTBEAT_INTERVAL ||= 60.seconds
def self.prefix
"email_sync"
end
private
def suppress_stdout
false
end
def suppress_stderr
false
end
def start_thread(db, group)
Thread.new do
RailsMultisite::ConnectionManagement.with_connection(db) do
puts "[EmailSync] Thread started for group #{group.name} (id = #{group.id}) in db #{db}"
begin
obj = Imap::Sync.for_group(group)
rescue Net::IMAP::NoResponseError => e
group.update(imap_last_error: e.message)
Thread.exit
end
@sync_lock.synchronize { @sync_data[db][group.id][:obj] = obj }
status = nil
idle = false
while @running && group.reload.imap_mailbox_name.present? do
puts "[EmailSync] Processing IMAP mailbox for group #{group.name} (id = #{group.id}) in db #{db}"
status = obj.process(
idle: obj.can_idle? && status && status[:remaining] == 0,
old_emails_limit: status && status[:remaining] > 0 ? 0 : nil,
)
if !obj.can_idle? && status[:remaining] == 0
puts "[EmailSync] Going to sleep for group #{group.name} (id = #{group.id}) in db #{db} to wait for new emails."
# Thread goes into sleep for a bit so it is better to return any
# connection back to the pool.
ActiveRecord::Base.connection_handler.clear_active_connections!
sleep SiteSetting.imap_polling_period_mins.minutes
end
end
obj.disconnect!
end
end
end
def kill_threads
# This is not really safe so the caller should ensure it happens in a
# thread-safe context.
# It should be safe when called from within a `trap` (there are no
# synchronization primitives available anyway).
@running = false
@sync_data.each do |db, sync_data|
sync_data.each do |_, data|
data[:thread].kill
data[:thread].join
data[:obj]&.disconnect! rescue nil
end
end
exit 0
end
def after_fork
puts "[EmailSync] Loading EmailSync in process id #{Process.pid}"
loop do
break if Discourse.redis.set(HEARTBEAT_KEY, Time.now.to_i, ex: HEARTBEAT_INTERVAL, nx: true)
sleep HEARTBEAT_INTERVAL
end
puts "[EmailSync] Starting EmailSync main thread"
@running = true
@sync_data = {}
@sync_lock = Mutex.new
trap('INT') { kill_threads }
trap('TERM') { kill_threads }
trap('HUP') { kill_threads }
while @running
Discourse.redis.set(HEARTBEAT_KEY, Time.now.to_i, ex: HEARTBEAT_INTERVAL)
# Kill all threads for databases that no longer exist
all_dbs = Set.new(RailsMultisite::ConnectionManagement.all_dbs)
@sync_data.filter! do |db, sync_data|
next true if all_dbs.include?(db)
sync_data.each do |_, data|
data[:thread].kill
data[:thread].join
data[:obj]&.disconnect!
end
false
end
RailsMultisite::ConnectionManagement.each_connection do |db|
if SiteSetting.enable_imap
groups = Group.where.not(imap_mailbox_name: '').map { |group| [group.id, group] }.to_h
@sync_lock.synchronize do
@sync_data[db] ||= {}
# Kill threads for group's mailbox that are no longer synchronized.
@sync_data[db].filter! do |group_id, data|
next true if groups[group_id] && data[:thread]&.alive? && !data[:obj]&.disconnected?
if !groups[group_id]
puts("[EmailSync] Killing thread for group (id = #{group_id}) because mailbox is no longer synced")
else
puts("[EmailSync] Thread for group #{groups[group_id].name} (id = #{group_id}) is dead")
end
data[:thread].kill
data[:thread].join
data[:obj]&.disconnect!
false
end
# Spawn new threads for groups that are now synchronized.
groups.each do |group_id, group|
if !@sync_data[db][group_id]
puts("[EmailSync] Starting thread for group #{group.name} (id = #{group.id}) and mailbox #{group.imap_mailbox_name}")
@sync_data[db][group_id] = {
thread: start_thread(db, group), obj: nil
}
end
end
end
end
end
# Thread goes into sleep for a bit so it is better to return any
# connection back to the pool.
ActiveRecord::Base.connection_handler.clear_active_connections!
sleep 5
end
@sync_lock.synchronize { kill_threads }
Discourse.redis.del(HEARTBEAT_KEY)
exit 0
rescue => e
STDERR.puts e.message
STDERR.puts e.backtrace.join("\n")
exit 1
end
end