discourse/app/services/email_settings_validator.rb
Martin Brennan 7b627dc14b
FIX: Office365/Outlook auth method for group SMTP (#27854)
Both office365 and outlook SMTP servers need LOGIN
SMTP authentication instead of PLAIN (which is what
we are using by default). This commit uses that
unconditionally for these servers, and also makes
sure to use STARTTLS for them too.
2024-07-11 16:16:54 +10:00

209 lines
7.1 KiB
Ruby

# frozen_string_literal: true
require "net/imap"
require "net/smtp"
require "net/pop"
# Usage:
#
# begin
# EmailSettingsValidator.validate_imap(host: "imap.test.com", port: 999, username: "test@test.com", password: "password")
#
# # or for specific host preset
# EmailSettingsValidator.validate_imap(**{ username: "test@gmail.com", password: "test" }.merge(Email.gmail_imap_settings))
#
# rescue *EmailSettingsExceptionHandler::EXPECTED_EXCEPTIONS => err
# EmailSettingsExceptionHandler.friendly_exception_message(err, host)
# end
class EmailSettingsValidator
def self.validate_as_user(user, protocol, **kwargs)
DistributedMutex.synchronize("validate_#{protocol}_#{user.id}", validity: 10) do
self.public_send("validate_#{protocol}", **kwargs)
end
end
##
# Attempts to authenticate and disconnect a POP3 session and if that raises
# an error then it is assumed the credentials or some other settings are wrong.
#
# @param debug [Boolean] - When set to true, any errors will be logged at a warning
# level before being re-raised.
def self.validate_pop3(
host:,
port:,
username:,
password:,
ssl: SiteSetting.pop3_polling_ssl,
openssl_verify: SiteSetting.pop3_polling_openssl_verify,
debug: Rails.env.development?
)
begin
pop3 = Net::POP3.new(host, port)
# Note that we do not allow which verification mode to be specified
# like we do for SMTP, we just pick TLS1_2 if the SSL and openSSL verify
# options have been enabled.
if ssl
if openssl_verify
pop3.enable_ssl(max_version: OpenSSL::SSL::TLS1_2_VERSION)
else
pop3.enable_ssl(OpenSSL::SSL::VERIFY_NONE)
end
end
# This disconnects itself, unlike SMTP and IMAP.
pop3.auth_only(username, password)
rescue => err
log_and_raise(err, debug)
end
end
##
# Attempts to start an SMTP session and if that raises an error then it is
# assumed the credentials or other settings are wrong.
#
# For Gmail, the port should be 587, enable_starttls_auto should be true,
# and enable_tls should be false.
#
# @param domain [String] - Used for HELO, should be the FQDN of the server sending the mail
# localhost can be used in development mode.
# See https://datatracker.ietf.org/doc/html/rfc788#section-4
# @param debug [Boolean] - When set to true, any errors will be logged at a warning
# level before being re-raised.
def self.validate_smtp(
host:,
port:,
username:,
password:,
domain: nil,
authentication: nil,
enable_starttls_auto: GlobalSetting.smtp_enable_start_tls,
enable_tls: GlobalSetting.smtp_force_tls,
openssl_verify_mode: GlobalSetting.smtp_openssl_verify_mode,
debug: Rails.env.development?
)
begin
port, enable_tls, enable_starttls_auto =
provider_specific_ssl_overrides(host, port, enable_tls, enable_starttls_auto)
if enable_tls && enable_starttls_auto
raise ArgumentError, "TLS and STARTTLS are mutually exclusive"
end
if username || password
authentication = provider_specific_authentication_overrides(host) if authentication.nil?
authentication = (authentication || GlobalSetting.smtp_authentication)&.to_sym
if !%i[plain login cram_md5].include?(authentication)
raise ArgumentError, "Invalid authentication method. Must be plain, login, or cram_md5."
end
else
authentication = nil
end
if domain.blank?
if Rails.env.development?
domain = "localhost"
else
# Because we are using the SMTP settings here to send emails,
# the domain should just be the TLD of the host.
domain = MiniSuffix.domain(host)
end
end
smtp = Net::SMTP.new(host, port)
# These SSL options are cribbed from the Mail gem, which is used internally
# by ActionMailer. Unfortunately the mail gem hides this setup in private
# methods, e.g. https://github.com/mikel/mail/blob/master/lib/mail/network/delivery_methods/smtp.rb#L112-L147
#
# Relying on the GlobalSetting options is a good idea here.
#
# For specific use cases, options should be passed in from higher up. For example
# Gmail needs either port 465 and tls enabled, or port 587 and starttls_auto.
if openssl_verify_mode.kind_of?(String)
openssl_verify_mode = OpenSSL::SSL.const_get("VERIFY_#{openssl_verify_mode.upcase}")
end
ssl_context = Net::SMTP.default_ssl_context
ssl_context.verify_mode = openssl_verify_mode if openssl_verify_mode
smtp.enable_starttls_auto(ssl_context) if enable_starttls_auto
smtp.enable_tls(ssl_context) if enable_tls
smtp.open_timeout = 5
# Some SMTP servers have a higher delay to respond with errors
# as a tarpit measure that slows down clients who are sending "bad" commands.
# 10s is the minimum, we might need to increase this in the future.
smtp.read_timeout = 10
smtp.start(domain, username, password, authentication)
smtp.finish
rescue => err
log_and_raise(err, debug)
end
end
##
# Attempts to login, logout, and disconnect an IMAP session and if that raises
# an error then it is assumed the credentials or some other settings are wrong.
#
# @param debug [Boolean] - When set to true, any errors will be logged at a warning
# level before being re-raised.
def self.validate_imap(
host:,
port:,
username:,
password:,
open_timeout: 5,
ssl: true,
debug: false
)
begin
imap = Net::IMAP.new(host, port: port, ssl: ssl, open_timeout: open_timeout)
imap.login(username, password)
begin
imap.logout
rescue StandardError
nil
end
imap.disconnect
rescue => err
log_and_raise(err, debug)
end
end
def self.log_and_raise(err, debug)
if debug
Rails.logger.warn(
"[EmailSettingsValidator] Error encountered when validating email settings: #{err.message} #{err.backtrace.join("\n")}",
)
end
raise err
end
# Ideally we (or net-smtp) would automatically detect the correct authentication
# method, but this is sufficient for our purposes because we know certain providers
# need certain authentication methods. This may need to change when we start to
# use XOAUTH2 for SMTP.
def self.provider_specific_authentication_overrides(host)
return :login if %w[smtp.office365.com smtp-mail.outlook.com].include?(host)
:plain
end
def self.provider_specific_ssl_overrides(host, port, enable_tls, enable_starttls_auto)
# Certain mail servers act weirdly if you do not use the correct combinations of
# TLS settings based on the port, we clean these up here for the user.
if %w[smtp.gmail.com smtp.office365.com smtp-mail.outlook.com].include?(host)
if port.to_i == 587
enable_starttls_auto = true
enable_tls = false
elsif port.to_i == 465
enable_starttls_auto = false
enable_tls = true
end
end
[port, enable_tls, enable_starttls_auto]
end
end