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

TODO (martin) 2023-04-01 We should remove these backwards-compatible formats for the Message-ID and solely use the discourse/post/999@host format.

Returns:

  • (Boolean)


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

def discourse_generated_message_id?(message_id)
  !!(message_id =~ message_id_post_id_regexp) ||
    !!(message_id =~ message_id_topic_id_regexp) ||
    !!(message_id =~ message_id_discourse_regexp)
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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# 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) }

  # TODO (martin) 2023-04-01 We should remove these backwards-compatible
  # formats for the Message-ID and solely use the discourse/post/999@host
  # format.
  topic_ids =
    message_ids
      .map { |message_id| message_id[message_id_topic_id_regexp, 1] }
      .compact
      .map(&:to_i)
  post_ids =
    message_ids
      .map { |message_id| message_id[message_id_post_id_regexp, 1] }
      .compact
      .map(&:to_i)

  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)
    .or(Post.where(topic_id: topic_ids, post_number: 1))
    .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



149
150
151
# File 'lib/email/message_id_service.rb', line 149

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

.is_message_id_rfc?(message_id) ⇒ Boolean

Returns:

  • (Boolean)


145
146
147
# File 'lib/email/message_id_service.rb', line 145

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



137
138
139
140
141
142
143
# File 'lib/email/message_id_service.rb', line 137

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



129
130
131
# File 'lib/email/message_id_service.rb', line 129

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

.message_id_post_id_regexpObject

TODO (martin) 2023-04-01 We should remove these backwards-compatible formats for the Message-ID and solely use the discourse/post/999@host format.



121
122
123
# File 'lib/email/message_id_service.rb', line 121

def message_id_post_id_regexp
  Regexp.new "topic/\\d+/(\\d+|\\d+\.\\w+)@#{Regexp.escape(host)}"
end

.message_id_rfc_format(message_id) ⇒ Object



133
134
135
# File 'lib/email/message_id_service.rb', line 133

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

.message_id_topic_id_regexpObject



125
126
127
# File 'lib/email/message_id_service.rb', line 125

def message_id_topic_id_regexp
  Regexp.new "topic/(\\d+|\\d+\.\\w+)@#{Regexp.escape(host)}"
end