626 lines
23 KiB
Ruby
626 lines
23 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# 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 "uri"
|
|
require "net/smtp"
|
|
|
|
SMTP_CLIENT_ERRORS = [Net::SMTPFatalError, Net::SMTPSyntaxError]
|
|
BYPASS_DISABLE_TYPES = %w[
|
|
admin_login
|
|
test_message
|
|
new_version
|
|
group_smtp
|
|
invite_password_instructions
|
|
download_backup_message
|
|
admin_confirmation_message
|
|
]
|
|
|
|
module Email
|
|
class Sender
|
|
def initialize(message, email_type, user = nil)
|
|
@message = message
|
|
@message_attachments_index = {}
|
|
@email_type = email_type
|
|
@user = user
|
|
end
|
|
|
|
def send
|
|
bypass_disable = BYPASS_DISABLE_TYPES.include?(@email_type.to_s)
|
|
|
|
return if SiteSetting.disable_emails == "yes" && !bypass_disable
|
|
|
|
return if ActionMailer::Base::NullMail === @message
|
|
if ActionMailer::Base::NullMail ===
|
|
(
|
|
begin
|
|
@message.message
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
)
|
|
return
|
|
end
|
|
|
|
return skip(SkippedEmailLog.reason_types[:sender_message_blank]) if @message.blank?
|
|
return skip(SkippedEmailLog.reason_types[:sender_message_to_blank]) if @message.to.blank?
|
|
|
|
if SiteSetting.disable_emails == "non-staff" && !bypass_disable
|
|
return unless find_user&.staff?
|
|
end
|
|
|
|
if to_address.end_with?(".invalid")
|
|
return skip(SkippedEmailLog.reason_types[:sender_message_to_invalid])
|
|
end
|
|
|
|
if @message.text_part
|
|
if @message.text_part.body.to_s.blank?
|
|
return skip(SkippedEmailLog.reason_types[:sender_text_part_body_blank])
|
|
end
|
|
else
|
|
return skip(SkippedEmailLog.reason_types[:sender_body_blank]) if @message.body.to_s.blank?
|
|
end
|
|
|
|
@message.charset = "UTF-8"
|
|
|
|
opts = {}
|
|
|
|
renderer = Email::Renderer.new(@message, opts)
|
|
|
|
if @message.html_part
|
|
@message.html_part.body = renderer.html
|
|
else
|
|
@message.html_part =
|
|
Mail::Part.new do
|
|
content_type "text/html; charset=UTF-8"
|
|
body renderer.html
|
|
end
|
|
end
|
|
|
|
# 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
|
|
@message.parts[0].body =
|
|
@message.parts[0].body.to_s.gsub(
|
|
%r{<a class="attachment" href="(/uploads/default/[^"]+)">([^<]*)</a>},
|
|
'[\2|attachment](' + url_prefix + '\1)',
|
|
)
|
|
@message.parts[0].body =
|
|
@message.parts[0].body.to_s.gsub(
|
|
%r{<img src="(/uploads/default/[^"]+)"([^>]*)>},
|
|
"![](" + url_prefix + '\1)',
|
|
)
|
|
|
|
@message.text_part.content_type = "text/plain; charset=UTF-8"
|
|
user_id = @user&.id
|
|
|
|
# Set up the email log
|
|
email_log = EmailLog.new(email_type: @email_type, to_address: to_address, user_id: user_id)
|
|
|
|
if cc_addresses.any?
|
|
email_log.cc_addresses = cc_addresses.join(";")
|
|
email_log.cc_user_ids = User.with_email(cc_addresses).pluck(:id)
|
|
end
|
|
|
|
email_log.bcc_addresses = bcc_addresses.join(";") if bcc_addresses.any?
|
|
|
|
host = Email::Sender.host_for(Discourse.base_url)
|
|
|
|
post_id = header_value("X-Discourse-Post-Id")
|
|
topic_id = header_value("X-Discourse-Topic-Id")
|
|
reply_key = get_reply_key(post_id, user_id)
|
|
from_address = @message.from&.first
|
|
smtp_group_id =
|
|
(
|
|
if from_address.blank?
|
|
nil
|
|
else
|
|
Group.where(email_username: from_address, smtp_enabled: true).pick(:id)
|
|
end
|
|
)
|
|
|
|
# always set a default Message ID from the host
|
|
@message.header["Message-ID"] = Email::MessageIdService.generate_default
|
|
|
|
if topic_id.present? && post_id.present?
|
|
post = Post.find_by(id: post_id, topic_id: topic_id)
|
|
|
|
# guards against deleted posts and topics
|
|
return skip(SkippedEmailLog.reason_types[:sender_post_deleted]) if post.blank?
|
|
|
|
topic = post.topic
|
|
return skip(SkippedEmailLog.reason_types[:sender_topic_deleted]) if topic.blank?
|
|
|
|
add_attachments(post)
|
|
add_identification_field_headers(topic, post)
|
|
|
|
# See https://www.ietf.org/rfc/rfc2919.txt for the List-ID
|
|
# specification.
|
|
if topic&.category && !topic.category.uncategorized?
|
|
list_id =
|
|
"#{SiteSetting.title} | #{topic.category.name} <#{topic.category.name.downcase.tr(" ", "-")}.#{host}>"
|
|
|
|
# subcategory case
|
|
if !topic.category.parent_category_id.nil?
|
|
parent_category_name = Category.find_by(id: topic.category.parent_category_id).name
|
|
list_id =
|
|
"#{SiteSetting.title} | #{parent_category_name} #{topic.category.name} <#{topic.category.name.downcase.tr(" ", "-")}.#{parent_category_name.downcase.tr(" ", "-")}.#{host}>"
|
|
end
|
|
else
|
|
list_id = "#{SiteSetting.title} <#{host}>"
|
|
end
|
|
|
|
# When we are emailing people from a group inbox, we are having a PM
|
|
# conversation with them, as a support account would. In this case
|
|
# mailing list headers do not make sense. It is not like a forum topic
|
|
# where you may have tens or hundreds of participants -- it is a
|
|
# conversation between the group and a small handful of people
|
|
# directly contacting the group, often just one person.
|
|
if !smtp_group_id
|
|
# https://www.ietf.org/rfc/rfc3834.txt
|
|
@message.header["Precedence"] = "list"
|
|
@message.header["List-ID"] = list_id
|
|
|
|
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
|
|
end
|
|
end
|
|
|
|
if Email::Sender.bounceable_reply_address?
|
|
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
|
|
@message.header[:return_path] = Email::Sender.bounce_address(email_log.bounce_key)
|
|
end
|
|
|
|
email_log.post_id = post_id if post_id.present?
|
|
email_log.topic_id = topic_id if topic_id.present?
|
|
|
|
if reply_key.present?
|
|
@message.header["Reply-To"] = header_value("Reply-To").gsub!("%{reply_key}", reply_key)
|
|
@message.header[Email::MessageBuilder::ALLOW_REPLY_BY_EMAIL_HEADER] = nil
|
|
end
|
|
|
|
MessageBuilder
|
|
.custom_headers(SiteSetting.email_custom_headers)
|
|
.each do |key, _|
|
|
# Any custom headers added via MessageBuilder that are doubled up here
|
|
# with values that we determine should be set to the last value, which is
|
|
# the one we determined. Our header values should always override the email_custom_headers.
|
|
#
|
|
# While it is valid via RFC5322 to have more than one value for certain headers,
|
|
# we just want to keep it to one, especially in cases where the custom value
|
|
# would conflict with our own.
|
|
#
|
|
# See https://datatracker.ietf.org/doc/html/rfc5322#section-3.6 and
|
|
# https://github.com/mikel/mail/blob/8ef377d6a2ca78aa5bd7f739813f5a0648482087/lib/mail/header.rb#L109-L132
|
|
custom_header = @message.header[key]
|
|
if custom_header.is_a?(Array)
|
|
our_value = custom_header.last.value
|
|
|
|
# Must be set to nil first otherwise another value is just added
|
|
# to the array of values for the header.
|
|
@message.header[key] = nil
|
|
@message.header[key] = our_value
|
|
end
|
|
|
|
value = header_value(key)
|
|
|
|
# Remove Auto-Submitted header for group private message emails, it does
|
|
# not make sense there and may hurt deliverability.
|
|
#
|
|
# From https://www.iana.org/assignments/auto-submitted-keywords/auto-submitted-keywords.xhtml:
|
|
#
|
|
# > Indicates that a message was generated by an automatic process, and is not a direct response to another message.
|
|
@message.header[key] = nil if key.downcase == "auto-submitted" && smtp_group_id
|
|
|
|
# Replace reply_key in custom headers or remove
|
|
if value&.include?("%{reply_key}")
|
|
# Delete old header first or else the same header will be added twice
|
|
@message.header[key] = nil
|
|
@message.header[key] = value.gsub!("%{reply_key}", reply_key) if reply_key.present?
|
|
end
|
|
end
|
|
|
|
# pass the original message_id when using mailjet/mandrill/sparkpost
|
|
case ActionMailer::Base.smtp_settings[:address]
|
|
when /\.mailjet\.com/
|
|
@message.header["X-MJ-CustomID"] = @message.message_id
|
|
when "smtp.mandrillapp.com"
|
|
merge_json_x_header("X-MC-Metadata", message_id: @message.message_id)
|
|
when "smtp.sparkpostmail.com"
|
|
merge_json_x_header("X-MSYS-API", metadata: { message_id: @message.message_id })
|
|
end
|
|
|
|
# Parse the HTML again so we can make any final changes before
|
|
# sending
|
|
style = Email::Styles.new(@message.html_part.body.to_s)
|
|
|
|
# Suppress images from short emails
|
|
if SiteSetting.strip_images_from_short_emails &&
|
|
@message.html_part.body.to_s.bytesize <= SiteSetting.short_email_length &&
|
|
@message.html_part.body =~ /<img[^>]+>/
|
|
style.strip_avatars_and_emojis
|
|
end
|
|
|
|
# Embeds any of the secure images that have been attached inline,
|
|
# removing the redaction notice.
|
|
if SiteSetting.secure_uploads_allow_embed_images_in_emails
|
|
style.inline_secure_images(@message.attachments, @message_attachments_index)
|
|
end
|
|
|
|
@message.html_part.body = style.to_s
|
|
|
|
email_log.message_id = @message.message_id
|
|
|
|
# Log when a message is being sent from a group SMTP address, so we
|
|
# can debug deliverability issues.
|
|
if smtp_group_id
|
|
email_log.smtp_group_id = smtp_group_id
|
|
|
|
# Store contents of all outgoing emails using group SMTP
|
|
# for greater visibility and debugging. If the size of this
|
|
# gets out of hand, we should look into a group-level setting
|
|
# to enable this; size should be kept in check by regular purging
|
|
# of EmailLog though.
|
|
email_log.raw = Email::Cleaner.new(@message).execute
|
|
end
|
|
|
|
DiscourseEvent.trigger(:before_email_send, @message, @email_type)
|
|
|
|
begin
|
|
message_response = @message.deliver!
|
|
|
|
# TestMailer from the Mail gem does not return a real response, it
|
|
# returns an array containing @message, so we have to have this workaround.
|
|
if message_response.kind_of?(Net::SMTP::Response)
|
|
email_log.smtp_transaction_response = message_response.message&.chomp
|
|
end
|
|
rescue *SMTP_CLIENT_ERRORS => e
|
|
return skip(SkippedEmailLog.reason_types[:custom], custom_reason: e.message)
|
|
end
|
|
|
|
DiscourseEvent.trigger(:after_email_send, @message, @email_type)
|
|
|
|
email_log.save!
|
|
email_log
|
|
end
|
|
|
|
def find_user
|
|
return @user if @user
|
|
User.find_by_email(to_address)
|
|
end
|
|
|
|
def to_address
|
|
@to_address ||=
|
|
begin
|
|
to = @message.try(:to)
|
|
to = to.first if Array === to
|
|
to.presence || "no_email_found"
|
|
end
|
|
end
|
|
|
|
def cc_addresses
|
|
@cc_addresses ||=
|
|
begin
|
|
@message.try(:cc) || []
|
|
end
|
|
end
|
|
|
|
def bcc_addresses
|
|
@bcc_addresses ||=
|
|
begin
|
|
@message.try(:bcc) || []
|
|
end
|
|
end
|
|
|
|
def self.host_for(base_url)
|
|
host = "localhost"
|
|
if base_url.present?
|
|
begin
|
|
uri = URI.parse(base_url)
|
|
host = uri.host.downcase if uri.host.present?
|
|
rescue URI::Error
|
|
end
|
|
end
|
|
host
|
|
end
|
|
|
|
private
|
|
|
|
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 |original_upload|
|
|
optimized_1X = original_upload.optimized_images.first
|
|
|
|
if FileHelper.is_supported_image?(original_upload.original_filename) &&
|
|
!should_attach_image?(original_upload, optimized_1X)
|
|
next
|
|
end
|
|
|
|
attached_upload = optimized_1X || original_upload
|
|
next if email_size + attached_upload.filesize > max_email_size
|
|
|
|
begin
|
|
path =
|
|
if attached_upload.local?
|
|
Discourse.store.path_for(attached_upload)
|
|
else
|
|
Discourse.store.download!(attached_upload).path
|
|
end
|
|
|
|
@message_attachments_index[original_upload.sha1] = @message.attachments.size
|
|
@message.attachments[original_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: original_upload.id,
|
|
filename: original_upload.original_filename,
|
|
},
|
|
)
|
|
end
|
|
end
|
|
|
|
fix_parts_after_attachments!
|
|
end
|
|
|
|
def should_attach_image?(upload, optimized_1X = nil)
|
|
return if !SiteSetting.secure_uploads_allow_embed_images_in_emails || !upload.secure?
|
|
if (optimized_1X&.filesize || upload.filesize) >
|
|
SiteSetting.secure_uploads_max_email_embed_image_size_kb.kilobytes
|
|
return
|
|
end
|
|
true
|
|
end
|
|
|
|
#
|
|
# Two behaviors in the mail gem collide:
|
|
#
|
|
# 1. Attachments are added as extra parts at the top level,
|
|
# 2. When there are both text and html parts, the content type is set
|
|
# to 'multipart/alternative'.
|
|
#
|
|
# Since attachments aren't alternative renderings, for emails that contain
|
|
# attachments and both html and text parts, some coercing is necessary.
|
|
#
|
|
# When there are alternative rendering and attachments, this method causes
|
|
# the top level to be 'multipart/mixed' and puts the html and text parts
|
|
# into a nested 'multipart/alternative' part.
|
|
#
|
|
# Due to mail gem magic, @message.text_part and @message.html_part still
|
|
# refer to the same objects.
|
|
#
|
|
def fix_parts_after_attachments!
|
|
has_attachments = @message.attachments.present?
|
|
has_alternative_renderings = @message.html_part.present? && @message.text_part.present?
|
|
|
|
if has_attachments && has_alternative_renderings
|
|
@message.content_type = "multipart/mixed"
|
|
|
|
html_part = @message.html_part
|
|
@message.html_part = nil
|
|
|
|
text_part = @message.text_part
|
|
@message.text_part = nil
|
|
|
|
content =
|
|
Mail::Part.new do
|
|
content_type "multipart/alternative"
|
|
|
|
# we have to re-specify the charset and give the part the decoded body
|
|
# here otherwise the parts will get encoded with US-ASCII which makes
|
|
# a bunch of characters not render correctly in the email
|
|
part content_type: "text/html; charset=utf-8", body: html_part.body.decoded
|
|
part content_type: "text/plain; charset=utf-8", body: text_part.body.decoded
|
|
end
|
|
|
|
@message.parts.unshift(content)
|
|
end
|
|
end
|
|
|
|
def header_value(name)
|
|
header = @message.header[name]
|
|
return nil unless header
|
|
|
|
# NOTE: In most cases this is not a problem, but if a header has
|
|
# doubled up the header[] method will return an array. So we always
|
|
# get the last value of the array and assume that is the correct
|
|
# value.
|
|
#
|
|
# See https://github.com/mikel/mail/blob/8ef377d6a2ca78aa5bd7f739813f5a0648482087/lib/mail/header.rb#L109-L132
|
|
return header.last.value if header.is_a?(Array)
|
|
|
|
header.value
|
|
end
|
|
|
|
def skip(reason_type, custom_reason: nil)
|
|
attributes = {
|
|
email_type: @email_type,
|
|
to_address: to_address,
|
|
user_id: @user&.id,
|
|
reason_type: reason_type,
|
|
}
|
|
|
|
attributes[:custom_reason] = custom_reason if custom_reason
|
|
SkippedEmailLog.create!(attributes)
|
|
end
|
|
|
|
def merge_json_x_header(name, value)
|
|
data =
|
|
begin
|
|
JSON.parse(@message.header[name].to_s)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
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] = ""
|
|
@message.header[name] = nil
|
|
@message.header[name] = data.to_json
|
|
end
|
|
|
|
def get_reply_key(post_id, user_id)
|
|
# ALLOW_REPLY_BY_EMAIL_HEADER is only added if we are _not_ sending
|
|
# via group SMTP and if reply by email site settings are configured
|
|
if !user_id || !post_id ||
|
|
!header_value(Email::MessageBuilder::ALLOW_REPLY_BY_EMAIL_HEADER).present?
|
|
return
|
|
end
|
|
|
|
PostReplyKey.create_or_find_by!(post_id: post_id, user_id: user_id).reply_key
|
|
end
|
|
|
|
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
|
|
|
|
##
|
|
# When sending an email for the first post (OP) of the topic, we do not
|
|
# set References or In-Reply-To headers, since there is nothing yet
|
|
# to reference. This counts as the first email in the thread.
|
|
#
|
|
# Once set, the post's `outbound_message_id` should _always_ be used
|
|
# when sending emails relating to a particular post to maintain threading.
|
|
# This will either be:
|
|
#
|
|
# a) A Message-ID generated in an external main client or service which
|
|
# is recorded when creating a post from an IncomingEmail via Email::Receiver
|
|
# b) A Message-ID generated by Discourse and recorded when sending an email
|
|
# for a newly created post, which is created and saved here to the
|
|
# outbound_message_id column on the Post.
|
|
#
|
|
# The RFC that covers using "Identification Fields", which are References,
|
|
# In-Reply-To, Message-ID, et. al. can be in the RFC link below. It's a good idea to read
|
|
# this beginning in the area immediately after these quotes, at least to understand
|
|
# the 3 main headers:
|
|
#
|
|
# > The "Message-ID:" field provides a unique message identifier that
|
|
# > refers to a particular version of a particular message. The
|
|
# > uniqueness of the message identifier is guaranteed by the host that
|
|
# > generates it.
|
|
#
|
|
# > ...
|
|
#
|
|
# > The "In-Reply-To:" field may be used to identify the message (or
|
|
# > messages) to which the new message is a reply, while the "References:"
|
|
# > field may be used to identify a "thread" of conversation.
|
|
#
|
|
# https://www.rfc-editor.org/rfc/rfc5322.html#section-3.6.4
|
|
#
|
|
# It is a long read, but to understand the decision making process for this
|
|
# threading logic you can take a look at:
|
|
#
|
|
# https://meta.discourse.org/t/discourse-email-messages-are-incorrectly-threaded/233499
|
|
def add_identification_field_headers(topic, post)
|
|
@message.header["Message-ID"] = Email::MessageIdService.generate_or_use_existing(
|
|
post.id,
|
|
).first
|
|
|
|
if post.post_number > 1
|
|
op_message_id = Email::MessageIdService.generate_or_use_existing(topic.first_post.id).first
|
|
|
|
##
|
|
# Whenever we reply to a post directly _or_ quote a post, a PostReply
|
|
# record is made, with the reply_post_id referencing the newly created
|
|
# post, and the post_id referencing the post that was quoted or replied to.
|
|
referenced_posts =
|
|
Post
|
|
.joins("INNER JOIN post_replies ON post_replies.post_id = posts.id ")
|
|
.where("post_replies.reply_post_id = ?", post.id)
|
|
.order(id: :desc)
|
|
.to_a
|
|
|
|
##
|
|
# No referenced posts means that we are just creating a new post not
|
|
# referring to anything, and as such we should just fall back to using
|
|
# the OP.
|
|
if referenced_posts.empty?
|
|
@message.header["In-Reply-To"] = op_message_id
|
|
@message.header["References"] = op_message_id
|
|
else
|
|
##
|
|
# When referencing _multiple_ posts then we just choose the most recent one
|
|
# to use for References so we have a single parent to work with, but
|
|
# every directly replied to post can go into In-Reply-To.
|
|
#
|
|
# We want to make sure all of the outbound_message_ids are already filled here.
|
|
in_reply_to_message_ids =
|
|
MessageIdService.generate_or_use_existing(referenced_posts.map(&:id))
|
|
@message.header["In-Reply-To"] = in_reply_to_message_ids
|
|
most_recent_post_message_id = in_reply_to_message_ids.last
|
|
|
|
##
|
|
# The RFC specifically states that the content of the parent's References
|
|
# field (in our case a tree of replies based on the PostReply table in
|
|
# addition to the OP post's Message-ID) first, _then_ the parent's
|
|
# Message-ID (in our case the outbound_message_id of the post we are replying to).
|
|
#
|
|
# This creates a thread from the OP all the way down to the most recent post we
|
|
# are replying to.
|
|
reply_tree = referenced_post_reply_tree(referenced_posts.first)
|
|
parent_message_ids = MessageIdService.generate_or_use_existing(reply_tree.values.flatten)
|
|
|
|
@message.header["References"] = [
|
|
op_message_id,
|
|
parent_message_ids,
|
|
most_recent_post_message_id,
|
|
].flatten.uniq
|
|
end
|
|
end
|
|
end
|
|
|
|
def referenced_post_reply_tree(post)
|
|
results = DB.query(<<~SQL, start_post_id: post.id)
|
|
WITH RECURSIVE cte AS (
|
|
SELECT reply_post_id, post_id FROM post_replies
|
|
WHERE reply_post_id = :start_post_id
|
|
UNION
|
|
SELECT pr.reply_post_id, pr.post_id
|
|
FROM post_replies pr
|
|
INNER JOIN cte
|
|
ON cte.post_id = pr.reply_post_id
|
|
)
|
|
SELECT DISTINCT cte.*, posts.created_at, posts.outbound_message_id
|
|
FROM cte
|
|
INNER JOIN posts ON posts.id = cte.reply_post_id
|
|
ORDER BY posts.created_at DESC, post_id DESC;
|
|
SQL
|
|
results.inject({}) do |hash, value|
|
|
# We only want to get a single replied-to post, which is the most recently
|
|
# created post, since we cannot deal with multiple parents for References
|
|
hash[value.reply_post_id] ||= [value.post_id]
|
|
hash
|
|
end
|
|
end
|
|
end
|
|
end
|