Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions app/controllers/legacy_api/send_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ class SendController < BaseController
"FromAddressMissing" => "The From address is missing and is required",
"UnauthenticatedFromAddress" => "The From address is not authorised to send mail from this server",
"AttachmentMissingName" => "An attachment is missing a name",
"AttachmentMissingData" => "An attachment is missing data"
"AttachmentMissingData" => "An attachment is missing data",
"InvalidMessageID" => "Message-ID must be in RFC 5322 format: local-part@domain"
}.freeze

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

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

# Store the result ready to return
result = { message_id: nil, messages: {} }

# Extract Message-ID from raw message for duplicate detection
mail_for_message_id = Mail.new(raw_message)
extracted_message_id = mail_for_message_id.message_id&.gsub(/^<|>$/, '')

if api_params["rcpt_to"].is_a?(Array)
api_params["rcpt_to"].uniq.each do |rcpt_to|
# Check for duplicate if Message-ID is present
if extracted_message_id.present?
existing = @current_credential.server.message_db.select(:messages, fields: [:id, :token, :message_id], where: { message_id: extracted_message_id, rcpt_to: rcpt_to }).first
if existing
result[:message_id] = existing["message_id"] if result[:message_id].nil?
result[:messages][rcpt_to] = { id: existing["id"], token: existing["token"], message_id: existing["message_id"], existing: true }
next
end
end

message = @current_credential.server.message_db.new_message
message.rcpt_to = rcpt_to
message.mail_from = api_params["mail_from"]
Expand All @@ -125,11 +144,11 @@ def raw
message.bounce = api_params["bounce"] ? true : false
message.save
result[:message_id] = message.message_id if result[:message_id].nil?
result[:messages][rcpt_to] = { id: message.id, token: message.token }
result[:messages][rcpt_to] = { id: message.id, token: message.token, message_id: message.message_id }
end
end
render_success result
end

end
end
end
106 changes: 73 additions & 33 deletions app/models/outgoing_message_prototype.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class OutgoingMessagePrototype
attr_accessor :tag
attr_accessor :credential
attr_accessor :bounce
attr_accessor :message_ids

def initialize(server, ip, source_type, attributes)
@server = server
Expand Down Expand Up @@ -76,7 +77,19 @@ def create_messages
if valid?
all_addresses.each_with_object({}) do |address, hash|
if address = Postal::Helpers.strip_name_from_address(address)
hash[address] = create_message(address)
# Check for existing message if message_ids provided
message_id = @message_ids.is_a?(Hash) ? @message_ids[address] : nil
if message_id
# Strip angle brackets if present
message_id = message_id.gsub(/^<|>$/, '')
# Check for duplicate
existing = @server.message_db.select(:messages, fields: [:id, :token, :message_id], where: { message_id: message_id }).first
if existing
hash[address] = { id: existing["id"], token: existing["token"], message_id: existing["message_id"], existing: true }
next
end
end
hash[address] = create_message(address, message_id)
end
end
else
Expand Down Expand Up @@ -105,6 +118,14 @@ def attachments
end
# rubocop:enable Lint/DuplicateMethods

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

def validate
@errors = []

Expand Down Expand Up @@ -145,57 +166,76 @@ def validate
end
end
end

if @message_ids.is_a?(Hash)
@message_ids.each do |recipient, message_id|
unless valid_message_id_format?(message_id)
@errors << "InvalidMessageID" unless @errors.include?("InvalidMessageID")
break
end
end
end
@errors
end

def raw_message
@raw_message ||= begin
mail = Mail.new
if @custom_headers.is_a?(Hash)
@custom_headers.each { |key, value| mail[key.to_s] = value.to_s }
end
mail.to = to_addresses.join(", ") if to_addresses.present?
mail.cc = cc_addresses.join(", ") if cc_addresses.present?
mail.from = @from
mail.sender = @sender
mail.subject = @subject
mail.reply_to = @reply_to
mail.part content_type: "multipart/alternative" do |p|
if @plain_body.present?
p.text_part = Mail::Part.new
p.text_part.body = @plain_body
end
if @html_body.present?
p.html_part = Mail::Part.new
p.html_part.content_type = "text/html; charset=UTF-8"
p.html_part.body = @html_body
end
@raw_message ||= raw_message_for_recipient(nil, @message_id)
end

def raw_message_for_recipient(address, message_id)
mail = Mail.new
if @custom_headers.is_a?(Hash)
@custom_headers.each { |key, value| mail[key.to_s] = value.to_s }
end
mail.to = to_addresses.join(", ") if to_addresses.present?
mail.cc = cc_addresses.join(", ") if cc_addresses.present?
mail.from = @from
mail.sender = @sender
mail.subject = @subject
mail.reply_to = @reply_to
mail.part content_type: "multipart/alternative" do |p|
if @plain_body.present?
p.text_part = Mail::Part.new
p.text_part.body = @plain_body
end
attachments.each do |attachment|
mail.attachments[attachment[:name]] = {
mime_type: attachment[:content_type],
content: attachment[:data]
}
if @html_body.present?
p.html_part = Mail::Part.new
p.html_part.content_type = "text/html; charset=UTF-8"
p.html_part.body = @html_body
end
mail.header["Received"] = ReceivedHeader.generate(@server, @source_type, @ip, :http)
mail.message_id = "<#{@message_id}>"
mail.to_s
end
attachments.each do |attachment|
mail.attachments[attachment[:name]] = {
mime_type: attachment[:content_type],
content: attachment[:data]
}
end
mail.header["Received"] = ReceivedHeader.generate(@server, @source_type, @ip, :http)
mail.message_id = "<#{message_id}>"
mail.to_s
end

def create_message(address)
def create_message(address, message_id = nil)
# Use provided message_id or generate one
msg_id = message_id || "#{SecureRandom.uuid}@#{Postal::Config.dns.return_path_domain}"

message = @server.message_db.new_message
message.scope = "outgoing"
message.rcpt_to = address
message.mail_from = from_address
message.domain_id = domain.id
message.raw_message = raw_message
message.raw_message = raw_message_for_recipient(address, msg_id)
message.tag = tag
message.credential_id = credential&.id
message.received_with_ssl = true
message.bounce = @bounce
message.save
{ id: message.id, token: message.token }
# Include message_id in response only if message_ids parameter was provided
if @message_ids.is_a?(Hash)
{ id: message.id, token: message.token, message_id: message.message_id }
else
{ id: message.id, token: message.token }
end
end

end
Loading
Loading