Skip to content

Commit 237032f

Browse files
feat: add support for Message-ID handling and idempotency in send API
Adds support for: - Custom Message-IDs via message_ids parameter in /api/v1/send/message - Idempotency detection based on Message-ID for duplicate prevention - Message-ID extraction from raw emails in /api/v1/send/raw - RFC 5322 format validation for Message-IDs Cherry-picked from postalserver/postal PR postalserver#3488 Co-authored-by: max-kuklin <max-kuklin@users.noreply.github.com> Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 76062d2 commit 237032f

4 files changed

Lines changed: 499 additions & 40 deletions

File tree

app/controllers/legacy_api/send_controller.rb

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ class SendController < BaseController
1212
"FromAddressMissing" => "The From address is missing and is required",
1313
"UnauthenticatedFromAddress" => "The From address is not authorised to send mail from this server",
1414
"AttachmentMissingName" => "An attachment is missing a name",
15-
"AttachmentMissingData" => "An attachment is missing data"
15+
"AttachmentMissingData" => "An attachment is missing data",
16+
"InvalidMessageID" => "Message-ID must be in RFC 5322 format: local-part@domain"
1617
}.freeze
1718

1819
# Send a message with the given options
@@ -32,6 +33,8 @@ class SendController < BaseController
3233
# custom_headers => A hash of custom headers
3334
# attachments => An array of attachments
3435
# (name, content_type and data (base64))
36+
# message_ids => A hash of recipient emails to Message-IDs
37+
# (enables idempotency)
3538
#
3639
# Response: A array of hashes containing message information
3740
# OR an error if there is an issue sending the message
@@ -50,6 +53,7 @@ def message
5053
attributes[:bounce] = api_params["bounce"] ? true : false
5154
attributes[:tag] = api_params["tag"]
5255
attributes[:custom_headers] = api_params["headers"] if api_params["headers"]
56+
attributes[:message_ids] = api_params["message_ids"] if api_params["message_ids"].is_a?(Hash)
5357
attributes[:attachments] = []
5458

5559
(api_params["attachments"] || []).each do |attachment|
@@ -112,8 +116,23 @@ def raw
112116

113117
# Store the result ready to return
114118
result = { message_id: nil, messages: {} }
119+
120+
# Extract Message-ID from raw message for duplicate detection
121+
mail_for_message_id = Mail.new(raw_message)
122+
extracted_message_id = mail_for_message_id.message_id&.gsub(/^<|>$/, '')
123+
115124
if api_params["rcpt_to"].is_a?(Array)
116125
api_params["rcpt_to"].uniq.each do |rcpt_to|
126+
# Check for duplicate if Message-ID is present
127+
if extracted_message_id.present?
128+
existing = @current_credential.server.message_db.select(:messages, fields: [:id, :token, :message_id], where: { message_id: extracted_message_id, rcpt_to: rcpt_to }).first
129+
if existing
130+
result[:message_id] = existing["message_id"] if result[:message_id].nil?
131+
result[:messages][rcpt_to] = { id: existing["id"], token: existing["token"], message_id: existing["message_id"], existing: true }
132+
next
133+
end
134+
end
135+
117136
message = @current_credential.server.message_db.new_message
118137
message.rcpt_to = rcpt_to
119138
message.mail_from = api_params["mail_from"]
@@ -125,11 +144,11 @@ def raw
125144
message.bounce = api_params["bounce"] ? true : false
126145
message.save
127146
result[:message_id] = message.message_id if result[:message_id].nil?
128-
result[:messages][rcpt_to] = { id: message.id, token: message.token }
147+
result[:messages][rcpt_to] = { id: message.id, token: message.token, message_id: message.message_id }
129148
end
130149
end
131150
render_success result
132151
end
133152

134153
end
135-
end
154+
end

app/models/outgoing_message_prototype.rb

Lines changed: 73 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class OutgoingMessagePrototype
1818
attr_accessor :tag
1919
attr_accessor :credential
2020
attr_accessor :bounce
21+
attr_accessor :message_ids
2122

2223
def initialize(server, ip, source_type, attributes)
2324
@server = server
@@ -76,7 +77,19 @@ def create_messages
7677
if valid?
7778
all_addresses.each_with_object({}) do |address, hash|
7879
if address = Postal::Helpers.strip_name_from_address(address)
79-
hash[address] = create_message(address)
80+
# Check for existing message if message_ids provided
81+
message_id = @message_ids.is_a?(Hash) ? @message_ids[address] : nil
82+
if message_id
83+
# Strip angle brackets if present
84+
message_id = message_id.gsub(/^<|>$/, '')
85+
# Check for duplicate
86+
existing = @server.message_db.select(:messages, fields: [:id, :token, :message_id], where: { message_id: message_id }).first
87+
if existing
88+
hash[address] = { id: existing["id"], token: existing["token"], message_id: existing["message_id"], existing: true }
89+
next
90+
end
91+
end
92+
hash[address] = create_message(address, message_id)
8093
end
8194
end
8295
else
@@ -105,6 +118,14 @@ def attachments
105118
end
106119
# rubocop:enable Lint/DuplicateMethods
107120

121+
def valid_message_id_format?(message_id)
122+
return false if message_id.blank?
123+
# Strip angle brackets if present for validation
124+
id = message_id.gsub(/^<|>$/, '')
125+
# RFC 5322: local-part@domain (must have @ and both parts non-empty, no spaces)
126+
id.match?(/\A[^@\s]+@[^@\s]+\z/)
127+
end
128+
108129
def validate
109130
@errors = []
110131

@@ -145,57 +166,76 @@ def validate
145166
end
146167
end
147168
end
169+
170+
if @message_ids.is_a?(Hash)
171+
@message_ids.each do |recipient, message_id|
172+
unless valid_message_id_format?(message_id)
173+
@errors << "InvalidMessageID" unless @errors.include?("InvalidMessageID")
174+
break
175+
end
176+
end
177+
end
148178
@errors
149179
end
150180

151181
def raw_message
152-
@raw_message ||= begin
153-
mail = Mail.new
154-
if @custom_headers.is_a?(Hash)
155-
@custom_headers.each { |key, value| mail[key.to_s] = value.to_s }
156-
end
157-
mail.to = to_addresses.join(", ") if to_addresses.present?
158-
mail.cc = cc_addresses.join(", ") if cc_addresses.present?
159-
mail.from = @from
160-
mail.sender = @sender
161-
mail.subject = @subject
162-
mail.reply_to = @reply_to
163-
mail.part content_type: "multipart/alternative" do |p|
164-
if @plain_body.present?
165-
p.text_part = Mail::Part.new
166-
p.text_part.body = @plain_body
167-
end
168-
if @html_body.present?
169-
p.html_part = Mail::Part.new
170-
p.html_part.content_type = "text/html; charset=UTF-8"
171-
p.html_part.body = @html_body
172-
end
182+
@raw_message ||= raw_message_for_recipient(nil, @message_id)
183+
end
184+
185+
def raw_message_for_recipient(address, message_id)
186+
mail = Mail.new
187+
if @custom_headers.is_a?(Hash)
188+
@custom_headers.each { |key, value| mail[key.to_s] = value.to_s }
189+
end
190+
mail.to = to_addresses.join(", ") if to_addresses.present?
191+
mail.cc = cc_addresses.join(", ") if cc_addresses.present?
192+
mail.from = @from
193+
mail.sender = @sender
194+
mail.subject = @subject
195+
mail.reply_to = @reply_to
196+
mail.part content_type: "multipart/alternative" do |p|
197+
if @plain_body.present?
198+
p.text_part = Mail::Part.new
199+
p.text_part.body = @plain_body
173200
end
174-
attachments.each do |attachment|
175-
mail.attachments[attachment[:name]] = {
176-
mime_type: attachment[:content_type],
177-
content: attachment[:data]
178-
}
201+
if @html_body.present?
202+
p.html_part = Mail::Part.new
203+
p.html_part.content_type = "text/html; charset=UTF-8"
204+
p.html_part.body = @html_body
179205
end
180-
mail.header["Received"] = ReceivedHeader.generate(@server, @source_type, @ip, :http)
181-
mail.message_id = "<#{@message_id}>"
182-
mail.to_s
183206
end
207+
attachments.each do |attachment|
208+
mail.attachments[attachment[:name]] = {
209+
mime_type: attachment[:content_type],
210+
content: attachment[:data]
211+
}
212+
end
213+
mail.header["Received"] = ReceivedHeader.generate(@server, @source_type, @ip, :http)
214+
mail.message_id = "<#{message_id}>"
215+
mail.to_s
184216
end
185217

186-
def create_message(address)
218+
def create_message(address, message_id = nil)
219+
# Use provided message_id or generate one
220+
msg_id = message_id || "#{SecureRandom.uuid}@#{Postal::Config.dns.return_path_domain}"
221+
187222
message = @server.message_db.new_message
188223
message.scope = "outgoing"
189224
message.rcpt_to = address
190225
message.mail_from = from_address
191226
message.domain_id = domain.id
192-
message.raw_message = raw_message
227+
message.raw_message = raw_message_for_recipient(address, msg_id)
193228
message.tag = tag
194229
message.credential_id = credential&.id
195230
message.received_with_ssl = true
196231
message.bounce = @bounce
197232
message.save
198-
{ id: message.id, token: message.token }
233+
# Include message_id in response only if message_ids parameter was provided
234+
if @message_ids.is_a?(Hash)
235+
{ id: message.id, token: message.token, message_id: message.message_id }
236+
else
237+
{ id: message.id, token: message.token }
238+
end
199239
end
200240

201241
end

0 commit comments

Comments
 (0)