discourse/lib/email/receiver.rb
Alan Guo Xiang Tan 322a3be2db
DEV: Remove logical OR assignment of constants (#29201)
Constants should always be only assigned once. The logical OR assignment
of a constant is a relic of the past before we used zeitwerk for
autoloading and had bugs where a file could be loaded twice resulting in
constant redefinition warnings.
2024-10-16 10:09:07 +08:00

1673 lines
55 KiB
Ruby

# frozen_string_literal: true
require "digest"
module Email
class Receiver
# If you add a new error, you need to
# * add it to Email::Processor#handle_failure()
# * add text to server.en.yml (parent key: "emails.incoming.errors")
class ProcessingError < StandardError
end
class EmptyEmailError < ProcessingError
end
class ScreenedEmailError < ProcessingError
end
class UserNotFoundError < ProcessingError
end
class AutoGeneratedEmailError < ProcessingError
end
class BouncedEmailError < ProcessingError
end
class NoBodyDetectedError < ProcessingError
end
class NoSenderDetectedError < ProcessingError
end
class FromReplyByAddressError < ProcessingError
end
class InactiveUserError < ProcessingError
end
class SilencedUserError < ProcessingError
end
class BadDestinationAddress < ProcessingError
end
class StrangersNotAllowedError < ProcessingError
end
class ReplyNotAllowedError < ProcessingError
end
class InsufficientTrustLevelError < ProcessingError
end
class ReplyUserNotMatchingError < ProcessingError
end
class TopicNotFoundError < ProcessingError
end
class TopicClosedError < ProcessingError
end
class InvalidPost < ProcessingError
end
class TooShortPost < ProcessingError
end
class InvalidPostAction < ProcessingError
end
class UnsubscribeNotAllowed < ProcessingError
end
class EmailNotAllowed < ProcessingError
end
class OldDestinationError < ProcessingError
end
class ReplyToDigestError < ProcessingError
end
class TooManyRecipientsError < ProcessingError
attr_reader :recipients_count
def initialize(recipients_count:)
@recipients_count = recipients_count
end
end
attr_reader :incoming_email
attr_reader :raw_email
attr_reader :mail
attr_reader :message_id
COMMON_ENCODINGS = [-"utf-8", -"windows-1252", -"iso-8859-1"]
def self.formats
@formats ||= Enum.new(plaintext: 1, markdown: 2)
end
def initialize(mail_string, opts = {})
raise EmptyEmailError if mail_string.blank?
@staged_users = []
@created_staged_users = []
@raw_email = mail_string
COMMON_ENCODINGS.each do |encoding|
fixed = try_to_encode(mail_string, encoding)
break @raw_email = fixed if fixed.present?
end
@mail = Mail.new(@raw_email)
@message_id = @mail.message_id.presence || Digest::MD5.hexdigest(mail_string)
@opts = opts
@destinations ||= opts[:destinations]
end
def process!
return if is_blocked?
id_hash = Digest::SHA1.hexdigest(@message_id)
DistributedMutex.synchronize("process_email_#{id_hash}") do
begin
# If we find an existing incoming email record with the exact same `message_id`
# do not create a new `IncomingEmail` record to avoid double ups.
return if @incoming_email = find_existing_and_update_imap
Email::Validator.ensure_valid!(@mail)
@from_email, @from_display_name = parse_from_field(@mail)
@from_user = User.find_by_email(@from_email)
@incoming_email = create_incoming_email
post = process_internal
raise BouncedEmailError if is_bounce?
post
rescue Exception => e
@incoming_email.update_columns(error: e.class.name) if @incoming_email
delete_created_staged_users
raise
end
end
end
def find_existing_and_update_imap
return unless incoming_email = IncomingEmail.find_by(message_id: @message_id)
# If we are not doing this for IMAP purposes just return the record.
return incoming_email if @opts[:imap_uid].blank?
# If the message_id matches the post id regexp then we
# generated the message_id not the imap server, e.g. in GroupSmtpEmail,
# so we want to update the incoming email because it will
# be missing IMAP details.
#
# Otherwise the incoming email is a completely new one from the IMAP
# server (e.g. a message_id generated by Gmail) and does not need to
# be updated, because message_ids from the IMAP server are not guaranteed
# to be unique.
return unless Email::MessageIdService.discourse_generated_message_id?(@message_id)
incoming_email.update(
imap_uid_validity: @opts[:imap_uid_validity],
imap_uid: @opts[:imap_uid],
imap_group_id: @opts[:imap_group_id],
imap_sync: false,
)
incoming_email
end
def is_blocked?
return false if SiteSetting.ignore_by_title.blank?
Regexp.new(SiteSetting.ignore_by_title, Regexp::IGNORECASE) =~ @mail.subject
end
def create_incoming_email
cc_addresses = Array.wrap(@mail.cc)
cc_addresses.concat(embedded_email.cc) if has_been_forwarded? && embedded_email&.cc
IncomingEmail.create(
message_id: @message_id,
raw: Email::Cleaner.new(@raw_email).execute,
subject: subject,
from_address: @from_email,
to_addresses: @mail.to,
cc_addresses: cc_addresses,
imap_uid_validity: @opts[:imap_uid_validity],
imap_uid: @opts[:imap_uid],
imap_group_id: @opts[:imap_group_id],
imap_sync: false,
created_via: IncomingEmail.created_via_types[@opts[:source] || :unknown],
)
end
def process_internal
handle_bounce if is_bounce?
raise NoSenderDetectedError if @from_email.blank?
raise FromReplyByAddressError if is_from_reply_by_email_address?
raise ScreenedEmailError if ScreenedEmail.should_block?(@from_email)
user = @from_user
if user.present?
log_and_validate_user(user)
else
raise UserNotFoundError unless SiteSetting.enable_staged_users
end
recipients = get_all_recipients(@mail)
if recipients.size > SiteSetting.maximum_recipients_per_new_group_email
raise TooManyRecipientsError.new(recipients_count: recipients.size)
end
body, elided = select_body
body ||= ""
raise NoBodyDetectedError if body.blank? && attachments.empty? && !is_bounce?
if is_auto_generated? && !sent_to_mailinglist_mirror?
@incoming_email.update_columns(is_auto_generated: true)
if SiteSetting.block_auto_generated_emails? && !is_bounce? && !@opts[:allow_auto_generated]
raise AutoGeneratedEmailError
end
end
if action = subscription_action_for(body, subject)
raise UnsubscribeNotAllowed if user.nil?
send_subscription_mail(action, user)
return
end
if post = find_related_post
# Most of the time, it is impossible to **reply** without a reply key, so exit early
if user.blank?
if sent_to_mailinglist_mirror? || !SiteSetting.find_related_post_with_key
user = stage_from_user
elsif user.blank?
raise BadDestinationAddress
end
end
create_reply(
user: user,
raw: body,
elided: elided,
post: post,
topic: post.topic,
skip_validations: user.staged?,
bounce: is_bounce?,
)
else
first_exception = nil
destinations.each do |destination|
begin
return process_destination(destination, user, body, elided)
rescue => e
first_exception ||= e
end
end
raise first_exception if first_exception
# We don't stage new users for emails to reply addresses, exit if user is nil
raise BadDestinationAddress if user.blank?
# We only get here if there are no destinations (the email is not going to
# a Category, Group, or PostReplyKey)
post = find_related_post(force: true)
if post && Guardian.new(user).can_see_post?(post)
if destination_too_old?(post)
raise OldDestinationError.new("#{Discourse.base_url}/p/#{post.id}")
end
end
if EmailLog.where(email_type: "digest", message_id: @mail.in_reply_to).exists?
raise ReplyToDigestError
end
raise BadDestinationAddress
end
end
def log_and_validate_user(user)
@incoming_email.update_columns(user_id: user.id)
raise InactiveUserError if !user.active && !user.staged
raise SilencedUserError if user.silenced?
end
def get_all_recipients(mail)
recipients = Set.new
%i[to cc bcc].each do |field|
next if mail[field].blank?
mail[field].each do |address_field|
begin
address_field.decoded
recipients << address_field.address.downcase
end
end
end
recipients
end
def is_bounce?
@mail.bounced? || bounce_key
end
def handle_bounce
@incoming_email.update_columns(is_bounce: true)
mail_error_statuses = Array.wrap(@mail.error_status)
if email_log.present?
email_log.update_columns(bounced: true, bounce_error_code: mail_error_statuses.first)
post = email_log.post
topic = email_log.topic
end
DiscourseEvent.trigger(:email_bounce, @mail, @incoming_email, @email_log)
if mail_error_statuses.any? { |s| s.start_with?(Email::SMTP_STATUS_TRANSIENT_FAILURE) }
Email::Receiver.update_bounce_score(@from_email, SiteSetting.soft_bounce_score)
else
Email::Receiver.update_bounce_score(@from_email, SiteSetting.hard_bounce_score)
end
if SiteSetting.whispers_allowed_groups.present? && @from_user&.staged?
return if email_log.blank?
if post.present? && topic.present? && topic.archetype == Archetype.private_message
body, elided = select_body
body ||= ""
create_reply(
user: @from_user,
raw: body,
elided: elided,
post: post,
topic: topic,
skip_validations: true,
bounce: true,
)
end
end
raise BouncedEmailError
end
def is_from_reply_by_email_address?
Email::Receiver.reply_by_email_address_regex.match(@from_email)
end
def bounce_key
@bounce_key ||=
begin
verp = all_destinations.select { |to| to[/\+verp-\h{32}@/] }.first
verp && verp[/\+verp-(\h{32})@/, 1]
end
end
def email_log
return nil if bounce_key.blank?
@email_log ||= EmailLog.find_by(bounce_key: bounce_key)
end
def self.update_bounce_score(email, score)
if user = User.find_by_email(email)
old_bounce_score = user.user_stat.bounce_score
new_bounce_score = old_bounce_score + score
range = (old_bounce_score + 1..new_bounce_score)
user.user_stat.bounce_score = new_bounce_score
user.user_stat.reset_bounce_score_after =
SiteSetting.reset_bounce_score_after_days.days.from_now
user.user_stat.save!
if range === SiteSetting.bounce_score_threshold
# NOTE: we check bounce_score before sending emails
# So log we revoked the email...
reason =
I18n.t(
"user.email.revoked",
email: user.email,
date: user.user_stat.reset_bounce_score_after,
)
StaffActionLogger.new(Discourse.system_user).log_revoke_email(user, reason)
# ... and PM the user
SystemMessage.create_from_system_user(user, :email_revoked)
end
end
end
def is_auto_generated?
return false if SiteSetting.auto_generated_allowlist.split("|").include?(@from_email)
@mail[:precedence].to_s[/list|junk|bulk|auto_reply/i] ||
@mail[:from].to_s[/(mailer[\-_]?daemon|post[\-_]?master|no[\-_]?reply)@/i] ||
@mail[:subject].to_s[
/\A\s*(Auto:|Automatic reply|Autosvar|Automatisk svar|Automatisch antwoord|Abwesenheitsnotiz|Risposta Non al computer|Automatisch antwoord|Auto Response|Respuesta automática|Fuori sede|Out of Office|Frånvaro|Réponse automatique)/i
] ||
@mail.header.reject { |h| h.name.downcase == "x-auto-response-suppress" }.to_s[
/auto[\-_]?(response|submitted|replied|reply|generated|respond)|holidayreply|machinegenerated/i
]
end
def is_spam?
case SiteSetting.email_in_spam_header
when "X-Spam-Flag"
@mail[:x_spam_flag].to_s[/YES/i]
when "X-Spam-Status"
@mail[:x_spam_status].to_s[/\AYes, /i]
when "X-SES-Spam-Verdict"
@mail[:x_ses_spam_verdict].to_s[/FAIL/i]
else
false
end
end
def auth_res_action
@auth_res_action ||= AuthenticationResults.new(@mail.header[:authentication_results]).action
end
def select_body
text = nil
html = nil
text_content_type = nil
if @mail.multipart?
text = fix_charset(@mail.text_part)
html = fix_charset(@mail.html_part)
text_content_type = @mail.text_part&.content_type
elsif @mail.content_type.to_s["text/html"]
html = fix_charset(@mail)
elsif @mail.content_type.blank? || @mail.content_type["text/plain"]
text = fix_charset(@mail)
text_content_type = @mail.content_type
end
return if text.blank? && html.blank?
if text.present?
text = trim_discourse_markers(text)
text, elided_text = trim_reply_and_extract_elided(text)
if @opts[:convert_plaintext] || sent_to_mailinglist_mirror?
text_content_type ||= ""
converter_opts = {
format_flowed: !!(text_content_type =~ /format\s*=\s*["']?flowed["']?/i),
delete_flowed_space: !!(text_content_type =~ /DelSp\s*=\s*["']?yes["']?/i),
}
text = PlainTextToMarkdown.new(text, converter_opts).to_markdown
elided_text = PlainTextToMarkdown.new(elided_text, converter_opts).to_markdown
end
end
# keep track of inlined images in html version
# so we can later check if they were elided
@cids = (html.presence || "").scan(/src\s*=\s*['"](cid:.+?)["']/).flatten
markdown, elided_markdown =
if html.present?
# use the first html extracter that matches
if html_extracter = HTML_EXTRACTERS.select { |_, r| html[r] }.min_by { |_, r| html =~ r }
doc = Nokogiri::HTML5.fragment(html)
self.public_send(:"extract_from_#{html_extracter[0]}", doc)
else
markdown =
HtmlToMarkdown.new(html, keep_img_tags: true, keep_cid_imgs: true).to_markdown
markdown = trim_discourse_markers(markdown)
trim_reply_and_extract_elided(markdown)
end
end
text_format = Receiver.formats[:plaintext]
if text.blank? || (SiteSetting.incoming_email_prefer_html && markdown.present?)
text, elided_text, text_format = markdown, elided_markdown, Receiver.formats[:markdown]
end
if SiteSetting.strip_incoming_email_lines && text.present?
in_code = nil
text =
text
.lines
.map! do |line|
stripped = line.strip << "\n"
# Do not strip list items.
if (stripped[0] == "*" || stripped[0] == "-" || stripped[0] == "+") &&
stripped[1] == " "
next line
end
# Match beginning and ending of code blocks.
if !in_code && stripped[0..2] == "```"
in_code = "```"
elsif in_code == "```" && stripped[0..2] == "```"
in_code = nil
elsif !in_code && stripped[0..4] == "[code"
in_code = "[code]"
elsif in_code == "[code]" && stripped[0..6] == "[/code]"
in_code = nil
end
# Strip only lines outside code blocks.
in_code ? line : stripped
end
.join
end
[text, elided_text, text_format]
end
def to_markdown(html, elided_html)
markdown = HtmlToMarkdown.new(html, keep_img_tags: true, keep_cid_imgs: true).to_markdown
elided_markdown =
HtmlToMarkdown.new(elided_html, keep_img_tags: true, keep_cid_imgs: true).to_markdown
[EmailReplyTrimmer.trim(markdown), elided_markdown]
end
HTML_EXTRACTERS = [
[:gmail, /class="gmail_(signature|extra)/],
[:outlook, /id="(divRplyFwdMsg|Signature)"/],
[:word, /class="WordSection1"/],
[:exchange, /name="message(Body|Reply)Section"/],
[:apple_mail, /id="AppleMailSignature"/],
[:mozilla, /class="moz-/],
[:protonmail, /class="protonmail_/],
[:zimbra, /data-marker="__/],
[:newton, /(id|class)="cm_/],
[:front, /class="front-/],
]
def extract_from_gmail(doc)
# GMail adds a bunch of 'gmail_' prefixed classes like: gmail_signature, gmail_extra, gmail_quote, gmail_default...
elided = doc.css(".gmail_signature, .gmail_extra").remove
to_markdown(doc.to_html, elided.to_html)
end
def extract_from_outlook(doc)
# Outlook properly identifies the signature and any replied/forwarded email
# Use their id to remove them and anything that comes after
elided = doc.css("#Signature, #Signature ~ *, hr, #divRplyFwdMsg, #divRplyFwdMsg ~ *").remove
to_markdown(doc.to_html, elided.to_html)
end
def extract_from_word(doc)
# Word (?) keeps the content in the 'WordSection1' class and uses <p> tags
# When there's something else (<table>, <div>, etc..) there's high chance it's a signature or forwarded email
elided =
doc.css(
".WordSection1 > :not(p):not(ul):first-of-type, .WordSection1 > :not(p):not(ul):first-of-type ~ *",
).remove
to_markdown(doc.at(".WordSection1").to_html, elided.to_html)
end
def extract_from_exchange(doc)
# Exchange is using the 'messageReplySection' class for forwarded emails
# And 'messageBodySection' for the actual email
elided = doc.css("div[name='messageReplySection']").remove
to_markdown(doc.css("div[name='messageReplySection']").to_html, elided.to_html)
end
def extract_from_apple_mail(doc)
# AppleMail is the worst. It adds 'AppleMailSignature' ids (!) to several div/p with no deterministic rules
# Our best guess is to elide whatever comes after that.
elided = doc.css("#AppleMailSignature:last-of-type ~ *").remove
to_markdown(doc.to_html, elided.to_html)
end
def extract_from_mozilla(doc)
# Mozilla (Thunderbird ?) properly identifies signature and forwarded emails
# Remove them and anything that comes after
elided =
doc.css(
"*[class^='moz-cite'], *[class^='moz-cite'] ~ *, " \
"*[class^='moz-signature'], *[class^='moz-signature'] ~ *, " \
"*[class^='moz-forward'], *[class^='moz-forward'] ~ *",
).remove
to_markdown(doc.to_html, elided.to_html)
end
def extract_from_protonmail(doc)
# Removes anything that has a class starting with "protonmail_" and everything after that
elided = doc.css("*[class^='protonmail_'], *[class^='protonmail_'] ~ *").remove
to_markdown(doc.to_html, elided.to_html)
end
def extract_from_zimbra(doc)
# Removes anything that has a 'data-marker' attribute
elided = doc.css("*[data-marker]").remove
to_markdown(doc.to_html, elided.to_html)
end
def extract_from_newton(doc)
# Removes anything that has an id or a class starting with 'cm_'
elided = doc.css("*[id^='cm_'], *[class^='cm_']").remove
to_markdown(doc.to_html, elided.to_html)
end
def extract_from_front(doc)
# Removes anything that has a class starting with 'front-'
elided = doc.css("*[class^='front-']").remove
to_markdown(doc.to_html, elided.to_html)
end
def trim_reply_and_extract_elided(text)
return text, "" if @opts[:skip_trimming] || !SiteSetting.trim_incoming_emails
EmailReplyTrimmer.trim(text, true)
end
def fix_charset(mail_part)
return nil if mail_part.blank? || mail_part.body.blank?
string =
begin
mail_part.body.decoded
rescue StandardError
nil
end
return nil if string.blank?
# common encodings
encodings = COMMON_ENCODINGS.dup
encodings.unshift(mail_part.charset) if mail_part.charset.present?
# mail (>=2.5) decodes mails with 8bit transfer encoding to utf-8, so always try UTF-8 first
if mail_part.content_transfer_encoding == "8bit"
encodings.delete("UTF-8")
encodings.unshift("UTF-8")
end
encodings.uniq.each do |encoding|
fixed = try_to_encode(string, encoding)
return fixed if fixed.present?
end
nil
end
def try_to_encode(string, encoding)
encoded = string.encode("UTF-8", encoding)
!encoded.nil? && encoded.valid_encoding? ? encoded : nil
rescue Encoding::InvalidByteSequenceError,
Encoding::UndefinedConversionError,
Encoding::ConverterNotFoundError
nil
end
def previous_replies_regex
strings =
I18n
.available_locales
.map do |locale|
I18n.with_locale(locale) { I18n.t("user_notifications.previous_discussion") }
end
.uniq
@previous_replies_regex ||=
/\A--[- ]\n\*(?:#{strings.map { |x| Regexp.escape(x) }.join("|")})\*\n/im
end
def reply_above_line_regex
strings =
I18n
.available_locales
.map do |locale|
I18n.with_locale(locale) { I18n.t("user_notifications.reply_above_line") }
end
.uniq
@reply_above_line_regex ||= /\n(?:#{strings.map { |x| Regexp.escape(x) }.join("|")})\n/im
end
def trim_discourse_markers(reply)
return "" if reply.blank?
reply = reply.split(previous_replies_regex)[0]
reply.split(reply_above_line_regex)[0]
end
def parse_from_field(mail, process_forwarded_emails: true)
if email_log.present?
email = email_log.to_address || email_log.user&.email
return email, email_log.user&.username
elsif mail.bounced?
# "Final-Recipient" has a specific format (<name> ; <address>)
# cf. https://www.ietf.org/rfc/rfc2298.html#section-3.2.4
address_type, generic_address = mail.final_recipient.to_s.split(";").map { _1.to_s.strip }
return generic_address, nil if generic_address.include?("@") && address_type == "rfc822"
end
return unless mail[:from]
# For forwarded emails, where the from address matches a group incoming
# email, we want to use the from address of the original email sender,
# which we can extract from embedded_email_raw.
if process_forwarded_emails && has_been_forwarded?
if mail[:from].to_s =~ group_incoming_emails_regex
if embedded_email && embedded_email[:from].errors.blank?
from_address, from_display_name =
Email::Receiver.extract_email_address_and_name(embedded_email[:from])
return from_address, from_display_name if from_address
end
end
end
# extract proper sender when using mailman mailing list
if mail[:x_mailman_version].present?
address, name = Email::Receiver.extract_email_address_and_name_from_mailman(mail)
return address, name if address
end
# For now we are only using the Reply-To header if the email has
# been forwarded via Google Groups, which is why we are checking the
# X-Original-From header too. In future we may want to use the Reply-To
# header in more cases.
if mail[:x_original_from].present? && mail[:reply_to].present?
original_from_address, _ =
Email::Receiver.extract_email_address_and_name(mail[:x_original_from])
reply_to_address, reply_to_name =
Email::Receiver.extract_email_address_and_name(mail[:reply_to])
return reply_to_address, reply_to_name if original_from_address == reply_to_address
end
Email::Receiver.extract_email_address_and_name(mail[:from])
rescue StandardError
nil
end
def self.extract_email_address_and_name_from_mailman(mail)
list_address, _ = Email::Receiver.extract_email_address_and_name(mail[:list_post])
list_address, _ =
Email::Receiver.extract_email_address_and_name(mail[:x_beenthere]) if list_address.blank?
return if list_address.blank?
# the CC header often includes the name of the sender
address_to_name = mail[:cc]&.element&.addresses&.to_h { [_1.address, _1.name] } || {}
%i[from reply_to x_mailfrom x_original_from].each do |header|
next if mail[header].blank?
email, name = Email::Receiver.extract_email_address_and_name(mail[header])
if email.present? && email != list_address
return email, name.presence || address_to_name[email]
end
end
end
def self.extract_email_address_and_name(value)
begin
# ensure the email header value is a string
value = value.to_s
# in embedded emails, converts [mailto:foo@bar.com] to <foo@bar.com>
value = value.gsub(/\[mailto:([^\[\]]+?)\]/, "<\\1>")
# 'mailto:' suffix isn't supported by Mail::Address parsing
value = value.gsub("mailto:", "")
# parse the email header value
parsed = Mail::Address.new(value)
# extract the email address and name
mail = parsed.address.to_s.downcase.strip
name = parsed.name.to_s.strip
# ensure the email address is "valid"
if mail.include?("@")
# remove surrounding quotes from the name
name = name[1...-1] if name.size > 2 && name[/\A(['"]).+(\1)\z/]
# return the email address and name
[mail, name]
end
rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError
# something went wrong parsing the email header value, return nil
end
end
def subject
@subject ||=
if mail_subject = @mail.subject
mail_subject.delete("\u0000")[0..254]
else
I18n.t("emails.incoming.default_subject", email: @from_email)
end
end
def find_or_create_user(email, display_name, raise_on_failed_create: false, user: nil)
User.transaction do
user ||= User.find_by_email(email)
if user.nil? && SiteSetting.enable_staged_users
raise EmailNotAllowed unless EmailValidator.allowed?(email)
username = UserNameSuggester.sanitize_username(display_name) if display_name.present?
begin
user =
User.create!(
email: email,
username: UserNameSuggester.suggest(username.presence || email),
name: display_name.presence || User.suggest_name(email),
staged: true,
)
@created_staged_users << user
rescue PG::UniqueViolation, ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
raise if raise_on_failed_create
user = nil
end
end
@staged_users << user if user&.staged?
end
user
end
def find_or_create_user!(email, display_name)
find_or_create_user(email, display_name, raise_on_failed_create: true)
end
def all_destinations
@all_destinations ||= [
@mail.destinations,
[@mail[:x_forwarded_to]].flatten.compact.map(&:decoded),
[@mail[:delivered_to]].flatten.compact.map(&:decoded),
].flatten.select(&:present?).uniq.lazy
end
def destinations
@destinations ||=
all_destinations.map { |d| Email::Receiver.check_address(d, is_bounce?) }.reject(&:blank?)
end
def sent_to_mailinglist_mirror?
@sent_to_mailinglist_mirror ||=
begin
destinations.each do |destination|
return true if destination.is_a?(Category) && destination.mailinglist_mirror?
end
false
end
end
def self.check_address(address, include_verp = false)
# only check for a group/category when 'email_in' is enabled
if SiteSetting.email_in
group = Group.find_by_email(address)
return group if group
category = Category.find_by_email(address)
return category if category
end
# reply
match = Email::Receiver.reply_by_email_address_regex(true, include_verp).match(address)
if match && match.captures
match.captures.each do |c|
next if c.blank?
post_reply_key = PostReplyKey.find_by(reply_key: c)
return post_reply_key if post_reply_key
end
end
nil
end
def process_destination(destination, user, body, elided)
if SiteSetting.forwarded_emails_behaviour != "hide" && has_been_forwarded? &&
process_forwarded_email(destination, user)
return
end
return if is_bounce? && !destination.is_a?(PostReplyKey)
if destination.is_a?(Group)
user ||= stage_from_user
create_group_post(destination, user, body, elided)
elsif destination.is_a?(Category)
if (user.nil? || user.staged?) && !destination.email_in_allow_strangers
raise StrangersNotAllowedError
end
user ||= stage_from_user
if !user.staged? && !user.in_any_groups?(SiteSetting.email_in_allowed_groups_map) &&
!sent_to_mailinglist_mirror?
raise InsufficientTrustLevelError
end
create_topic(
user: user,
raw: body,
elided: elided,
title: subject,
category: destination.id,
skip_validations: user.staged?,
)
elsif destination.is_a?(PostReplyKey)
# We don't stage new users for emails to reply addresses, exit if user is nil
raise BadDestinationAddress if user.blank?
post = Post.with_deleted.find(destination.post_id)
raise ReplyNotAllowedError if !Guardian.new(user).can_create_post?(post&.topic)
if destination.user_id != user.id && !forwarded_reply_key?(destination, user)
raise ReplyUserNotMatchingError,
"post_reply_key.user_id => #{destination.user_id.inspect}, user.id => #{user.id.inspect}"
end
create_reply(
user: user,
raw: body,
elided: elided,
post: post,
topic: post&.topic,
skip_validations: user.staged?,
bounce: is_bounce?,
)
end
end
def create_group_post(group, user, body, elided)
message_ids = Email::Receiver.extract_reply_message_ids(@mail, max_message_id_count: 5)
# incoming emails with matching message ids, and then cross references
# these with any email addresses for the user vs to/from/cc of the
# incoming emails. in effect, any incoming email record for these
# message ids where the user is involved in any way will be returned
incoming_emails = IncomingEmail.where(message_id: message_ids)
if !group.allow_unknown_sender_topic_replies
incoming_emails = incoming_emails.addressed_to_user(user)
end
post_ids = incoming_emails.pluck(:post_id) || []
# if the user is directly replying to an email send to them from discourse,
# there will be a corresponding EmailLog record, so we can use that as the
# reply post if it exists
if Email::MessageIdService.discourse_generated_message_id?(mail.in_reply_to)
post_id_from_email_log =
EmailLog
.where(message_id: mail.in_reply_to)
.addressed_to_user(user)
.order(created_at: :desc)
.limit(1)
.pluck(:post_id)
.last
post_ids << post_id_from_email_log if post_id_from_email_log
end
target_post = post_ids.any? && Post.where(id: post_ids).order(:created_at).last
too_old_for_group_smtp = (destination_too_old?(target_post) && group.smtp_enabled)
if target_post.blank? || too_old_for_group_smtp
create_topic(
user: user,
raw: new_group_topic_body(body, target_post, too_old_for_group_smtp),
elided: elided,
title: subject,
archetype: Archetype.private_message,
target_group_names: [group.name],
is_group_message: true,
skip_validations: true,
)
else
# This must be done for the unknown user (who is staged) to
# be allowed to post a reply in the topic.
if group.allow_unknown_sender_topic_replies
target_post.topic.topic_allowed_users.find_or_create_by!(user_id: user.id)
end
create_reply(
user: user,
raw: body,
elided: elided,
post: target_post,
topic: target_post.topic,
skip_validations: true,
)
end
end
def new_group_topic_body(body, target_post, too_old_for_group_smtp)
return body if !too_old_for_group_smtp
body + "\n\n----\n\n" +
I18n.t(
"emails.incoming.continuing_old_discussion",
url: target_post.topic.url,
title: target_post.topic.title,
count: SiteSetting.disallow_reply_by_email_after_days,
)
end
def forwarded_reply_key?(post_reply_key, user)
incoming_emails =
IncomingEmail
.joins(:post)
.where("posts.topic_id = ?", post_reply_key.post.topic_id)
.addressed_to(post_reply_key.reply_key)
.addressed_to_user(user)
.pluck(:to_addresses, :cc_addresses)
incoming_emails.each do |to_addresses, cc_addresses|
unless contains_email_address_of_user?(to_addresses, user) ||
contains_email_address_of_user?(cc_addresses, user)
next
end
if contains_reply_by_email_address(to_addresses, post_reply_key.reply_key) ||
contains_reply_by_email_address(cc_addresses, post_reply_key.reply_key)
return true
end
end
false
end
def contains_email_address_of_user?(addresses, user)
return false if addresses.blank?
addresses = addresses.split(";")
user.user_emails.any? { |user_email| addresses.include?(user_email.email) }
end
def contains_reply_by_email_address(addresses, reply_key)
return false if addresses.blank?
addresses
.split(";")
.each do |address|
match = Email::Receiver.reply_by_email_address_regex.match(address)
return true if match && match.captures&.include?(reply_key)
end
false
end
def has_been_forwarded?
subject[/\A[[:blank:]]*(fwd?|tr)[[:blank:]]?:/i] && embedded_email_raw.present?
end
def embedded_email_raw
return @embedded_email_raw if @embedded_email_raw
text = fix_charset(@mail.multipart? ? @mail.text_part : @mail)
@embedded_email_raw, @before_embedded = EmailReplyTrimmer.extract_embedded_email(text)
@embedded_email_raw
end
def embedded_email
@embedded_email ||=
if embedded_email_raw.present?
mail = Mail.new(embedded_email_raw)
Email::Validator.ensure_valid_address_lists!(mail)
mail
else
nil
end
end
def process_forwarded_email(destination, user)
user ||= stage_from_user
case SiteSetting.forwarded_emails_behaviour
when "create_replies"
forwarded_email_create_replies(destination, user)
when "quote"
forwarded_email_quote_forwarded(destination, user)
else
false
end
end
def forwarded_email_create_topic(
destination:,
user:,
raw:,
title:,
date: nil,
embedded_user: nil
)
if destination.is_a?(Group)
topic_user = embedded_user&.call || user
create_topic(
user: topic_user,
raw: raw,
title: title,
archetype: Archetype.private_message,
target_usernames: [user.username],
target_group_names: [destination.name],
is_group_message: true,
skip_validations: true,
created_at: date,
)
elsif destination.is_a?(Category)
return false if user.staged? && !destination.email_in_allow_strangers
if user.groups.any? && !user.in_any_groups?(SiteSetting.email_in_allowed_groups_map)
return false
end
topic_user = embedded_user&.call || user
create_topic(
user: topic_user,
raw: raw,
title: title,
category: destination.id,
skip_validations: topic_user.staged?,
created_at: date,
)
else
false
end
end
def forwarded_email_create_replies(destination, user)
forwarded_by_address, forwarded_by_name =
Email::Receiver.extract_email_address_and_name(@mail[:from])
if forwarded_by_address && forwarded_by_name
@forwarded_by_user = stage_sender_user(forwarded_by_address, forwarded_by_name)
end
email_address, display_name =
parse_from_field(embedded_email, process_forwarded_emails: false)
return false if email_address.blank? || !email_address.include?("@")
post =
forwarded_email_create_topic(
destination: destination,
user: user,
raw: try_to_encode(embedded_email.decoded, "UTF-8").presence || embedded_email.to_s,
title: embedded_email.subject.presence || subject,
date: embedded_email.date,
embedded_user: lambda { find_or_create_user(email_address, display_name) },
)
return false unless post
if post.topic
# mark post as seen for the forwarder
PostTiming.record_timing(
user_id: user.id,
topic_id: post.topic_id,
post_number: post.post_number,
msecs: 5000,
)
# create reply when available
if @before_embedded.present?
post_type = Post.types[:regular]
post_type = Post.types[:whisper] if post.topic.private_message? &&
destination.usernames[user.username]
create_reply(
user: user,
raw: @before_embedded,
post: post,
topic: post.topic,
post_type: post_type,
skip_validations: user.staged?,
)
else
if @forwarded_by_user
post.topic.topic_allowed_users.find_or_create_by!(user_id: @forwarded_by_user.id)
end
post.topic.add_small_action(@forwarded_by_user || user, "forwarded")
end
end
true
end
def forwarded_email_quote_forwarded(destination, user)
raw = <<~MD
#{@before_embedded}
[quote]
#{PlainTextToMarkdown.new(@embedded_email_raw).to_markdown}
[/quote]
MD
if forwarded_email_create_topic(
destination: destination,
user: user,
raw: raw,
title: subject,
)
true
end
end
def self.reply_by_email_address_regex(extract_reply_key = true, include_verp = false)
reply_addresses = [SiteSetting.reply_by_email_address]
reply_addresses << (SiteSetting.alternative_reply_by_email_addresses.presence || "").split(
"|",
)
if include_verp && SiteSetting.reply_by_email_address.present? &&
SiteSetting.reply_by_email_address["+"]
reply_addresses << SiteSetting.reply_by_email_address.sub(
"%{reply_key}",
"verp-%{reply_key}",
)
end
reply_addresses.flatten!
reply_addresses.select!(&:present?)
reply_addresses.map! { |a| Regexp.escape(a) }
reply_addresses.map! { |a| a.gsub("\+", "\+?") }
reply_addresses.map! { |a| a.gsub(Regexp.escape("%{reply_key}"), "(\\h{32})?") }
if reply_addresses.empty?
/$a/ # a regex that can never match
else
/#{reply_addresses.join("|")}/
end
end
def group_incoming_emails_regex
@group_incoming_emails_regex =
Regexp.union(DB.query_single(<<~SQL).map { |e| e.split("|") }.flatten.compact_blank.uniq)
SELECT CONCAT(incoming_email, '|', email_username)
FROM groups
WHERE incoming_email IS NOT NULL OR email_username IS NOT NULL
SQL
end
def category_email_in_regex
@category_email_in_regex ||=
Regexp.union Category
.pluck(:email_in)
.select(&:present?)
.map { |e| e.split("|") }
.flatten
.uniq
end
def find_related_post(force: false)
return if !force && SiteSetting.find_related_post_with_key && !sent_to_mailinglist_mirror?
message_ids = Email::Receiver.extract_reply_message_ids(@mail, max_message_id_count: 5)
return if message_ids.empty?
Email::MessageIdService.find_post_from_message_ids(message_ids)
end
def self.extract_reply_message_ids(mail, max_message_id_count:)
message_ids = [mail.in_reply_to, Email::Receiver.extract_references(mail.references)]
message_ids.flatten!
message_ids.select!(&:present?)
message_ids.uniq!
message_ids.first(max_message_id_count)
end
def self.extract_references(references)
if Array === references
references
elsif references.present?
references.split(/[\s,]/).map { |r| Email::MessageIdService.message_id_clean(r) }
end
end
def likes
@likes ||= Set.new ["+1", "<3", "", I18n.t("post_action_types.like.title").downcase]
end
def subscription_action_for(body, subject)
return unless SiteSetting.unsubscribe_via_email
return if sent_to_mailinglist_mirror?
if ([subject, body].compact.map(&:to_s).map(&:downcase) & ["unsubscribe"]).any?
:confirm_unsubscribe
end
end
def post_action_for(body)
PostActionType.types[:like] if likes.include?(body.strip.downcase)
end
def create_topic(options = {})
enable_email_pm_setting(options[:user]) if options[:archetype] == Archetype.private_message
create_post_with_attachments(options)
end
def notification_level_for(body)
# since we are stripping save all this work on long replies
return nil if body.length > 40
body = body.strip.downcase
case body
when "mute"
NotificationLevels.topic_levels[:muted]
when "track"
NotificationLevels.topic_levels[:tracking]
when "watch"
NotificationLevels.topic_levels[:watching]
else
nil
end
end
def create_reply(options = {})
raise TopicNotFoundError if options[:topic].nil? || options[:topic].trashed?
if options[:bounce] && options[:topic].archetype != Archetype.private_message
raise BouncedEmailError
end
options[:post] = nil if options[:post]&.trashed?
if options[:topic].archetype == Archetype.private_message
enable_email_pm_setting(options[:user])
end
if post_action_type = post_action_for(options[:raw])
create_post_action(options[:user], options[:post], post_action_type)
elsif notification_level = notification_level_for(options[:raw])
TopicUser.change(
options[:user].id,
options[:post].topic_id,
notification_level: notification_level,
)
else
raise TopicClosedError if options[:topic].closed?
options[:topic_id] = options[:topic].id
options[:reply_to_post_number] = options[:post]&.post_number
options[:is_group_message] = options[:topic].private_message? &&
options[:topic].allowed_groups.exists?
create_post_with_attachments(options)
end
end
def create_post_action(user, post, type)
result = PostActionCreator.new(user, post, type).perform
raise InvalidPostAction.new if result.failed? && result.forbidden
end
def is_allowed?(attachment)
attachment.content_type !~ SiteSetting.blocked_attachment_content_types_regex &&
attachment.filename !~ SiteSetting.blocked_attachment_filenames_regex
end
def attachments
@attachments ||=
begin
attachments = @mail.attachments.select { |attachment| is_allowed?(attachment) }
attachments << @mail if @mail.attachment? && is_allowed?(@mail)
@mail.parts.each { |part| attachments << part if part.attachment? && is_allowed?(part) }
attachments.uniq!
attachments
end
end
def create_post_with_attachments(options = {})
add_elided_to_raw!(options)
options[:raw] = add_attachments(options[:raw], options[:user], options)
create_post(options)
end
def add_attachments(raw, user, options = {})
raw = raw.dup
upload_ids =
UploadReference.where(
target_id: Post.where(topic_id: options[:topic_id]).select(:id),
).pluck("DISTINCT upload_id")
upload_shas = Upload.where(id: upload_ids).pluck("DISTINCT COALESCE(original_sha1, sha1)")
is_duplicate = ->(upload_id, upload_sha, attachment) do
return true if upload_id && upload_ids.include?(upload_id)
return true if upload_sha && upload_shas.include?(upload_sha)
if attachment.respond_to?(:url) && attachment.url&.start_with?("cid:") &&
attachment.content_type&.start_with?("image/")
return true if @cids&.include?(attachment.url)
end
false
end
added_attachments = []
rejected_attachments = []
attachments.each do |attachment|
tmp = Tempfile.new(["discourse-email-attachment", File.extname(attachment.filename)])
begin
# read attachment
File.open(tmp.path, "w+b") { |f| f.write attachment.body.decoded }
# create the upload for the user
opts = { for_group_message: options[:is_group_message] }
upload = UploadCreator.new(tmp, attachment.filename, opts).create_for(user.id)
upload_sha = upload.original_sha1.presence || upload.sha1
if upload.errors.empty?
# try to inline images
if attachment.content_type&.start_with?("image/")
if raw[attachment.url]
raw.sub!(attachment.url, upload.url)
InlineUploads.match_img(
raw,
uploads: {
upload.url => upload,
},
) do |match, src, replacement, _|
raw = raw.sub(match, replacement) if src == upload.url
end
elsif raw[/\[image:[^\]]*\]/i]
raw.sub!(/\[image:[^\]]*\]/i, UploadMarkdown.new(upload).to_markdown)
elsif !is_duplicate[upload.id, upload_sha, attachment]
added_attachments << upload
end
elsif !is_duplicate[upload.id, upload_sha, attachment]
added_attachments << upload
end
else
rejected_attachments << upload
raw << "\n\n#{I18n.t("emails.incoming.missing_attachment", filename: upload.original_filename)}\n\n"
end
ensure
tmp&.close!
end
end
if rejected_attachments.present? && !user.staged?
notify_about_rejected_attachment(rejected_attachments)
end
if added_attachments.present?
markdown =
added_attachments.map { |upload| UploadMarkdown.new(upload).to_markdown }.join("\n")
if markdown.present?
raw << "\n\n"
raw << "[details=\"#{I18n.t("emails.incoming.attachments")}\"]"
raw << "\n\n"
raw << markdown
raw << "\n\n"
raw << "[/details]"
end
end
raw
end
def notify_about_rejected_attachment(attachments)
errors = []
attachments.each do |a|
error = a.errors.messages.values[0][0]
errors << "#{a.original_filename}: #{error}"
end
message = Mail::Message.new(@mail)
template_args = {
former_title: message.subject,
destination: message.to,
site_name: SiteSetting.title,
rejected_errors: errors.join("\n"),
}
client_message =
RejectionMailer.send_rejection(:email_reject_attachment, message.from, template_args)
Email::Sender.new(client_message, :email_reject_attachment).send
end
def add_elided_to_raw!(options)
is_private_message =
options[:archetype] == Archetype.private_message || options[:topic].try(:private_message?)
# only add elided part in messages
if options[:elided].present? &&
(SiteSetting.always_show_trimmed_content || is_private_message)
options[:raw] << Email::Receiver.elided_html(options[:elided])
options[:elided] = ""
end
end
def create_post(options = {})
options[:import_mode] = @opts[:import_mode]
options[:via_email] = true
options[:raw_email] = @raw_email
options[:created_at] ||= @mail.date
options[:created_at] = DateTime.now if options[:created_at] > DateTime.now
add_elided_to_raw!(options)
if sent_to_mailinglist_mirror?
options[:skip_validations] = true
options[:skip_guardian] = true
else
options[:email_spam] = is_spam?
options[:first_post_checks] = true if is_spam?
options[:email_auth_res_action] = auth_res_action
end
user = options.delete(:user)
if options[:bounce]
options[:raw] = I18n.t(
"system_messages.email_bounced",
email: user.email,
raw: options[:raw],
)
user = Discourse.system_user
options[:post_type] = Post.types[:whisper]
end
# To avoid race conditions with the post alerter and Group SMTP
# emails, we skip the jobs here and enqueue them only _after_
# the incoming email has been updated with the post and topic.
options[:skip_jobs] = true
options[:skip_events] = true
result = NewPostManager.new(user, options).perform
errors = result.errors.full_messages
if errors.any? { |message|
message.include?(I18n.t("activerecord.attributes.post.raw").strip) &&
message.include?(
I18n.t("errors.messages.too_short", count: SiteSetting.min_post_length).strip,
)
}
raise TooShortPost
end
raise InvalidPost, errors.join("\n") if result.errors.present?
if result.post
IncomingEmail.transaction do
@incoming_email.update_columns(topic_id: result.post.topic_id, post_id: result.post.id)
result.post.update(outbound_message_id: @incoming_email.message_id)
end
if result.post.topic&.private_message? && !is_bounce?
add_other_addresses(result.post, user, @mail)
if has_been_forwarded?
add_other_addresses(result.post, @forwarded_by_user || user, embedded_email)
end
end
# Alert the people involved in the topic now that the incoming email
# has been linked to the post.
PostJobsEnqueuer.new(
result.post,
result.post.topic,
options[:topic_id].blank?,
import_mode: options[:import_mode],
post_alert_options: options[:post_alert_options],
).enqueue_jobs
if result.post.is_first_post?
DiscourseEvent.trigger(:topic_created, result.post.topic, options, user)
end
DiscourseEvent.trigger(:post_created, result.post, options, user)
end
result.post
end
def self.elided_html(elided)
html = +"\n\n" << "<details class='elided'>" << "\n"
html << "<summary title='#{I18n.t("emails.incoming.show_trimmed_content")}'>&#183;&#183;&#183;</summary>" <<
"\n\n"
html << elided << "\n\n"
html << "</details>" << "\n"
html
end
def add_other_addresses(post, sender, mail_object)
max_staged_users_post = nil
%i[to cc bcc].each do |d|
next if mail_object[d].blank?
mail_object[d].each do |address_field|
begin
address_field.decoded
email = address_field.address.downcase
display_name = address_field.display_name.try(:to_s)
next if !email.include?("@")
if should_invite?(email)
user = User.find_by_email(email)
# cap number of staged users created per email
if (!user || user.staged) &&
@staged_users.count >= SiteSetting.maximum_staged_users_per_email
max_staged_users_post ||=
post.topic.add_moderator_post(
sender,
I18n.t("emails.incoming.maximum_staged_user_per_email_reached"),
import_mode: @opts[:import_mode],
)
next
end
user = find_or_create_user(email, display_name, user: user)
if user && can_invite?(post.topic, user)
post.topic.topic_allowed_users.create!(user_id: user.id)
TopicUser.auto_notification_for_staging(
user.id,
post.topic_id,
TopicUser.notification_reasons[:auto_watch],
)
post.topic.add_small_action(
sender,
"invited_user",
user.username,
import_mode: @opts[:import_mode],
)
end
end
rescue ActiveRecord::RecordInvalid, EmailNotAllowed
# don't care if user already allowed or the user's email address is not allowed
end
end
end
end
def should_invite?(email)
email !~ Email::Receiver.reply_by_email_address_regex &&
email !~ group_incoming_emails_regex && email !~ category_email_in_regex
end
def can_invite?(topic, user)
!topic.topic_allowed_users.where(user_id: user.id).exists? &&
!topic
.topic_allowed_groups
.where("group_id IN (SELECT group_id FROM group_users WHERE user_id = ?)", user.id)
.exists?
end
def send_subscription_mail(action, user)
message = SubscriptionMailer.public_send(action, user)
Email::Sender.new(message, :subscription).send
end
def stage_from_user
@from_user ||= stage_sender_user(@from_email, @from_display_name)
end
def stage_sender_user(email, display_name)
find_or_create_user!(email, display_name).tap { |u| log_and_validate_user(u) }
end
def delete_created_staged_users
@created_staged_users.each do |user|
@incoming_email.update_columns(user_id: nil) if @incoming_email.user&.id == user.id
UserDestroyer.new(Discourse.system_user).destroy(user, quiet: true) if user.posts.count == 0
end
end
def enable_email_pm_setting(user)
# ensure user PM emails are enabled (since user is posting via email)
if !user.staged &&
user.user_option.email_messages_level == UserOption.email_level_types[:never]
user.user_option.update!(email_messages_level: UserOption.email_level_types[:always])
end
end
def destination_too_old?(post)
return false if post.blank?
num_of_days = SiteSetting.disallow_reply_by_email_after_days
num_of_days > 0 && post.created_at < num_of_days.days.ago
end
end
end