# 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