ms-graph-mailer 1.0.0 → 1.1.0

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: 469b5d7a57ff7c226a55b9e73cd2995ee229f9f22824e9155585d905c66b5887
4
+ data.tar.gz: 4a358f5c1ea87954bfa99169a41faa6f226d2fc80a054dede48ced754b32a6ac
5
5
  SHA512:
6
- metadata.gz: c9e4732a3646a088d83974f85f48403be2ea34c5c48d0d14626ac6b6e371dd860e91577692940c40cba0ed70641556c224f0acce11c92b761d0b721a76afa902
7
- data.tar.gz: 3135ace13bea41f6f31dae45dbbc8bfcec1870be5b47ff62995c81fe8340427d55ce10c5610f302d5d1b5c978d3f5fb85d3ee906756e76b5eb6bebf0252c158f
6
+ metadata.gz: fa851676f45ced636be7c356cf7f33dc36ecc2685d9fb6662692756b0fdcae2fbb908f30b5b9908c08a88192c2f1858e78b1bf23d982e6034b9e25b3373245c7
7
+ data.tar.gz: 2c9a35d08b4579a65bd9c1596d0074cf21368f596c0a5a18362d4136e40981a1e521ad89a88b6bf25f5a6e8059ae9e67149c9b72cccdffdabcd1be6e78afa9ed
data/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ 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.0] - 2026-11-06
9
+
10
+ ### Added
11
+
12
+ - `MsGraphMailer::DeliveryError` exposes `status`, `code`, `retry_after`, `request_id` and
13
+ `client_request_id`, plus `throttled?` (429/503 — rejected, safe to retry) and
14
+ `outcome_unknown?` (other 5xx and network errors — the mail may have been sent).
15
+ - Every sendMail request carries a generated `client-request-id`; request ids and
16
+ `Retry-After` are logged on success and failure.
17
+ - `open_timeout` / `timeout` configuration options for the sendMail request.
18
+
19
+ ### Changed
20
+
21
+ - Error classes moved to `lib/ms/graph/mailer/errors.rb`. Error messages are unchanged.
22
+
8
23
  ## [1.0.0] - 2025-12-15
9
24
 
10
25
  ### Added
@@ -43,5 +58,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
43
58
  - Configuration system for tenant, client credentials
44
59
  - Integration with Rails logger
45
60
 
61
+ [1.1.0]: https://github.com/zauberware/ms-graph-mailer/releases/tag/v1.1.0
46
62
  [1.0.0]: https://github.com/zauberware/ms-graph-mailer/releases/tag/v1.0.0
47
63
  [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:
@@ -28,31 +28,56 @@ module MsGraphMailer
28
28
  saveToSentItems: true
29
29
  }
30
30
 
31
+ # Sent with the request and echoed back by Graph, so a failure can be
32
+ # traced with Microsoft even when no response arrives (timeouts).
33
+ client_request_id = SecureRandom.uuid
34
+
31
35
  response = connection(token_result.value!).post("users/#{CGI.escape(sender_email)}/sendMail") do |req|
32
36
  req.headers['Content-Type'] = 'application/json'
37
+ req.headers['client-request-id'] = client_request_id
38
+ req.headers['return-client-request-id'] = 'true'
33
39
  req.body = message_payload.to_json
34
40
  end
35
41
 
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
42
+ raise build_delivery_error(response, client_request_id) unless response.status == 202
46
43
 
47
- logger.info('[MsGraphMailer] Email sent successfully via Microsoft Graph')
44
+ logger.info('[MsGraphMailer] Email sent successfully via Microsoft Graph ' \
45
+ "[request-id: #{response.headers['request-id']}] [client-request-id: #{client_request_id}]")
48
46
  response
49
47
  rescue Faraday::Error => e
50
- logger.error("[MsGraphMailer] Network error: #{e.message}")
51
- raise DeliveryError, "Network error sending email: #{e.message}"
48
+ logger.error("[MsGraphMailer] Network error: #{e.message} [client-request-id: #{client_request_id}]")
49
+ raise DeliveryError.new("Network error sending email: #{e.message}",
50
+ client_request_id: client_request_id, network_error: true)
52
51
  end
53
52
 
54
53
  private
55
54
 
55
+ def build_delivery_error(response, client_request_id)
56
+ error = parse_error_body(response.body)
57
+ request_id = response.headers['request-id']
58
+ retry_after = DeliveryError.parse_retry_after(response.headers['Retry-After'])
59
+
60
+ logger.error("[MsGraphMailer] sendMail failed: [status: #{response.status}] [code: #{error['code']}] " \
61
+ "[request-id: #{request_id}] [client-request-id: #{client_request_id}] " \
62
+ "[retry-after: #{retry_after.inspect}] - [body: #{response.body}]")
63
+
64
+ DeliveryError.new(
65
+ "Microsoft Graph sendEmail failed with status #{response.status}: #{error['message'] || 'Unknown error'}",
66
+ status: response.status,
67
+ code: error['code'],
68
+ retry_after: retry_after,
69
+ request_id: request_id,
70
+ client_request_id: client_request_id
71
+ )
72
+ end
73
+
74
+ def parse_error_body(body)
75
+ parsed = JSON.parse(body.to_s)
76
+ parsed.is_a?(Hash) && parsed['error'].is_a?(Hash) ? parsed['error'] : {}
77
+ rescue JSON::ParserError
78
+ {}
79
+ end
80
+
56
81
  def validate_configuration!
57
82
  config = MsGraphMailer.configuration
58
83
  return if config&.valid?
@@ -133,6 +158,8 @@ module MsGraphMailer
133
158
  def connection(access_token)
134
159
  @connection ||= Faraday.new(url: 'https://graph.microsoft.com/v1.0/') do |f|
135
160
  f.headers['Authorization'] = "Bearer #{access_token}"
161
+ f.options.open_timeout = MsGraphMailer.configuration&.open_timeout
162
+ f.options.timeout = MsGraphMailer.configuration&.timeout
136
163
  f.adapter Faraday.default_adapter
137
164
  # SSL verification configuration
138
165
  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.0'
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.0
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
@@ -191,6 +191,7 @@ files:
191
191
  - README.md
192
192
  - lib/ms/graph/mailer.rb
193
193
  - lib/ms/graph/mailer/delivery_method.rb
194
+ - lib/ms/graph/mailer/errors.rb
194
195
  - lib/ms/graph/mailer/token_service.rb
195
196
  - lib/ms/graph/mailer/version.rb
196
197
  homepage: https://github.com/zauberware/ms-graph-mailer