ms-graph-mailer 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42a5f17bc2451562927b00da39c699228579048d89cf34966b399d78c4b3b24b
4
- data.tar.gz: 3d5bb273afdd1861a226a42944586dfac77c4ea2591879fa0fdf24edb11f2b51
3
+ metadata.gz: bc38df909076fbf11baa641699ca2e813f683ab2dc95dc6c69c27b897bc85cd3
4
+ data.tar.gz: f48660fa9212b12343c92ef9a8786965fa8dfc3b86705c7c46cf86e339991557
5
5
  SHA512:
6
- metadata.gz: c9e4732a3646a088d83974f85f48403be2ea34c5c48d0d14626ac6b6e371dd860e91577692940c40cba0ed70641556c224f0acce11c92b761d0b721a76afa902
7
- data.tar.gz: 3135ace13bea41f6f31dae45dbbc8bfcec1870be5b47ff62995c81fe8340427d55ce10c5610f302d5d1b5c978d3f5fb85d3ee906756e76b5eb6bebf0252c158f
6
+ metadata.gz: '09014fecd0b2d3a1d5fdd417c887acdd9b6580cccf5a8e19c38ee92e9a44634785ccb2cab4865e96cb2814c61da89bc9ba6bbe9826cde8b30bbd452a25154743'
7
+ data.tar.gz: 6ea5c55727621b5fad871add5d91dc18ec55fa31648718a33531227cfcac7162b3fa20cd14a46a2038d3d5e2cb7e614ebc95cf5670680d9baf8ab55c4d058855
data/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.1] - 2026-11-06
9
+
10
+ ### Fixed
11
+
12
+ - `In-Reply-To` and `References` headers are no longer dropped when sending.
13
+ The JSON `sendMail` payload cannot carry standard internet message headers,
14
+ so they are now mapped to their MAPI extended properties
15
+ (`PidTagInReplyToId` / `PidTagInternetReferences`), keeping replies threaded
16
+ in the recipient's mail client.
17
+
18
+ ## [1.1.0] - 2026-11-06
19
+
20
+ ### Added
21
+
22
+ - `MsGraphMailer::DeliveryError` exposes `status`, `code`, `retry_after`, `request_id` and
23
+ `client_request_id`, plus `throttled?` (429/503 — rejected, safe to retry) and
24
+ `outcome_unknown?` (other 5xx and network errors — the mail may have been sent).
25
+ - Every sendMail request carries a generated `client-request-id`; request ids and
26
+ `Retry-After` are logged on success and failure.
27
+ - `open_timeout` / `timeout` configuration options for the sendMail request.
28
+
29
+ ### Changed
30
+
31
+ - Error classes moved to `lib/ms/graph/mailer/errors.rb`. Error messages are unchanged.
32
+
8
33
  ## [1.0.0] - 2025-12-15
9
34
 
10
35
  ### Added
@@ -43,5 +68,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
43
68
  - Configuration system for tenant, client credentials
44
69
  - Integration with Rails logger
45
70
 
71
+ [1.1.1]: https://github.com/zauberware/ms-graph-mailer/releases/tag/v1.1.1
72
+ [1.1.0]: https://github.com/zauberware/ms-graph-mailer/releases/tag/v1.1.0
46
73
  [1.0.0]: https://github.com/zauberware/ms-graph-mailer/releases/tag/v1.0.0
47
74
  [0.1.0]: https://github.com/zauberware/ms-graph-mailer/releases/tag/v0.1.0
data/README.md CHANGED
@@ -48,6 +48,9 @@ MsGraphMailer.configure do |config|
48
48
  config.client_id = ENV['AZURE_MAIL_APP_CLIENT_ID']
49
49
  config.client_secret = ENV['AZURE_MAIL_APP_CLIENT_SECRET']
50
50
  config.logger = Rails.logger
51
+ # Optional: request timeouts in seconds (default: HTTP adapter defaults)
52
+ config.open_timeout = 10
53
+ config.timeout = 120
51
54
  end
52
55
  ```
53
56
 
@@ -106,6 +109,21 @@ The gem raises specific exceptions for different error scenarios:
106
109
  - `MsGraphMailer::AuthenticationError` - OAuth authentication failures
107
110
  - `MsGraphMailer::DeliveryError` - Email delivery failures
108
111
 
112
+ `DeliveryError` carries the Graph response details so callers can decide whether to retry:
113
+
114
+ ```ruby
115
+ rescue MsGraphMailer::DeliveryError => e
116
+ e.status # => 503
117
+ e.code # => "CommandConcurrencyLimitReached"
118
+ e.retry_after # => 7 (seconds, from the Retry-After header; nil if absent)
119
+ e.request_id # => Graph request-id, for Microsoft support
120
+ e.client_request_id # => id the gem sent with the request (also set on timeouts)
121
+
122
+ e.throttled? # 429/503: Graph rejected the request, nothing was sent - safe to retry
123
+ e.outcome_unknown? # other 5xx or network error/timeout - the mail may have been sent
124
+ end
125
+ ```
126
+
109
127
  ## Development
110
128
 
111
129
  After checking out the repo, run:
@@ -4,6 +4,10 @@ module MsGraphMailer
4
4
  class DeliveryMethod
5
5
  include Dry::Monads[:result]
6
6
 
7
+ # PidTagInReplyToId (0x1042) / PidTagInternetReferences (0x1039)
8
+ IN_REPLY_TO_PROPERTY_ID = 'String 0x1042'
9
+ REFERENCES_PROPERTY_ID = 'String 0x1039'
10
+
7
11
  attr_reader :settings
8
12
 
9
13
  # INFO: settings is passed from config.action_mailer.<environment>
@@ -28,31 +32,56 @@ module MsGraphMailer
28
32
  saveToSentItems: true
29
33
  }
30
34
 
35
+ # Sent with the request and echoed back by Graph, so a failure can be
36
+ # traced with Microsoft even when no response arrives (timeouts).
37
+ client_request_id = SecureRandom.uuid
38
+
31
39
  response = connection(token_result.value!).post("users/#{CGI.escape(sender_email)}/sendMail") do |req|
32
40
  req.headers['Content-Type'] = 'application/json'
41
+ req.headers['client-request-id'] = client_request_id
42
+ req.headers['return-client-request-id'] = 'true'
33
43
  req.body = message_payload.to_json
34
44
  end
35
45
 
36
- unless response.status == 202
37
- error_body = begin
38
- JSON.parse(response.body)
39
- rescue StandardError
40
- {}
41
- end
42
- error_msg = error_body.dig('error', 'message') || 'Unknown error'
43
- logger.error("[MsGraphMailer] sendMail failed: [status: #{response.status}] - [body: #{response.body}]")
44
- raise DeliveryError, "Microsoft Graph sendEmail failed with status #{response.status}: #{error_msg}"
45
- end
46
+ raise build_delivery_error(response, client_request_id) unless response.status == 202
46
47
 
47
- logger.info('[MsGraphMailer] Email sent successfully via Microsoft Graph')
48
+ logger.info('[MsGraphMailer] Email sent successfully via Microsoft Graph ' \
49
+ "[request-id: #{response.headers['request-id']}] [client-request-id: #{client_request_id}]")
48
50
  response
49
51
  rescue Faraday::Error => e
50
- logger.error("[MsGraphMailer] Network error: #{e.message}")
51
- raise DeliveryError, "Network error sending email: #{e.message}"
52
+ logger.error("[MsGraphMailer] Network error: #{e.message} [client-request-id: #{client_request_id}]")
53
+ raise DeliveryError.new("Network error sending email: #{e.message}",
54
+ client_request_id: client_request_id, network_error: true)
52
55
  end
53
56
 
54
57
  private
55
58
 
59
+ def build_delivery_error(response, client_request_id)
60
+ error = parse_error_body(response.body)
61
+ request_id = response.headers['request-id']
62
+ retry_after = DeliveryError.parse_retry_after(response.headers['Retry-After'])
63
+
64
+ logger.error("[MsGraphMailer] sendMail failed: [status: #{response.status}] [code: #{error['code']}] " \
65
+ "[request-id: #{request_id}] [client-request-id: #{client_request_id}] " \
66
+ "[retry-after: #{retry_after.inspect}] - [body: #{response.body}]")
67
+
68
+ DeliveryError.new(
69
+ "Microsoft Graph sendEmail failed with status #{response.status}: #{error['message'] || 'Unknown error'}",
70
+ status: response.status,
71
+ code: error['code'],
72
+ retry_after: retry_after,
73
+ request_id: request_id,
74
+ client_request_id: client_request_id
75
+ )
76
+ end
77
+
78
+ def parse_error_body(body)
79
+ parsed = JSON.parse(body.to_s)
80
+ parsed.is_a?(Hash) && parsed['error'].is_a?(Hash) ? parsed['error'] : {}
81
+ rescue JSON::ParserError
82
+ {}
83
+ end
84
+
56
85
  def validate_configuration!
57
86
  config = MsGraphMailer.configuration
58
87
  return if config&.valid?
@@ -76,14 +105,42 @@ module MsGraphMailer
76
105
  ccRecipients: build_recipients(mail.cc),
77
106
  bccRecipients: build_recipients(mail.bcc),
78
107
  replyTo: build_recipients(mail.reply_to),
79
- attachments: build_attachments(mail)
108
+ attachments: build_attachments(mail),
109
+ singleValueExtendedProperties: build_threading_properties(mail)
80
110
  }.tap do |msg|
81
- %i[toRecipients ccRecipients bccRecipients replyTo attachments].each do |field|
111
+ %i[toRecipients ccRecipients bccRecipients replyTo attachments singleValueExtendedProperties].each do |field|
82
112
  msg.delete(field) if msg[field].blank?
83
113
  end
84
114
  end
85
115
  end
86
116
 
117
+ # Graph's sendMail only accepts x-* custom headers via
118
+ # internetMessageHeaders, so the standard threading headers
119
+ # (In-Reply-To/References) would silently get lost. Mapping them to their
120
+ # MAPI extended properties makes Exchange emit them on the wire, keeping
121
+ # replies threaded in the recipient's client.
122
+ def build_threading_properties(mail)
123
+ properties = []
124
+
125
+ in_reply_to = format_message_ids(mail.in_reply_to)
126
+ references = format_message_ids(mail.references)
127
+
128
+ properties << { id: IN_REPLY_TO_PROPERTY_ID, value: in_reply_to } if in_reply_to
129
+ properties << { id: REFERENCES_PROPERTY_ID, value: references } if references
130
+ properties
131
+ end
132
+
133
+ # The mail gem parses message-id fields to bare ids (no angle brackets);
134
+ # the RFC 5322 header format Exchange writes needs them re-added.
135
+ def format_message_ids(ids)
136
+ formatted = Array(ids).compact.map(&:to_s).reject(&:empty?).map do |id|
137
+ id.start_with?('<') ? id : "<#{id}>"
138
+ end
139
+ return nil if formatted.empty?
140
+
141
+ formatted.join(' ')
142
+ end
143
+
87
144
  def build_body(mail)
88
145
  if mail.multipart?
89
146
  html_part = mail.html_part
@@ -133,6 +190,8 @@ module MsGraphMailer
133
190
  def connection(access_token)
134
191
  @connection ||= Faraday.new(url: 'https://graph.microsoft.com/v1.0/') do |f|
135
192
  f.headers['Authorization'] = "Bearer #{access_token}"
193
+ f.options.open_timeout = MsGraphMailer.configuration&.open_timeout
194
+ f.options.timeout = MsGraphMailer.configuration&.timeout
136
195
  f.adapter Faraday.default_adapter
137
196
  # SSL verification configuration
138
197
  f.ssl.verify = ssl_verify?
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module MsGraphMailer
6
+ class Error < StandardError; end
7
+ class AuthenticationError < Error; end
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised when Microsoft Graph does not accept a sendMail request.
11
+ #
12
+ # Besides the message (kept stable for callers that persist or match it),
13
+ # the error exposes the response details a caller needs to decide whether a
14
+ # retry is safe:
15
+ #
16
+ # - throttled? Graph rejected the request (429, or 503 such as
17
+ # "CommandConcurrencyLimitReached"). Nothing was sent;
18
+ # retry after #retry_after seconds.
19
+ # - outcome_unknown? Gateway errors and network failures/timeouts. The
20
+ # mail may or may not have been sent — retrying blindly
21
+ # risks a duplicate.
22
+ class DeliveryError < Error
23
+ THROTTLED_STATUSES = [429, 503].freeze
24
+
25
+ attr_reader :status, :code, :retry_after, :request_id, :client_request_id
26
+
27
+ def initialize(message = nil, status: nil, code: nil, retry_after: nil, request_id: nil, # rubocop:disable Metrics/ParameterLists
28
+ client_request_id: nil, network_error: false)
29
+ super(message)
30
+ @status = status
31
+ @code = code
32
+ @retry_after = retry_after
33
+ @request_id = request_id
34
+ @client_request_id = client_request_id
35
+ @network_error = network_error
36
+ end
37
+
38
+ def throttled?
39
+ THROTTLED_STATUSES.include?(status)
40
+ end
41
+
42
+ def outcome_unknown?
43
+ return true if @network_error
44
+ return false if status.nil? || throttled?
45
+
46
+ status >= 500
47
+ end
48
+
49
+ # Retry-After is either delta-seconds or an HTTP date (RFC 9110 §10.2.3).
50
+ # Returns whole seconds, or nil when the header is absent or unparsable.
51
+ def self.parse_retry_after(value, now: Time.now)
52
+ return nil if value.nil? || value.to_s.strip.empty?
53
+
54
+ raw = value.to_s.strip
55
+ return raw.to_i if raw.match?(/\A\d+\z/)
56
+
57
+ [(Time.httpdate(raw) - now).ceil, 0].max
58
+ rescue ArgumentError
59
+ nil
60
+ end
61
+ end
62
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MsGraphMailer
4
- VERSION = '1.0.0'
4
+ VERSION = '1.1.1'
5
5
  end
@@ -6,17 +6,14 @@ require 'dry/monads'
6
6
  require 'json'
7
7
  require 'base64'
8
8
  require 'cgi'
9
+ require 'securerandom'
9
10
 
10
11
  require_relative 'mailer/version'
12
+ require_relative 'mailer/errors'
11
13
  require_relative 'mailer/token_service'
12
14
  require_relative 'mailer/delivery_method'
13
15
 
14
16
  module MsGraphMailer
15
- class Error < StandardError; end
16
- class AuthenticationError < Error; end
17
- class DeliveryError < Error; end
18
- class ConfigurationError < Error; end
19
-
20
17
  class << self
21
18
  attr_accessor :configuration
22
19
 
@@ -27,13 +24,17 @@ module MsGraphMailer
27
24
  end
28
25
 
29
26
  class Configuration
30
- attr_accessor :tenant_id, :client_id, :client_secret, :logger
27
+ # open_timeout / timeout (seconds) apply to the sendMail request. nil keeps
28
+ # the HTTP adapter's defaults.
29
+ attr_accessor :tenant_id, :client_id, :client_secret, :logger, :open_timeout, :timeout
31
30
 
32
31
  def initialize
33
32
  @tenant_id = nil
34
33
  @client_id = nil
35
34
  @client_secret = nil
36
35
  @logger = nil
36
+ @open_timeout = nil
37
+ @timeout = nil
37
38
  end
38
39
 
39
40
  def valid?
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ms-graph-mailer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zauberware Technologies GmbH
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-12-15 00:00:00.000000000 Z
11
+ date: 2026-11-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionmailer
@@ -80,104 +80,90 @@ dependencies:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
82
  version: '0.3'
83
- - !ruby/object:Gem::Dependency
84
- name: bundler
85
- requirement: !ruby/object:Gem::Requirement
86
- requirements:
87
- - - "~>"
88
- - !ruby/object:Gem::Version
89
- version: '2.0'
90
- type: :development
91
- prerelease: false
92
- version_requirements: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - "~>"
95
- - !ruby/object:Gem::Version
96
- version: '2.0'
97
83
  - !ruby/object:Gem::Dependency
98
84
  name: rake
99
85
  requirement: !ruby/object:Gem::Requirement
100
86
  requirements:
101
87
  - - "~>"
102
88
  - !ruby/object:Gem::Version
103
- version: '13.0'
89
+ version: '13.4'
104
90
  type: :development
105
91
  prerelease: false
106
92
  version_requirements: !ruby/object:Gem::Requirement
107
93
  requirements:
108
94
  - - "~>"
109
95
  - !ruby/object:Gem::Version
110
- version: '13.0'
96
+ version: '13.4'
111
97
  - !ruby/object:Gem::Dependency
112
98
  name: rspec
113
99
  requirement: !ruby/object:Gem::Requirement
114
100
  requirements:
115
101
  - - "~>"
116
102
  - !ruby/object:Gem::Version
117
- version: '3.0'
103
+ version: '3.13'
118
104
  type: :development
119
105
  prerelease: false
120
106
  version_requirements: !ruby/object:Gem::Requirement
121
107
  requirements:
122
108
  - - "~>"
123
109
  - !ruby/object:Gem::Version
124
- version: '3.0'
110
+ version: '3.13'
125
111
  - !ruby/object:Gem::Dependency
126
112
  name: rubocop
127
113
  requirement: !ruby/object:Gem::Requirement
128
114
  requirements:
129
115
  - - "~>"
130
116
  - !ruby/object:Gem::Version
131
- version: '1.0'
117
+ version: '1.92'
132
118
  type: :development
133
119
  prerelease: false
134
120
  version_requirements: !ruby/object:Gem::Requirement
135
121
  requirements:
136
122
  - - "~>"
137
123
  - !ruby/object:Gem::Version
138
- version: '1.0'
124
+ version: '1.92'
139
125
  - !ruby/object:Gem::Dependency
140
126
  name: rubocop-rake
141
127
  requirement: !ruby/object:Gem::Requirement
142
128
  requirements:
143
129
  - - "~>"
144
130
  - !ruby/object:Gem::Version
145
- version: '0.6'
131
+ version: '0.7'
146
132
  type: :development
147
133
  prerelease: false
148
134
  version_requirements: !ruby/object:Gem::Requirement
149
135
  requirements:
150
136
  - - "~>"
151
137
  - !ruby/object:Gem::Version
152
- version: '0.6'
138
+ version: '0.7'
153
139
  - !ruby/object:Gem::Dependency
154
140
  name: rubocop-rspec
155
141
  requirement: !ruby/object:Gem::Requirement
156
142
  requirements:
157
143
  - - "~>"
158
144
  - !ruby/object:Gem::Version
159
- version: '2.0'
145
+ version: '3.10'
160
146
  type: :development
161
147
  prerelease: false
162
148
  version_requirements: !ruby/object:Gem::Requirement
163
149
  requirements:
164
150
  - - "~>"
165
151
  - !ruby/object:Gem::Version
166
- version: '2.0'
152
+ version: '3.10'
167
153
  - !ruby/object:Gem::Dependency
168
154
  name: webmock
169
155
  requirement: !ruby/object:Gem::Requirement
170
156
  requirements:
171
157
  - - "~>"
172
158
  - !ruby/object:Gem::Version
173
- version: '3.0'
159
+ version: '3.34'
174
160
  type: :development
175
161
  prerelease: false
176
162
  version_requirements: !ruby/object:Gem::Requirement
177
163
  requirements:
178
164
  - - "~>"
179
165
  - !ruby/object:Gem::Version
180
- version: '3.0'
166
+ version: '3.34'
181
167
  description: A custom delivery method for ActionMailer that sends emails via Microsoft
182
168
  Graph API using OAuth 2.0 client credentials flow
183
169
  email:
@@ -191,6 +177,7 @@ files:
191
177
  - README.md
192
178
  - lib/ms/graph/mailer.rb
193
179
  - lib/ms/graph/mailer/delivery_method.rb
180
+ - lib/ms/graph/mailer/errors.rb
194
181
  - lib/ms/graph/mailer/token_service.rb
195
182
  - lib/ms/graph/mailer/version.rb
196
183
  homepage: https://github.com/zauberware/ms-graph-mailer