Martin Brennan 95b71b35d6
FEATURE: IMAP delete email sync for group inboxes (#10392)
Adds functionality to reflect topic delete in Discourse to IMAP inbox (Gmail only for now) and reflecting Gmail deletes in Discourse.

Adding lots of tests, various refactors and code improvements.

When Discourse topic is destroyed in PostDestroyer mark the topic incoming email as imap_sync: true, and do the opposite when post is recovered.
2020-08-12 10:16:26 +10:00

216 lines
6.6 KiB
Ruby

# frozen_string_literal: true
module Imap
module Providers
# Gmail has a special header for both labels (X-GM-LABELS) and their
# threading system (X-GM-THRID). We need to monkey-patch Net::IMAP to
# get access to these. Also the archiving functionality is custom,
# all UIDs in a thread must have the \\Inbox label removed.
#
class Gmail < Generic
X_GM_LABELS = 'X-GM-LABELS'
X_GM_THRID = 'X-GM-THRID'
def imap
@imap ||= super.tap { |imap| apply_gmail_patch(imap) }
end
def emails(uids, fields, opts = {})
# gmail has a special header for labels
if fields.include?('LABELS')
fields[fields.index('LABELS')] = X_GM_LABELS
end
emails = super(uids, fields, opts)
emails.each do |email|
email['LABELS'] = Array(email['LABELS'])
if email[X_GM_LABELS]
email['LABELS'] << Array(email.delete(X_GM_LABELS))
email['LABELS'].flatten!
end
email['LABELS'] << '\\Inbox' if @open_mailbox_name == 'INBOX'
email['LABELS'].uniq!
end
emails
end
def store(uid, attribute, old_set, new_set)
attribute = X_GM_LABELS if attribute == 'LABELS'
super(uid, attribute, old_set, new_set)
end
def to_tag(label)
# Label `\\Starred` is Gmail equivalent of :Flagged (both present)
return 'starred' if label == :Flagged
return if label == '[Gmail]/All Mail'
label = label.to_s.gsub('[Gmail]/', '')
super(label)
end
def tag_to_flag(tag)
return :Flagged if tag == 'starred'
super(tag)
end
def tag_to_label(tag)
return '\\Important' if tag == 'important'
return '\\Starred' if tag == 'starred'
super(tag)
end
# All emails in the thread must be archived in Gmail for the thread
# to get removed from the inbox
def archive(uid)
thread_id = thread_id_from_uid(uid)
emails_to_archive = emails_in_thread(thread_id)
emails_to_archive.each do |email|
labels = email['LABELS']
new_labels = labels.reject { |l| l == "\\Inbox" }
store(email["UID"], "LABELS", labels, new_labels)
end
Imap::Sync::Logger.log("[IMAP] Thread ID #{thread_id} (UID #{uid}) archived in Gmail mailbox for #{@username}")
end
# Though Gmail considers the email thread unarchived if the first email
# has the \\Inbox label applied, we want to do this to all emails in the
# thread to be consistent with archive behaviour.
def unarchive(uid)
thread_id = thread_id_from_uid(uid)
emails_to_unarchive = emails_in_thread(thread_id)
emails_to_unarchive.each do |email|
labels = email['LABELS']
new_labels = labels.dup
if !new_labels.include?("\\Inbox")
new_labels << "\\Inbox"
end
store(email["UID"], "LABELS", labels, new_labels)
end
Imap::Sync::Logger.log("[IMAP] Thread ID #{thread_id} (UID #{uid}) unarchived in Gmail mailbox for #{@username}")
end
def thread_id_from_uid(uid)
fetched = imap.uid_fetch(uid, [X_GM_THRID])
if !fetched
raise "Thread not found for UID #{uid}!"
end
fetched.last.attr[X_GM_THRID]
end
def emails_in_thread(thread_id)
uids_to_fetch = imap.uid_search("#{X_GM_THRID} #{thread_id}")
emails(uids_to_fetch, ["UID", "LABELS"])
end
def trash_move(uid)
thread_id = thread_id_from_uid(uid)
email_uids_to_trash = emails_in_thread(thread_id).map { |e| e['UID'] }
imap.uid_move(email_uids_to_trash, trash_mailbox)
Imap::Sync::Logger.log("[IMAP] Thread ID #{thread_id} (UID #{uid}) trashed in Gmail mailbox for #{@username}")
{ trash_uid_validity: open_trash_mailbox, email_uids_to_trash: email_uids_to_trash }
end
private
def apply_gmail_patch(imap)
class << imap.instance_variable_get('@parser')
# Modified version of the original `msg_att` from here:
# https://github.com/ruby/ruby/blob/1cc8ff001da217d0e98d13fe61fbc9f5547ef722/lib/net/imap.rb#L2346
#
# This is done so we can extract X-GM-LABELS, X-GM-MSGID, and
# X-GM-THRID, all Gmail extended attributes.
#
# rubocop:disable Style/RedundantReturn
def msg_att(n)
match(T_LPAR)
attr = {}
while true
token = lookahead
case token.symbol
when T_RPAR
shift_token
break
when T_SPACE
shift_token
next
end
case token.value
when /\A(?:ENVELOPE)\z/ni
name, val = envelope_data
when /\A(?:FLAGS)\z/ni
name, val = flags_data
when /\A(?:INTERNALDATE)\z/ni
name, val = internaldate_data
when /\A(?:RFC822(?:\.HEADER|\.TEXT)?)\z/ni
name, val = rfc822_text
when /\A(?:RFC822\.SIZE)\z/ni
name, val = rfc822_size
when /\A(?:BODY(?:STRUCTURE)?)\z/ni
name, val = body_data
when /\A(?:UID)\z/ni
name, val = uid_data
when /\A(?:MODSEQ)\z/ni
name, val = modseq_data
# Adding support for GMail extended attributes.
when /\A(?:X-GM-LABELS)\z/ni
name, val = label_data
when /\A(?:X-GM-MSGID)\z/ni
name, val = uid_data
when /\A(?:X-GM-THRID)\z/ni
name, val = uid_data
# End custom support for Gmail.
else
parse_error("unknown attribute `%s' for {%d}", token.value, n)
end
attr[name] = val
end
return attr
end
def label_data
token = match(T_ATOM)
name = token.value.upcase
match(T_SPACE)
match(T_LPAR)
result = []
while true
token = lookahead
case token.symbol
when T_RPAR
shift_token
break
when T_SPACE
shift_token
end
token = lookahead
if string_token?(token)
result.push(string)
else
result.push(atom)
end
end
return name, result
end
# rubocop:enable Style/RedundantReturn
end
end
end
end
end