discourse/lib/imap/providers/generic.rb
2023-01-09 12:10:19 +00:00

321 lines
9.6 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!
begin
imap.logout
rescue StandardError
nil
end
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 { |field| attributes[field] = email.attr[field] }
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)
# Lists all the mailboxes but just returns the names.
list_mailboxes_with_attributes(attr_filter).map(&:name)
end
def list_mailboxes_with_attributes(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
end
def filter_mailboxes(mailboxes)
# we do not want to filter out any mailboxes for generic providers,
# because we do not know what they are ahead of time
mailboxes
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, %w[UID ENVELOPE]).map do |e|
BasicMail.new(
message_id: Email::MessageIdService.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, %w[UID ENVELOPE]).map do |e|
BasicMail.new(
message_id: Email::MessageIdService.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::MessageIdService.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