mirror of
https://github.com/discourse/discourse.git
synced 2024-11-26 06:43:44 +08:00
758b9dd0ba
This change how we present attachments from incoming emails to now be "hidden" in a "[details]" so they don't "hang" at the end of the post. This is especially useful when using Discourse as a support tool where email is the main communication channel. For various reasons, images are often duplicated by email user agents, and hiding them behind the details block help keep the conversation focused on the isssue at hand. Internal ref t/122333
1673 lines
55 KiB
Ruby
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")}'>···</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
|