mirror of
https://github.com/discourse/discourse.git
synced 2024-11-30 08:54:36 +08:00
95b71b35d6
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.
249 lines
7.4 KiB
Ruby
249 lines
7.4 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'net/imap'
|
|
|
|
module Imap
|
|
module Providers
|
|
class WriteDisabledError < StandardError; end
|
|
|
|
class TrashedMailResponse
|
|
attr_accessor :trashed_emails, :trash_uid_validity
|
|
end
|
|
|
|
class BasicMail
|
|
attr_accessor :uid, :message_id
|
|
|
|
def initialize(uid: nil, message_id: nil)
|
|
@uid = uid
|
|
@message_id = message_id
|
|
end
|
|
end
|
|
|
|
class Generic
|
|
def initialize(server, options = {})
|
|
@server = server
|
|
@port = options[:port] || 993
|
|
@ssl = options[:ssl] || true
|
|
@username = options[:username]
|
|
@password = options[:password]
|
|
@timeout = options[:timeout] || 10
|
|
end
|
|
|
|
def account_digest
|
|
@account_digest ||= Digest::MD5.hexdigest("#{@username}:#{@server}")
|
|
end
|
|
|
|
def imap
|
|
@imap ||= Net::IMAP.new(@server, port: @port, ssl: @ssl, open_timeout: @timeout)
|
|
end
|
|
|
|
def disconnected?
|
|
@imap && @imap.disconnected?
|
|
end
|
|
|
|
def connect!
|
|
imap.login(@username, @password)
|
|
end
|
|
|
|
def disconnect!
|
|
imap.logout rescue nil
|
|
imap.disconnect
|
|
end
|
|
|
|
def can?(capability)
|
|
@capabilities ||= imap.responses['CAPABILITY'][-1] || imap.capability
|
|
@capabilities.include?(capability)
|
|
end
|
|
|
|
def uids(opts = {})
|
|
if opts[:from] && opts[:to]
|
|
imap.uid_search("UID #{opts[:from]}:#{opts[:to]}")
|
|
elsif opts[:from]
|
|
imap.uid_search("UID #{opts[:from]}:*")
|
|
elsif opts[:to]
|
|
imap.uid_search("UID 1:#{opts[:to]}")
|
|
else
|
|
imap.uid_search('ALL')
|
|
end
|
|
end
|
|
|
|
def labels
|
|
@labels ||= begin
|
|
labels = {}
|
|
|
|
list_mailboxes.each do |name|
|
|
if tag = to_tag(name)
|
|
labels[tag] = name
|
|
end
|
|
end
|
|
|
|
labels
|
|
end
|
|
end
|
|
|
|
def open_mailbox(mailbox_name, write: false)
|
|
if write
|
|
if !SiteSetting.enable_imap_write
|
|
raise WriteDisabledError.new("Two-way IMAP sync is disabled! Cannot write to inbox.")
|
|
end
|
|
imap.select(mailbox_name)
|
|
else
|
|
imap.examine(mailbox_name)
|
|
end
|
|
|
|
@open_mailbox_name = mailbox_name
|
|
@open_mailbox_write = write
|
|
|
|
{
|
|
uid_validity: imap.responses['UIDVALIDITY'][-1]
|
|
}
|
|
end
|
|
|
|
def emails(uids, fields, opts = {})
|
|
fetched = imap.uid_fetch(uids, fields)
|
|
|
|
# This will happen if the email does not exist in the provided mailbox.
|
|
# It may have been deleted or otherwise moved, e.g. if deleted in Gmail
|
|
# it will end up in "[Gmail]/Bin"
|
|
return [] if fetched.nil?
|
|
|
|
fetched.map do |email|
|
|
attributes = {}
|
|
|
|
fields.each do |field|
|
|
attributes[field] = email.attr[field]
|
|
end
|
|
|
|
attributes
|
|
end
|
|
end
|
|
|
|
def store(uid, attribute, old_set, new_set)
|
|
additions = new_set.reject { |val| old_set.include?(val) }
|
|
imap.uid_store(uid, "+#{attribute}", additions) if additions.length > 0
|
|
removals = old_set.reject { |val| new_set.include?(val) }
|
|
imap.uid_store(uid, "-#{attribute}", removals) if removals.length > 0
|
|
end
|
|
|
|
def to_tag(label)
|
|
label = DiscourseTagging.clean_tag(label.to_s)
|
|
label if label != 'inbox' && label != 'sent'
|
|
end
|
|
|
|
def tag_to_flag(tag)
|
|
:Seen if tag == 'seen'
|
|
end
|
|
|
|
def tag_to_label(tag)
|
|
tag
|
|
end
|
|
|
|
def list_mailboxes(attr_filter = nil)
|
|
# Basically, list all mailboxes in the root of the server.
|
|
# ref: https://tools.ietf.org/html/rfc3501#section-6.3.8
|
|
imap.list('', '*').reject do |m|
|
|
|
|
# Noselect cannot be selected with the SELECT command.
|
|
# technically we could use this for readonly mode when
|
|
# SiteSetting.imap_write is disabled...maybe a later TODO
|
|
# ref: https://tools.ietf.org/html/rfc3501#section-7.2.2
|
|
m.attr.include?(:Noselect)
|
|
end.select do |m|
|
|
|
|
# There are Special-Use mailboxes denoted by an attribute. For
|
|
# example, some common ones are \Trash or \Sent.
|
|
# ref: https://tools.ietf.org/html/rfc6154
|
|
if attr_filter
|
|
m.attr.include? attr_filter
|
|
else
|
|
true
|
|
end
|
|
end.map do |m|
|
|
m.name
|
|
end
|
|
end
|
|
|
|
def archive(uid)
|
|
# do nothing by default, just removing the Inbox label should be enough
|
|
end
|
|
|
|
def unarchive(uid)
|
|
# same as above
|
|
end
|
|
|
|
# Look for the special Trash XLIST attribute.
|
|
# TODO: It might be more efficient to just store this against the group.
|
|
# Another table is looking more and more attractive....
|
|
def trash_mailbox
|
|
Discourse.cache.fetch("imap_trash_mailbox_#{account_digest}", expires_in: 30.minutes) do
|
|
list_mailboxes(:Trash).first
|
|
end
|
|
end
|
|
|
|
# open the trash mailbox for inspection or writing. after the yield we
|
|
# close the trash and reopen the original mailbox to continue operations.
|
|
# the normal open_mailbox call can be made if more extensive trash ops
|
|
# need to be done.
|
|
def open_trash_mailbox(write: false)
|
|
open_mailbox_before_trash = @open_mailbox_name
|
|
open_mailbox_before_trash_write = @open_mailbox_write
|
|
|
|
trash_uid_validity = open_mailbox(trash_mailbox, write: write)[:uid_validity]
|
|
|
|
yield(trash_uid_validity) if block_given?
|
|
|
|
open_mailbox(open_mailbox_before_trash, write: open_mailbox_before_trash_write)
|
|
trash_uid_validity
|
|
end
|
|
|
|
def find_trashed_by_message_ids(message_ids)
|
|
trashed_emails = []
|
|
trash_uid_validity = open_trash_mailbox do
|
|
header_message_id_terms = message_ids.map do |msgid|
|
|
"HEADER Message-ID '#{Email.message_id_rfc_format(msgid)}'"
|
|
end
|
|
|
|
# OR clauses are written in Polish notation...so the query looks like this:
|
|
# OR OR HEADER Message-ID XXXX HEADER Message-ID XXXX HEADER Message-ID XXXX
|
|
or_clauses = 'OR ' * (header_message_id_terms.length - 1)
|
|
query = "#{or_clauses}#{header_message_id_terms.join(" ")}"
|
|
|
|
trashed_email_uids = imap.uid_search(query)
|
|
if trashed_email_uids.any?
|
|
trashed_emails = emails(trashed_email_uids, ["UID", "ENVELOPE"]).map do |e|
|
|
BasicMail.new(message_id: Email.message_id_clean(e['ENVELOPE'].message_id), uid: e['UID'])
|
|
end
|
|
end
|
|
end
|
|
|
|
TrashedMailResponse.new.tap do |resp|
|
|
resp.trashed_emails = trashed_emails
|
|
resp.trash_uid_validity = trash_uid_validity
|
|
end
|
|
end
|
|
|
|
def trash(uid)
|
|
# MOVE is way easier than doing the COPY \Deleted EXPUNGE dance ourselves.
|
|
# It is supported by Gmail and Outlook.
|
|
if can?('MOVE')
|
|
trash_move(uid)
|
|
else
|
|
|
|
# default behaviour for IMAP servers is to add the \Deleted flag
|
|
# then EXPUNGE the mailbox which permanently deletes these messages
|
|
# https://tools.ietf.org/html/rfc3501#section-6.4.3
|
|
#
|
|
# TODO: We may want to add the option at some point to copy to some
|
|
# other mailbox first before doing this (e.g. Trash)
|
|
store(uid, 'FLAGS', [], ["\\Deleted"])
|
|
imap.expunge
|
|
end
|
|
end
|
|
|
|
def trash_move(uid)
|
|
# up to the provider
|
|
end
|
|
end
|
|
end
|
|
end
|