mirror of
https://github.com/discourse/discourse.git
synced 2024-11-26 03:23:38 +08:00
964da21817
This overhauls the user interface for the group email settings management, aiming to make it a lot easier to test the settings entered and confirm they are correct before proceeding. We do this by forcing the user to test the settings before they can be saved to the database. It also includes some quality of life improvements around setting up IMAP and SMTP for our first supported provider, GMail. This PR does not remove the old group email config, that will come in a subsequent PR. This is related to https://meta.discourse.org/t/imap-support-for-group-inboxes/160588 so read that if you would like more backstory. ### UI Both site settings of `enable_imap` and `enable_smtp` must be true to test this. You must enable SMTP first to enable IMAP. You can prefill the SMTP settings with GMail configuration. To proceed with saving these settings you must test them, which is handled by the EmailSettingsValidator. If there is an issue with the configuration or credentials a meaningful error message should be shown. IMAP settings must also be validated when IMAP is enabled, before saving. When saving IMAP, we fetch the mailboxes for that account and populate them. This mailbox must be selected and saved for IMAP to work (the feature acts as though it is disabled until the mailbox is selected and saved): ### Database & Backend This adds several columns to the Groups table. The purpose of this change is to make it much more explicit that SMTP/IMAP is enabled for a group, rather than relying on settings not being null. Also included is an UPDATE query to backfill these columns. These columns are automatically filled when updating the group. For GMail, we now filter the mailboxes returned. This is so users cannot use a mailbox like Sent or Trash for syncing, which would generally be disastrous. There is a new group endpoint for testing email settings. This may be useful in the future for other places in our UI, at which point it can be extracted to a more generic endpoint or module to be included.
304 lines
9.2 KiB
Ruby
304 lines
9.2 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)
|
|
# 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, ["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
|