diff --git a/CHANGELOG.md b/CHANGELOG.md index 63793fe..2195956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/ms/graph/mailer/delivery_method.rb b/lib/ms/graph/mailer/delivery_method.rb index 4405ff5..c4cb270 100644 --- a/lib/ms/graph/mailer/delivery_method.rb +++ b/lib/ms/graph/mailer/delivery_method.rb @@ -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. @@ -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 diff --git a/spec/microsoft/graph/mailer/delivery_method_spec.rb b/spec/microsoft/graph/mailer/delivery_method_spec.rb index 8b5ebcc..0d59e93 100644 --- a/spec/microsoft/graph/mailer/delivery_method_spec.rb +++ b/spec/microsoft/graph/mailer/delivery_method_spec.rb @@ -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' => '', + 'References' => ' ') + + delivery_method.deliver!(mail) + + properties = sent_message_payload['singleValueExtendedProperties'] + expect(properties).to contain_exactly( + { 'id' => 'String 0x1042', 'value' => '' }, + { 'id' => 'String 0x1039', 'value' => ' ' } + ) + 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' => '' } + ) + 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