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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +18 -0
- data/lib/ms/graph/mailer/delivery_method.rb +40 -13
- data/lib/ms/graph/mailer/errors.rb +62 -0
- data/lib/ms/graph/mailer/version.rb +1 -1
- data/lib/ms/graph/mailer.rb +7 -6
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 469b5d7a57ff7c226a55b9e73cd2995ee229f9f22824e9155585d905c66b5887
|
|
4
|
+
data.tar.gz: 4a358f5c1ea87954bfa99169a41faa6f226d2fc80a054dede48ced754b32a6ac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
data/lib/ms/graph/mailer.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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:
|
|
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
|