2019-05-02 17:17:27 -05:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2013-06-10 14:33:37 -05:00
|
|
|
#
|
|
|
|
# A helper class to send an email. It will also handle a nil message, which it considers
|
|
|
|
# to be "do nothing". This is because some Mailers will decide not to do work for some
|
|
|
|
# reason. For example, emailing a user too frequently. A nil to address is also considered
|
|
|
|
# "do nothing"
|
|
|
|
#
|
|
|
|
# It also adds an HTML part for the plain text body
|
|
|
|
#
|
|
|
|
require_dependency 'email/renderer'
|
2013-07-02 13:13:46 -05:00
|
|
|
require 'uri'
|
2014-03-12 05:55:08 -05:00
|
|
|
require 'net/smtp'
|
2013-06-10 14:33:37 -05:00
|
|
|
|
2014-03-07 09:33:15 -06:00
|
|
|
SMTP_CLIENT_ERRORS = [Net::SMTPFatalError, Net::SMTPSyntaxError]
|
2019-03-21 16:57:09 -05:00
|
|
|
BYPASS_DISABLE_TYPES = ["admin_login", "test_message"]
|
2014-03-07 09:33:15 -06:00
|
|
|
|
2013-06-10 14:33:37 -05:00
|
|
|
module Email
|
|
|
|
class Sender
|
|
|
|
|
2017-07-27 20:20:09 -05:00
|
|
|
def initialize(message, email_type, user = nil)
|
2013-06-10 14:33:37 -05:00
|
|
|
@message = message
|
|
|
|
@email_type = email_type
|
|
|
|
@user = user
|
|
|
|
end
|
|
|
|
|
2019-03-21 16:57:09 -05:00
|
|
|
def send
|
|
|
|
bypass_disable = BYPASS_DISABLE_TYPES.include?(@email_type.to_s)
|
2019-03-11 20:39:16 -05:00
|
|
|
|
2019-03-21 16:57:09 -05:00
|
|
|
if SiteSetting.disable_emails == "yes" && !bypass_disable
|
2019-03-11 20:39:16 -05:00
|
|
|
return
|
|
|
|
end
|
2016-02-17 10:31:46 -06:00
|
|
|
|
|
|
|
return if ActionMailer::Base::NullMail === @message
|
|
|
|
return if ActionMailer::Base::NullMail === (@message.message rescue nil)
|
|
|
|
|
2018-07-23 23:55:43 -05:00
|
|
|
return skip(SkippedEmailLog.reason_types[:sender_message_blank]) if @message.blank?
|
|
|
|
return skip(SkippedEmailLog.reason_types[:sender_message_to_blank]) if @message.to.blank?
|
2013-11-28 16:20:56 -06:00
|
|
|
|
2019-03-21 16:57:09 -05:00
|
|
|
if SiteSetting.disable_emails == "non-staff" && !bypass_disable
|
2019-03-21 15:46:14 -05:00
|
|
|
return unless User.find_by_email(to_address)&.staff?
|
|
|
|
end
|
|
|
|
|
2019-03-13 11:17:59 -05:00
|
|
|
return skip(SkippedEmailLog.reason_types[:sender_message_to_invalid]) if to_address.end_with?(".invalid")
|
|
|
|
|
2013-11-28 16:20:56 -06:00
|
|
|
if @message.text_part
|
2018-07-23 23:55:43 -05:00
|
|
|
if @message.text_part.body.to_s.blank?
|
|
|
|
return skip(SkippedEmailLog.reason_types[:sender_text_part_body_blank])
|
|
|
|
end
|
2013-11-28 16:20:56 -06:00
|
|
|
else
|
2018-07-23 23:55:43 -05:00
|
|
|
if @message.body.to_s.blank?
|
|
|
|
return skip(SkippedEmailLog.reason_types[:sender_body_blank])
|
|
|
|
end
|
2013-11-28 16:20:56 -06:00
|
|
|
end
|
2013-06-10 14:33:37 -05:00
|
|
|
|
|
|
|
@message.charset = 'UTF-8'
|
|
|
|
|
|
|
|
opts = {}
|
|
|
|
|
|
|
|
renderer = Email::Renderer.new(@message, opts)
|
|
|
|
|
2013-11-29 11:21:21 -06:00
|
|
|
if @message.html_part
|
|
|
|
@message.html_part.body = renderer.html
|
|
|
|
else
|
2013-07-24 02:13:15 -05:00
|
|
|
@message.html_part = Mail::Part.new do
|
|
|
|
content_type 'text/html; charset=UTF-8'
|
|
|
|
body renderer.html
|
|
|
|
end
|
2013-06-10 14:33:37 -05:00
|
|
|
end
|
|
|
|
|
2015-01-17 04:07:58 -06:00
|
|
|
# Fix relative (ie upload) HTML links in markdown which do not work well in plain text emails.
|
|
|
|
# These are the links we add when a user uploads a file or image.
|
|
|
|
# Ideally we would parse general markdown into plain text, but that is almost an intractable problem.
|
|
|
|
url_prefix = Discourse.base_url
|
2019-06-11 03:00:59 -05:00
|
|
|
@message.parts[0].body = @message.parts[0].body.to_s.gsub(/<a class="attachment" href="(\/uploads\/default\/[^"]+)">([^<]*)<\/a>/, '[\2|attachment](' + url_prefix + '\1)')
|
2017-07-27 20:20:09 -05:00
|
|
|
@message.parts[0].body = @message.parts[0].body.to_s.gsub(/<img src="(\/uploads\/default\/[^"]+)"([^>]*)>/, '![](' + url_prefix + '\1)')
|
2015-01-17 04:07:58 -06:00
|
|
|
|
2013-06-10 14:33:37 -05:00
|
|
|
@message.text_part.content_type = 'text/plain; charset=UTF-8'
|
2018-07-18 03:28:44 -05:00
|
|
|
user_id = @user&.id
|
2013-06-10 14:33:37 -05:00
|
|
|
|
2013-06-25 10:35:26 -05:00
|
|
|
# Set up the email log
|
2018-07-18 03:28:44 -05:00
|
|
|
email_log = EmailLog.new(
|
|
|
|
email_type: @email_type,
|
|
|
|
to_address: to_address,
|
|
|
|
user_id: user_id
|
|
|
|
)
|
2013-07-02 13:13:46 -05:00
|
|
|
|
2013-07-08 10:48:40 -05:00
|
|
|
host = Email::Sender.host_for(Discourse.base_url)
|
2013-07-02 13:13:46 -05:00
|
|
|
|
2017-02-01 16:02:41 -06:00
|
|
|
post_id = header_value('X-Discourse-Post-Id')
|
|
|
|
topic_id = header_value('X-Discourse-Topic-Id')
|
2018-07-18 03:28:44 -05:00
|
|
|
reply_key = set_reply_key(post_id, user_id)
|
2013-07-08 10:48:40 -05:00
|
|
|
|
2015-01-28 03:12:49 -06:00
|
|
|
# always set a default Message ID from the host
|
2016-11-25 16:25:39 -06:00
|
|
|
@message.header['Message-ID'] = "<#{SecureRandom.uuid}@#{host}>"
|
2015-01-28 03:12:49 -06:00
|
|
|
|
2018-07-17 21:21:54 -05:00
|
|
|
if topic_id.present? && post_id.present?
|
|
|
|
post = Post.find_by(id: post_id, topic_id: topic_id)
|
2018-08-22 06:13:58 -05:00
|
|
|
|
|
|
|
# guards against deleted posts
|
|
|
|
return skip(SkippedEmailLog.reason_types[:sender_post_deleted]) unless post
|
|
|
|
|
2019-07-25 07:04:00 -05:00
|
|
|
add_attachments(post)
|
|
|
|
|
2018-07-17 21:21:54 -05:00
|
|
|
topic = post.topic
|
2017-02-01 16:02:41 -06:00
|
|
|
first_post = topic.ordered_posts.first
|
2016-04-06 14:04:30 -05:00
|
|
|
|
2017-03-16 14:40:14 -05:00
|
|
|
topic_message_id = first_post.incoming_email&.message_id.present? ?
|
2017-02-01 16:02:41 -06:00
|
|
|
"<#{first_post.incoming_email.message_id}>" :
|
|
|
|
"<topic/#{topic_id}@#{host}>"
|
2016-04-06 14:04:30 -05:00
|
|
|
|
2017-02-01 16:02:41 -06:00
|
|
|
post_message_id = post.incoming_email&.message_id.present? ?
|
|
|
|
"<#{post.incoming_email.message_id}>" :
|
|
|
|
"<topic/#{topic_id}/#{post_id}@#{host}>"
|
2016-11-25 16:25:39 -06:00
|
|
|
|
|
|
|
referenced_posts = Post.includes(:incoming_email)
|
2019-04-10 00:56:44 -05:00
|
|
|
.joins("INNER JOIN post_replies ON post_replies.post_id = posts.id ")
|
|
|
|
.where("post_replies.reply_id = ?", post_id)
|
2017-07-27 20:20:09 -05:00
|
|
|
.order(id: :desc)
|
2016-11-25 16:25:39 -06:00
|
|
|
|
2018-09-03 21:16:21 -05:00
|
|
|
referenced_post_message_ids = referenced_posts.map do |referenced_post|
|
|
|
|
if referenced_post.incoming_email&.message_id.present?
|
|
|
|
"<#{referenced_post.incoming_email.message_id}>"
|
2016-11-25 16:25:39 -06:00
|
|
|
else
|
2018-09-03 21:16:21 -05:00
|
|
|
if referenced_post.post_number == 1
|
2017-02-01 16:02:41 -06:00
|
|
|
"<topic/#{topic_id}@#{host}>"
|
|
|
|
else
|
2018-09-03 21:16:21 -05:00
|
|
|
"<topic/#{topic_id}/#{referenced_post.id}@#{host}>"
|
2017-02-01 16:02:41 -06:00
|
|
|
end
|
2016-11-25 16:25:39 -06:00
|
|
|
end
|
|
|
|
end
|
2014-06-13 17:42:14 -05:00
|
|
|
|
2017-02-01 16:02:41 -06:00
|
|
|
# https://www.ietf.org/rfc/rfc2822.txt
|
|
|
|
if post.post_number == 1
|
|
|
|
@message.header['Message-ID'] = topic_message_id
|
|
|
|
else
|
|
|
|
@message.header['Message-ID'] = post_message_id
|
|
|
|
@message.header['In-Reply-To'] = referenced_post_message_ids[0] || topic_message_id
|
2018-02-22 03:48:23 -06:00
|
|
|
@message.header['References'] = [topic_message_id, referenced_post_message_ids].flatten.compact.uniq
|
2016-11-25 16:25:39 -06:00
|
|
|
end
|
2014-10-08 13:09:21 -05:00
|
|
|
|
2017-02-01 16:02:41 -06:00
|
|
|
# https://www.ietf.org/rfc/rfc2919.txt
|
2018-08-22 06:13:58 -05:00
|
|
|
if topic&.category && !topic.category.uncategorized?
|
2018-05-03 09:39:25 -05:00
|
|
|
list_id = "#{SiteSetting.title} | #{topic.category.name} <#{topic.category.name.downcase.tr(' ', '-')}.#{host}>"
|
2014-10-08 13:09:21 -05:00
|
|
|
|
|
|
|
# subcategory case
|
|
|
|
if !topic.category.parent_category_id.nil?
|
|
|
|
parent_category_name = Category.find_by(id: topic.category.parent_category_id).name
|
2018-05-04 18:51:53 -05:00
|
|
|
list_id = "#{SiteSetting.title} | #{parent_category_name} #{topic.category.name} <#{topic.category.name.downcase.tr(' ', '-')}.#{parent_category_name.downcase.tr(' ', '-')}.#{host}>"
|
2014-10-08 13:09:21 -05:00
|
|
|
end
|
|
|
|
else
|
2018-05-03 09:39:25 -05:00
|
|
|
list_id = "#{SiteSetting.title} <#{host}>"
|
2014-10-08 13:09:21 -05:00
|
|
|
end
|
2014-10-08 14:57:30 -05:00
|
|
|
|
2017-02-01 16:02:41 -06:00
|
|
|
# https://www.ietf.org/rfc/rfc3834.txt
|
2018-05-03 09:39:25 -05:00
|
|
|
@message.header['Precedence'] = 'list'
|
|
|
|
@message.header['List-ID'] = list_id
|
2017-04-24 14:26:06 -05:00
|
|
|
|
|
|
|
if topic
|
|
|
|
if SiteSetting.private_email?
|
|
|
|
@message.header['List-Archive'] = "#{Discourse.base_url}#{topic.slugless_url}"
|
|
|
|
else
|
|
|
|
@message.header['List-Archive'] = topic.url
|
|
|
|
end
|
|
|
|
end
|
2014-06-13 17:49:11 -05:00
|
|
|
end
|
|
|
|
|
2016-01-29 09:49:49 -06:00
|
|
|
if reply_key.present? && @message.header['Reply-To'] =~ /\<([^\>]+)\>/
|
|
|
|
email = Regexp.last_match[1]
|
|
|
|
@message.header['List-Post'] = "<mailto:#{email}>"
|
2013-07-08 10:48:40 -05:00
|
|
|
end
|
|
|
|
|
2019-03-26 11:59:56 -05:00
|
|
|
if Email::Sender.bounceable_reply_address?
|
2016-04-18 02:13:41 -05:00
|
|
|
email_log.bounce_key = SecureRandom.hex
|
|
|
|
|
|
|
|
# WARNING: RFC claims you can not set the Return Path header, this is 100% correct
|
|
|
|
# however Rails has special handling for this header and ends up using this value
|
|
|
|
# as the Envelope From address so stuff works as expected
|
2019-03-26 11:59:56 -05:00
|
|
|
@message.header[:return_path] = Email::Sender.bounce_address(email_log.bounce_key)
|
2016-04-18 02:13:41 -05:00
|
|
|
end
|
|
|
|
|
2013-07-08 10:48:40 -05:00
|
|
|
email_log.post_id = post_id if post_id.present?
|
2013-06-13 09:56:16 -05:00
|
|
|
|
2013-06-25 10:35:26 -05:00
|
|
|
# Remove headers we don't need anymore
|
2018-08-22 06:13:58 -05:00
|
|
|
@message.header['X-Discourse-Topic-Id'] = nil if topic_id.present?
|
|
|
|
@message.header['X-Discourse-Post-Id'] = nil if post_id.present?
|
2018-07-18 03:28:44 -05:00
|
|
|
|
|
|
|
if reply_key.present?
|
|
|
|
@message.header[Email::MessageBuilder::ALLOW_REPLY_BY_EMAIL_HEADER] = nil
|
|
|
|
end
|
2013-06-25 10:35:26 -05:00
|
|
|
|
2016-10-27 12:35:50 -05:00
|
|
|
# pass the original message_id when using mailjet/mandrill/sparkpost
|
2016-06-13 05:31:01 -05:00
|
|
|
case ActionMailer::Base.smtp_settings[:address]
|
|
|
|
when /\.mailjet\.com/
|
2016-06-06 12:47:45 -05:00
|
|
|
@message.header['X-MJ-CustomID'] = @message.message_id
|
2016-06-13 05:31:01 -05:00
|
|
|
when "smtp.mandrillapp.com"
|
2017-07-27 20:20:09 -05:00
|
|
|
merge_json_x_header('X-MC-Metadata', message_id: @message.message_id)
|
2016-10-27 12:35:50 -05:00
|
|
|
when "smtp.sparkpostmail.com"
|
2017-07-27 20:20:09 -05:00
|
|
|
merge_json_x_header('X-MSYS-API', metadata: { message_id: @message.message_id })
|
2016-06-06 12:47:45 -05:00
|
|
|
end
|
|
|
|
|
2014-09-13 00:26:31 -05:00
|
|
|
# Suppress images from short emails
|
2016-01-29 09:49:49 -06:00
|
|
|
if SiteSetting.strip_images_from_short_emails &&
|
2016-05-09 13:37:33 -05:00
|
|
|
@message.html_part.body.to_s.bytesize <= SiteSetting.short_email_length &&
|
|
|
|
@message.html_part.body =~ /<img[^>]+>/
|
2014-09-13 00:26:31 -05:00
|
|
|
style = Email::Styles.new(@message.html_part.body.to_s)
|
|
|
|
@message.html_part.body = style.strip_avatars_and_emojis
|
|
|
|
end
|
|
|
|
|
2016-06-01 14:48:06 -05:00
|
|
|
email_log.message_id = @message.message_id
|
|
|
|
|
2014-03-07 09:33:15 -06:00
|
|
|
begin
|
2014-10-15 02:04:47 -05:00
|
|
|
@message.deliver_now
|
2014-03-09 07:06:54 -05:00
|
|
|
rescue *SMTP_CLIENT_ERRORS => e
|
2018-07-23 23:55:43 -05:00
|
|
|
return skip(SkippedEmailLog.reason_types[:custom], custom_reason: e.message)
|
2014-03-07 09:33:15 -06:00
|
|
|
end
|
2013-06-25 10:35:26 -05:00
|
|
|
|
2013-06-13 09:56:16 -05:00
|
|
|
email_log.save!
|
|
|
|
email_log
|
2013-06-10 14:33:37 -05:00
|
|
|
end
|
|
|
|
|
2014-02-14 12:06:21 -06:00
|
|
|
def to_address
|
|
|
|
@to_address ||= begin
|
2016-02-15 10:53:07 -06:00
|
|
|
to = @message.try(:to)
|
2016-01-29 09:49:49 -06:00
|
|
|
to = to.first if Array === to
|
|
|
|
to.presence || "no_email_found"
|
2014-02-14 12:06:21 -06:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2013-07-08 10:48:40 -05:00
|
|
|
def self.host_for(base_url)
|
2013-07-02 13:13:46 -05:00
|
|
|
host = "localhost"
|
|
|
|
if base_url.present?
|
|
|
|
begin
|
|
|
|
uri = URI.parse(base_url)
|
|
|
|
host = uri.host.downcase if uri.host.present?
|
2018-08-14 05:23:32 -05:00
|
|
|
rescue URI::Error
|
2013-07-02 13:13:46 -05:00
|
|
|
end
|
|
|
|
end
|
2013-07-08 10:48:40 -05:00
|
|
|
host
|
|
|
|
end
|
2013-07-02 13:13:46 -05:00
|
|
|
|
2013-06-13 17:11:10 -05:00
|
|
|
private
|
|
|
|
|
2019-07-25 07:04:00 -05:00
|
|
|
def add_attachments(post)
|
|
|
|
max_email_size = SiteSetting.email_total_attachment_size_limit_kb.kilobytes
|
|
|
|
return if max_email_size == 0
|
|
|
|
|
|
|
|
email_size = 0
|
|
|
|
post.uploads.each do |upload|
|
|
|
|
next if FileHelper.is_supported_image?(upload.original_filename)
|
|
|
|
next if email_size + upload.filesize > max_email_size
|
|
|
|
|
|
|
|
begin
|
|
|
|
path = if upload.local?
|
|
|
|
Discourse.store.path_for(upload)
|
|
|
|
else
|
|
|
|
Discourse.store.download(upload).path
|
|
|
|
end
|
|
|
|
|
|
|
|
@message.attachments[upload.original_filename] = File.read(path)
|
|
|
|
email_size += File.size(path)
|
|
|
|
rescue => e
|
|
|
|
Discourse.warn_exception(
|
|
|
|
e,
|
|
|
|
message: "Failed to attach file to email",
|
|
|
|
env: {
|
|
|
|
post_id: post.id,
|
|
|
|
upload_id: upload.id,
|
|
|
|
filename: upload.original_filename
|
|
|
|
}
|
|
|
|
)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2013-07-08 10:48:40 -05:00
|
|
|
def header_value(name)
|
2013-06-13 17:11:10 -05:00
|
|
|
header = @message.header[name]
|
2013-07-08 10:48:40 -05:00
|
|
|
return nil unless header
|
|
|
|
header.value
|
2013-06-13 17:11:10 -05:00
|
|
|
end
|
|
|
|
|
2018-07-23 23:55:43 -05:00
|
|
|
def skip(reason_type, custom_reason: nil)
|
|
|
|
attributes = {
|
2016-01-29 09:49:49 -06:00
|
|
|
email_type: @email_type,
|
|
|
|
to_address: to_address,
|
2018-07-23 23:55:43 -05:00
|
|
|
user_id: @user&.id,
|
|
|
|
reason_type: reason_type
|
|
|
|
}
|
|
|
|
|
|
|
|
attributes[:custom_reason] = custom_reason if custom_reason
|
|
|
|
SkippedEmailLog.create!(attributes)
|
2014-02-14 12:06:21 -06:00
|
|
|
end
|
|
|
|
|
2016-10-30 05:38:55 -05:00
|
|
|
def merge_json_x_header(name, value)
|
2016-11-02 20:26:12 -05:00
|
|
|
data = JSON.parse(@message.header[name].to_s) rescue nil
|
|
|
|
data ||= {}
|
|
|
|
data.merge!(value)
|
|
|
|
# /!\ @message.header is not a standard ruby hash.
|
|
|
|
# It can have multiple values attached to the same key...
|
|
|
|
# In order to remove all the previous keys, we have to "nil" it.
|
|
|
|
# But for "nil" to work, there must already be a key...
|
|
|
|
@message.header[name] = ""
|
2016-10-30 05:38:55 -05:00
|
|
|
@message.header[name] = nil
|
2016-11-02 20:26:12 -05:00
|
|
|
@message.header[name] = data.to_json
|
2016-10-30 05:38:55 -05:00
|
|
|
end
|
|
|
|
|
2018-07-18 03:28:44 -05:00
|
|
|
def set_reply_key(post_id, user_id)
|
|
|
|
return unless user_id &&
|
|
|
|
post_id &&
|
|
|
|
header_value(Email::MessageBuilder::ALLOW_REPLY_BY_EMAIL_HEADER).present?
|
|
|
|
|
2018-08-20 19:59:18 -05:00
|
|
|
# use safe variant here cause we tend to see concurrency issue
|
|
|
|
reply_key = PostReplyKey.find_or_create_by_safe!(
|
2018-07-18 03:28:44 -05:00
|
|
|
post_id: post_id,
|
|
|
|
user_id: user_id
|
|
|
|
).reply_key
|
|
|
|
|
|
|
|
@message.header['Reply-To'] =
|
|
|
|
header_value('Reply-To').gsub!("%{reply_key}", reply_key)
|
|
|
|
end
|
|
|
|
|
2019-03-26 11:59:56 -05:00
|
|
|
def self.bounceable_reply_address?
|
|
|
|
SiteSetting.reply_by_email_address.present? && SiteSetting.reply_by_email_address["+"]
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.bounce_address(bounce_key)
|
|
|
|
SiteSetting.reply_by_email_address.sub("%{reply_key}", "verp-#{bounce_key}")
|
|
|
|
end
|
2013-06-10 14:33:37 -05:00
|
|
|
end
|
2013-07-24 02:13:15 -05:00
|
|
|
end
|