Class: Email::MessageIdService

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

Overview

Email Message-IDs are used in both our outbound and inbound email flow. For the outbound flow via Email::Sender, we assign a unique Message-ID for any emails sent out from the application. If we are sending an email related to a post, such as through the PostAlerter class, then the Message-ID will contain references to the post ID. The host must also be included on the Message-IDs. The format looks like this:

discourse/post/POST_ID@HOST

We previously had the following formats, but support for these will be removed in 2023:

topic/TOPIC_ID/POST_ID@HOST topic/TOPIC_ID@HOST

For the inbound email flow via Email::Receiver, we use Message-IDs to discern which topic and post the inbound email reply should be in response to. In this case, the Message-ID is extracted from the References and/or In-Reply-To headers, and compared with either the IncomingEmail table, the Post table, or the IncomingEmail to determine where to send the reply.

See datatracker.ietf.org/doc/html/rfc2822#section-3.6.4 for more specific information around Message-IDs in email.

See tools.ietf.org/html/rfc850#section-2.1.7 for the Message-ID format specification.

Class Method Summary collapse

Class Method Details

.discourse_generated_message_id?(message_id) ⇒ Boolean

Returns:

  • (Boolean)


93
94
95
# File 'lib/email/message_id_service.rb', line 93

def discourse_generated_message_id?(message_id)
  message_id_discourse_regexp.match?(message_id)
end

.find_post_from_message_ids(message_ids) ⇒ Object

Uses extracted Message-IDs from both the In-Reply-To and References headers from an incoming email.



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/email/message_id_service.rb', line 71

def find_post_from_message_ids(message_ids)
  message_ids = message_ids.map { |message_id| message_id_clean(message_id) }

  post_ids =
    message_ids
      .map { |message_id| message_id[message_id_discourse_regexp, 1] }
      .compact
      .map(&:to_i)

  post_ids << Post.where(outbound_message_id: message_ids).pluck(:id)
  post_ids << EmailLog.where(message_id: message_ids).pluck(:post_id)
  post_ids << IncomingEmail.where(message_id: message_ids).pluck(:post_id)

  post_ids.flatten!
  post_ids.compact!
  post_ids.uniq!

  return if post_ids.empty?

  Post.where(id: post_ids).order(:created_at).last
end

.generate_defaultObject



35
36
37
# File 'lib/email/message_id_service.rb', line 35

def generate_default
  "<#{SecureRandom.uuid}@#{host}>"
end

.generate_or_use_existing(post_ids) ⇒ Object

The outbound_message_id may be present because either:

  • The post was created via incoming email and Email::Receiver, and references a Message-ID generated by an external email client or service.

  • At least one email has been sent because of the post being created to inform interested parties via email.

If it is blank then we should assume Discourse was the originator of the post, and generate a Message-ID to be used from now on using our discourse/post/POST_ID@HOST format.



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/email/message_id_service.rb', line 50

def generate_or_use_existing(post_ids)
  post_ids = Array.wrap(post_ids)
  return [] if post_ids.empty?

  DB.exec(<<~SQL, host: host)
    UPDATE posts
    SET outbound_message_id = 'discourse/post/' || posts.id || '@' || :host
    WHERE outbound_message_id IS NULL AND posts.id IN (#{post_ids.join(",")});
  SQL

  DB.query_single(<<~SQL)
    SELECT '<' || posts.outbound_message_id || '>'
    FROM posts
    WHERE posts.id IN (#{post_ids.join(",")})
    ORDER BY posts.created_at ASC;
  SQL
end

.hostObject



117
118
119
# File 'lib/email/message_id_service.rb', line 117

def host
  Email::Sender.host_for(Discourse.base_url)
end

.is_message_id_rfc?(message_id) ⇒ Boolean

Returns:

  • (Boolean)


113
114
115
# File 'lib/email/message_id_service.rb', line 113

def is_message_id_rfc?(message_id)
  message_id.start_with?("<") && message_id.include?("@") && message_id.end_with?(">")
end

.message_id_clean(message_id) ⇒ Object



105
106
107
108
109
110
111
# File 'lib/email/message_id_service.rb', line 105

def message_id_clean(message_id)
  if message_id.present? && is_message_id_rfc?(message_id)
    message_id.gsub(/\A<|>\z/, "")
  else
    message_id
  end
end

.message_id_discourse_regexpObject



97
98
99
# File 'lib/email/message_id_service.rb', line 97

def message_id_discourse_regexp
  Regexp.new "discourse/post/(\\d+)@#{Regexp.escape(host)}"
end

.message_id_rfc_format(message_id) ⇒ Object



101
102
103
# File 'lib/email/message_id_service.rb', line 101

def message_id_rfc_format(message_id)
  message_id.present? && !is_message_id_rfc?(message_id) ? "<#{message_id}>" : message_id
end