295 lines
8.9 KiB
Ruby
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
|