Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- `In-Reply-To` and `References` headers are no longer dropped when sending.
The JSON `sendMail` payload cannot carry standard internet message headers,
so they are now mapped to their MAPI extended properties
(`PidTagInReplyToId` / `PidTagInternetReferences`), keeping replies threaded
in the recipient's mail client.

## [1.0.0] - 2025-12-15

### Added
Expand Down
36 changes: 34 additions & 2 deletions lib/ms/graph/mailer/delivery_method.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ module MsGraphMailer
class DeliveryMethod
include Dry::Monads[:result]

# PidTagInReplyToId (0x1042) / PidTagInternetReferences (0x1039)
IN_REPLY_TO_PROPERTY_ID = 'String 0x1042'
REFERENCES_PROPERTY_ID = 'String 0x1039'

attr_reader :settings

# INFO: settings is passed from config.action_mailer.<environment>
Expand Down Expand Up @@ -76,14 +80,42 @@ def build_message_payload(mail, sender_email)
ccRecipients: build_recipients(mail.cc),
bccRecipients: build_recipients(mail.bcc),
replyTo: build_recipients(mail.reply_to),
attachments: build_attachments(mail)
attachments: build_attachments(mail),
singleValueExtendedProperties: build_threading_properties(mail)
}.tap do |msg|
%i[toRecipients ccRecipients bccRecipients replyTo attachments].each do |field|
%i[toRecipients ccRecipients bccRecipients replyTo attachments singleValueExtendedProperties].each do |field|
msg.delete(field) if msg[field].blank?
end
end
end

# Graph's sendMail only accepts x-* custom headers via
# internetMessageHeaders, so the standard threading headers
# (In-Reply-To/References) would silently get lost. Mapping them to their
# MAPI extended properties makes Exchange emit them on the wire, keeping
# replies threaded in the recipient's client.
def build_threading_properties(mail)
properties = []

in_reply_to = format_message_ids(mail.in_reply_to)
references = format_message_ids(mail.references)

properties << { id: IN_REPLY_TO_PROPERTY_ID, value: in_reply_to } if in_reply_to
properties << { id: REFERENCES_PROPERTY_ID, value: references } if references
properties
end

# The mail gem parses message-id fields to bare ids (no angle brackets);
# the RFC 5322 header format Exchange writes needs them re-added.
def format_message_ids(ids)
formatted = Array(ids).compact.map(&:to_s).reject(&:empty?).map do |id|
id.start_with?('<') ? id : "<#{id}>"
end
return nil if formatted.empty?

formatted.join(' ')
end

def build_body(mail)
if mail.multipart?
html_part = mail.html_part
Expand Down
84 changes: 83 additions & 1 deletion spec/microsoft/graph/mailer/delivery_method_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,89 @@

require 'spec_helper'
require 'ms/graph/mailer'
require 'mail'

RSpec.describe MsGraphMailer::DeliveryMethod do
# Add specs here
subject(:delivery_method) { described_class.new({}) }

let(:token_service) { instance_double(MsGraphMailer::TokenService) }
let(:send_mail_endpoint) { 'https://graph.microsoft.com/v1.0/users/sender%40example.com/sendMail' }

before do
MsGraphMailer.configure do |config|
config.tenant_id = 'tenant-id'
config.client_id = 'client-id'
config.client_secret = 'client-secret'
config.logger = Logger.new(File::NULL)
end

allow(MsGraphMailer::TokenService).to receive(:new).and_return(token_service)
allow(token_service).to receive(:call).and_return(Dry::Monads::Success('access-token'))

stub_request(:post, send_mail_endpoint).to_return(status: 202, body: '')
end

def build_mail(headers = {})
mail = Mail.new do
from 'sender@example.com'
to 'recipient@example.com'
subject 'Re: Hello'
body 'Hi there'
end
headers.each { |name, value| mail[name] = value }
mail
end

def sent_message_payload
payload = nil
expect(WebMock).to(have_requested(:post, send_mail_endpoint).with do |request|
payload = JSON.parse(request.body)['message']
true
end)
payload
end

describe '#deliver! threading headers' do
it 'transmits In-Reply-To and References as MAPI extended properties' do
mail = build_mail('In-Reply-To' => '<parent@example.com>',
'References' => '<root@example.com> <parent@example.com>')

delivery_method.deliver!(mail)

properties = sent_message_payload['singleValueExtendedProperties']
expect(properties).to contain_exactly(
{ 'id' => 'String 0x1042', 'value' => '<parent@example.com>' },
{ 'id' => 'String 0x1039', 'value' => '<root@example.com> <parent@example.com>' }
)
end

it 'wraps bare message ids in angle brackets' do
mail = build_mail
mail.in_reply_to = 'parent@example.com'

delivery_method.deliver!(mail)

properties = sent_message_payload['singleValueExtendedProperties']
expect(properties).to contain_exactly(
{ 'id' => 'String 0x1042', 'value' => '<parent@example.com>' }
)
end

it 'omits the extended properties when no threading headers are present' do
delivery_method.deliver!(build_mail)

expect(sent_message_payload).not_to have_key('singleValueExtendedProperties')
end
end

describe '#deliver! basic payload' do
it 'sends subject, body and recipients' do
delivery_method.deliver!(build_mail)

payload = sent_message_payload
expect(payload['subject']).to eq('Re: Hello')
expect(payload['body']).to include('content' => 'Hi there')
expect(payload['toRecipients']).to eq([{ 'emailAddress' => { 'address' => 'recipient@example.com' } }])
end
end
end
Loading