discourse/lib/imap/providers/generic.rb
Martin Brennan 87961534ea
FEATURE: IMAP detect spammed email and delete associated Discourse topic (#11654)
This PR adds functionality for the IMAP sync code to detect if a UID that is missing from the mail group mailbox is in the Spam/Junk folder for the mail account, and if so delete the associated Discourse topic. This is identical to what we do for emails that are moved for Trash.

If an email is missing but not in Spam or Trash, then we mark the incoming email record with imap_missing: true. This may be used in future to further filter or identify these emails, and perhaps go hunting for them in the email account in bulk.

Note: This adds some code duplication because the trash and spam email detection and handling is very similar. I intend to do more refactors/improvements to the IMAP sync code in time because there is a lot of room for improvement.
2021-01-14 09:54:18 +10:00

295 lines
8.9 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 SpamMailResponse
attr_accessor :spam_emails, :spam_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.
def trash_mailbox
Discourse.cache.fetch("imap_trash_mailbox_#{account_digest}", expires_in: 30.minutes) do
list_mailboxes(:Trash).first
end
end
# Look for the special Junk XLIST attribute.
def spam_mailbox
Discourse.cache.fetch("imap_spam_mailbox_#{account_digest}", expires_in: 30.minutes) do
list_mailboxes(:Junk).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
# open the spam mailbox for inspection or writing. after the yield we
# close the spam and reopen the original mailbox to continue operations.
# the normal open_mailbox call can be made if more extensive spam ops
# need to be done.
def open_spam_mailbox(write: false)
open_mailbox_before_spam = @open_mailbox_name
open_mailbox_before_spam_write = @open_mailbox_write
spam_uid_validity = open_mailbox(spam_mailbox, write: write)[:uid_validity]
yield(spam_uid_validity) if block_given?
open_mailbox(open_mailbox_before_spam, write: open_mailbox_before_spam_write)
spam_uid_validity
end
def find_trashed_by_message_ids(message_ids)
trashed_emails = []
trash_uid_validity = open_trash_mailbox do
trashed_email_uids = find_uids_by_message_ids(message_ids)
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 find_spam_by_message_ids(message_ids)
spam_emails = []
spam_uid_validity = open_spam_mailbox do
spam_email_uids = find_uids_by_message_ids(message_ids)
if spam_email_uids.any?
spam_emails = emails(spam_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
SpamMailResponse.new.tap do |resp|
resp.spam_emails = spam_emails
resp.spam_uid_validity = spam_uid_validity
end
end
def find_uids_by_message_ids(message_ids)
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(" ")}"
imap.uid_search(query)
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