Class: Email::Sender

Inherits:
Object
  • Object
show all
Defined in:
lib/email/sender.rb

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(message, email_type, user = nil) ⇒ Sender

Returns a new instance of Sender.



27
28
29
30
31
32
# File 'lib/email/sender.rb', line 27

def initialize(message, email_type, user = nil)
  @message = message
  @message_attachments_index = {}
  @email_type = email_type
  @user = user
end

Class Method Details

.host_for(base_url) ⇒ Object



331
332
333
334
335
336
337
338
339
340
341
# File 'lib/email/sender.rb', line 331

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

Instance Method Details

#bcc_addressesObject



324
325
326
327
328
329
# File 'lib/email/sender.rb', line 324

def bcc_addresses
  @bcc_addresses ||=
    begin
      @message.try(:bcc) || []
    end
end

#cc_addressesObject



317
318
319
320
321
322
# File 'lib/email/sender.rb', line 317

def cc_addresses
  @cc_addresses ||=
    begin
      @message.try(:cc) || []
    end
end

#find_userObject



303
304
305
306
# File 'lib/email/sender.rb', line 303

def find_user
  return @user if @user
  User.find_by_email(to_address)
end

#sendObject



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/email/sender.rb', line 34

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

#to_addressObject



308
309
310
311
312
313
314
315
# File 'lib/email/sender.rb', line 308

def to_address
  @to_address ||=
    begin
      to = @message.try(:to)
      to = to.first if Array === to
      to.presence || "no_email_found"
    end
end