mailkube-rails 1.0.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 +7 -0
- data/LICENSE +201 -0
- data/NOTICE +11 -0
- data/README.md +198 -0
- data/lib/mailkube/rails/config.rb +101 -0
- data/lib/mailkube/rails/delivery_method.rb +60 -0
- data/lib/mailkube/rails/errors.rb +31 -0
- data/lib/mailkube/rails/payload.rb +169 -0
- data/lib/mailkube/rails/railtie.rb +45 -0
- data/lib/mailkube/rails/version.rb +13 -0
- data/lib/mailkube/rails/webhooks_controller.rb +109 -0
- data/lib/mailkube/rails.rb +33 -0
- data/sig/mailkube/rails.rbs +63 -0
- metadata +116 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f536b33be8802fb5082f7ff0728c24cdd6e0b236a8b1f1f9be280458a33ea7c9
|
|
4
|
+
data.tar.gz: a644b374eae8bccaa8efd2ab896ee3adec93e57a46857533ae66c7f20e32dcd5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dcf1c48032b57f0646d8ffb76cf3d4442176af146a8a2bcb54012dec515ccd36adc1365ead40bd48eceddab10816a14180c1274e9d75ec04feb791ad6502f1a6
|
|
7
|
+
data.tar.gz: a100a9dc37811df1eb70c9f42a5e15c70437a1a79f9f235f427e8e0a03b75adc46274b85f0aee49bdecf6d84c04c27f0ce0ff8104aef69705479e8af0be2a231
|
data/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or Derivative
|
|
95
|
+
Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 Mailtactic, Corp.
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
data/NOTICE
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
mailkube-rails
|
|
2
|
+
Copyright 2026 Mailtactic, Corp.
|
|
3
|
+
|
|
4
|
+
This product is licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this product except in compliance with the License. You may
|
|
6
|
+
obtain a copy of the License at:
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
"mailkube" is a trademark of Mailtactic, Corp. Use of the name is governed by
|
|
11
|
+
Section 6 (Trademarks) of the License.
|
data/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# mailkube-rails
|
|
2
|
+
|
|
3
|
+
[](https://github.com/mailkube/mailkube-rails/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/mailkube-rails)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](CODE_OF_CONDUCT.md)
|
|
7
|
+
|
|
8
|
+
ActionMailer delivery method for mailkube.
|
|
9
|
+
|
|
10
|
+
Send mail through mailkube using ActionMailer exactly as you already do, and receive webhooks as
|
|
11
|
+
`ActiveSupport::Notifications`. This gem is a thin adapter over the
|
|
12
|
+
[`mailkube`](https://rubygems.org/gems/mailkube) gem: the API, retries,
|
|
13
|
+
errors and signature verification all live there.
|
|
14
|
+
|
|
15
|
+
Requires Ruby 3.4+ and Rails 7.2.3+.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# Gemfile
|
|
21
|
+
gem "mailkube-rails"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then point ActionMailer at it:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# config/environments/production.rb
|
|
28
|
+
config.action_mailer.delivery_method = :mailkube
|
|
29
|
+
config.action_mailer.mailkube_settings = {
|
|
30
|
+
api_key: ENV.fetch("MAILKUBE_API_KEY")
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That is the whole setup. Everything else on this page is optional.
|
|
35
|
+
|
|
36
|
+
## Sending
|
|
37
|
+
|
|
38
|
+
Nothing about your application changes. Mailers, `deliver_now`, `deliver_later`, attachments,
|
|
39
|
+
`ActionMailer::Base.deliveries` in tests: all of it works as it already did.
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
class OrderMailer < ApplicationMailer
|
|
43
|
+
def shipped(order)
|
|
44
|
+
mail(to: order.customer_email, subject: "Your order shipped")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
OrderMailer.shipped(order).deliver_later
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### What is mapped
|
|
52
|
+
|
|
53
|
+
The sender, recipients (including blind copies), subject, both bodies, attachments and your custom
|
|
54
|
+
headers. Anything ActionMailer's message can express, this delivery method passes through.
|
|
55
|
+
|
|
56
|
+
Send-time features the SDK offers but `Mail::Message` has no slot for — tags, topics, templates,
|
|
57
|
+
scheduling, idempotency keys — are reached by using the SDK directly:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
Mailkube.new.emails.send(
|
|
61
|
+
from: "Acme <hello@yourdomain.com>",
|
|
62
|
+
to: "customer@example.com",
|
|
63
|
+
subject: "Hello world",
|
|
64
|
+
html: "<p>It works!</p>",
|
|
65
|
+
tags: [Mailkube::Tag.new(name: "campaign", value: "spring")]
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
> **Inline images degrade to ordinary attachments.** The API's attachment model carries a filename,
|
|
70
|
+
> content and content type, with no content id, so a `cid:` reference has nothing to resolve
|
|
71
|
+
> against. This is a capability of the platform rather than of this gem.
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Two homes, and they do not overlap, so there is nothing to resolve between them.
|
|
76
|
+
|
|
77
|
+
| Setting | Where | Environment fallback | Default |
|
|
78
|
+
|---|---|---|---|
|
|
79
|
+
| API key | `config.action_mailer.mailkube_settings[:api_key]` | `MAILKUBE_API_KEY` | required |
|
|
80
|
+
| Base URL | `config.action_mailer.mailkube_settings[:base_url]` | `MAILKUBE_BASE_URL` | the SDK's |
|
|
81
|
+
| Timeout | `config.action_mailer.mailkube_settings[:timeout]` | | the SDK's |
|
|
82
|
+
| Webhook secret | `config.mailkube.webhook_secret` | | required for webhooks |
|
|
83
|
+
| Webhook tolerance | `config.mailkube.webhook_tolerance` | | the SDK's |
|
|
84
|
+
|
|
85
|
+
An unset setting is **omitted** rather than passed along as nil, so the SDK's own environment
|
|
86
|
+
resolution still applies. That is why the API key can come from `MAILKUBE_API_KEY`
|
|
87
|
+
alone with no Rails configuration at all.
|
|
88
|
+
|
|
89
|
+
### Two accounts, two mailers
|
|
90
|
+
|
|
91
|
+
ActionMailer resolves the delivery method per message, so overriding the settings on one mailer
|
|
92
|
+
gives it its own client:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class MarketingMailer < ApplicationMailer
|
|
96
|
+
self.mailkube_settings = { api_key: ENV.fetch("MARKETING_API_KEY") }
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Errors
|
|
101
|
+
|
|
102
|
+
A failed delivery raises `Mailkube::Rails::DeliveryError`, with the SDK's own exception preserved as
|
|
103
|
+
`cause`.
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
class OrderMailer < ApplicationMailer
|
|
107
|
+
retry_on Mailkube::Rails::DeliveryError, wait: :polynomially_longer, attempts: 5
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The class this gem owns is what you name, deliberately: naming an SDK class would tie your retry
|
|
112
|
+
policy to the SDK's exception hierarchy, and it would break the day that hierarchy was reorganized.
|
|
113
|
+
For the detail — which error, which status, whether it is safe to retry — read `cause`, where the
|
|
114
|
+
SDK defines the categories once:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
rescue Mailkube::Rails::DeliveryError => e
|
|
118
|
+
Rails.logger.warn(e.cause.error_name) # e.g. "quota_exceeded", with status_code and request_id
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`config.action_mailer.raise_delivery_errors = false` still swallows failures exactly as it does for
|
|
123
|
+
any other delivery method.
|
|
124
|
+
|
|
125
|
+
## Webhooks
|
|
126
|
+
|
|
127
|
+
You write the route. A mountable engine would force a path on your application and drag generator
|
|
128
|
+
machinery along for one endpoint, so this gem ships the controller and lets you mount it where you
|
|
129
|
+
want:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
# config/initializers/mailkube.rb
|
|
133
|
+
Rails.application.config.mailkube.webhook_secret = ENV.fetch("MAILKUBE_WEBHOOK_SECRET")
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
# config/routes.rb
|
|
138
|
+
require "mailkube/rails/webhooks_controller"
|
|
139
|
+
|
|
140
|
+
Rails.application.routes.draw do
|
|
141
|
+
post "/webhooks/mailkube",
|
|
142
|
+
to: Mailkube::Rails::WebhooksController.action(:create)
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The `require` is needed and the `.action(:create)` form is deliberate: a gem's `lib/` is on the load
|
|
147
|
+
path but is not an autoload path, so the `"controller#action"` string form would name a constant
|
|
148
|
+
Zeitwerk has never been told about.
|
|
149
|
+
|
|
150
|
+
Deliveries are verified against the **raw** request body and published as one notification:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
# config/initializers/mailkube.rb
|
|
154
|
+
ActiveSupport::Notifications.subscribe("webhook.mailkube") do |*, payload|
|
|
155
|
+
event = payload[:event]
|
|
156
|
+
|
|
157
|
+
case event
|
|
158
|
+
when Mailkube::Events::EmailDeliveredEvent then Rails.logger.info("delivered #{event.data.email_id}")
|
|
159
|
+
when Mailkube::Events::EmailBouncedEvent then Suppression.add(event.data.email_id)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
One notification rather than one per webhook type, so an event this SDK release does not model still
|
|
165
|
+
reaches your subscriber with its payload intact instead of being dropped.
|
|
166
|
+
|
|
167
|
+
Subscribers run **inside the request**, and the endpoint answers as soon as they return. Enqueue
|
|
168
|
+
anything slow, or delivery latency becomes retries.
|
|
169
|
+
|
|
170
|
+
The endpoint answers `204` on success, `400` when verification fails, and `500` when no secret is
|
|
171
|
+
configured — the last so that the platform keeps retrying rather than discarding deliveries while
|
|
172
|
+
your configuration is broken. CSRF is skipped on this controller because a machine-to-machine POST
|
|
173
|
+
cannot present a token; the signature is what authenticates the request, and it is strictly
|
|
174
|
+
stronger. Any middleware that decodes and re-encodes the request body breaks verification: it no
|
|
175
|
+
longer covers the bytes that were signed.
|
|
176
|
+
|
|
177
|
+
## Extending this gem
|
|
178
|
+
|
|
179
|
+
Before adding a setting, a mapped field, or another entry point, read
|
|
180
|
+
[`.rules/INTEGRATION_CONTRACT.md`](.rules/INTEGRATION_CONTRACT.md) (what every mailkube framework
|
|
181
|
+
integration does identically) and [`.rules/RAILS_INTEGRATION.md`](.rules/RAILS_INTEGRATION.md)
|
|
182
|
+
(how those rules land here). Both carry a checklist.
|
|
183
|
+
|
|
184
|
+
The short version: the capability has to exist in the SDK first, inputs are mapped in `payload.rb`
|
|
185
|
+
and never at a call site, and configuration goes through `config.rb`.
|
|
186
|
+
|
|
187
|
+
This gem ships no `examples/` directory, deliberately: every entry point it exposes needs a host
|
|
188
|
+
Rails application before it can run. This README is the wiring documentation, and the spec suite is
|
|
189
|
+
what keeps it honest.
|
|
190
|
+
|
|
191
|
+
## Contributing
|
|
192
|
+
|
|
193
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup and the quality gates every change
|
|
194
|
+
must pass. Security issues: see [SECURITY.md](SECURITY.md).
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
[Apache-2.0](LICENSE) © 2026 Mailtactic, Corp.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
module Rails
|
|
5
|
+
# The one place this gem turns Rails settings into SDK constructor arguments.
|
|
6
|
+
#
|
|
7
|
+
# Everything that reaches the SDK goes through {build_client}: the delivery method, the webhook
|
|
8
|
+
# endpoint, and anything added later. Two call sites building their own client is how one of
|
|
9
|
+
# them ends up on a different base URL, or without the User-Agent suffix, and the difference
|
|
10
|
+
# shows up as a support question rather than as a failing test.
|
|
11
|
+
#
|
|
12
|
+
# ## Two homes, and no precedence rules
|
|
13
|
+
#
|
|
14
|
+
# Settings live in the two places Rails already puts these kinds of settings, and they do not
|
|
15
|
+
# overlap, so there is nothing to resolve between them:
|
|
16
|
+
#
|
|
17
|
+
# - `config.action_mailer.mailkube_settings` — the delivery credentials. This hash is
|
|
18
|
+
# what `add_delivery_method` creates, and ActionMailer hands it to the delivery method for
|
|
19
|
+
# every message, so it is the idiomatic home and needs no invention.
|
|
20
|
+
# - `config.mailkube` — the webhook secret and freshness window, on the
|
|
21
|
+
# `ActiveSupport::OrderedOptions` the Railtie installs. These are not delivery settings and
|
|
22
|
+
# putting them under `action_mailer` would misfile them.
|
|
23
|
+
module Config
|
|
24
|
+
# SDK constructor keywords, mapped to the settings key that answers each.
|
|
25
|
+
#
|
|
26
|
+
# One list, so a setting cannot be readable in the delivery method and quietly ignored in
|
|
27
|
+
# a startup check. Adding an SDK keyword is a row here and nothing else.
|
|
28
|
+
CLIENT_KEYS = { api_key: :api_key, base_url: :base_url, timeout: :timeout }.freeze
|
|
29
|
+
|
|
30
|
+
# Build an SDK client from a delivery-method settings hash.
|
|
31
|
+
#
|
|
32
|
+
# @param settings [Hash{Symbol => Object}, nil] the `mailkube_settings` hash.
|
|
33
|
+
# @return [Mailkube::Client] the client, carrying this gem's User-Agent suffix.
|
|
34
|
+
# @raise [Mailkube::ConfigurationError] when no API key is available anywhere.
|
|
35
|
+
def self.build_client(settings)
|
|
36
|
+
# Splatted as keywords: a setting this gem did not resolve is simply not passed, which is
|
|
37
|
+
# what leaves the SDK's own default and its environment fallback in charge. Passing an
|
|
38
|
+
# explicit nil would suppress that fallback while looking like configuration.
|
|
39
|
+
Mailkube::Client.new(**client_kwargs(settings), user_agent_suffix: user_agent_suffix)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Resolve the SDK keywords that are actually set, dropping the rest.
|
|
43
|
+
#
|
|
44
|
+
# @param settings [Hash{Symbol => Object}, nil] the `mailkube_settings` hash.
|
|
45
|
+
# @return [Hash{Symbol => Object}] the keywords to pass to the SDK.
|
|
46
|
+
def self.client_kwargs(settings)
|
|
47
|
+
given = settings || {}
|
|
48
|
+
kwargs = {} #: Hash[Symbol, untyped]
|
|
49
|
+
CLIENT_KEYS.each do |keyword, key|
|
|
50
|
+
value = given[key]
|
|
51
|
+
# An empty string is treated as unset. It is what an unset `ENV["..."]` interpolated
|
|
52
|
+
# into an initializer produces, and passing it through would defeat the SDK's fallback
|
|
53
|
+
# while looking like a configured value.
|
|
54
|
+
next if value.nil? || value == ""
|
|
55
|
+
|
|
56
|
+
kwargs[keyword] = value
|
|
57
|
+
end
|
|
58
|
+
kwargs
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# This gem's own `name/version` token for the SDK's User-Agent.
|
|
62
|
+
#
|
|
63
|
+
# Read from {VERSION} rather than written here. A literal would be a second source of truth
|
|
64
|
+
# that the release process does not update, so it would go stale on the first release and
|
|
65
|
+
# stay wrong for every one after it. The SDK's own token stays leading, so the result is
|
|
66
|
+
# `mailkube/1.1.0 mailkube-rails/0.1.0`.
|
|
67
|
+
#
|
|
68
|
+
# @return [String] the suffix token.
|
|
69
|
+
def self.user_agent_suffix = "mailkube-rails/#{VERSION}"
|
|
70
|
+
|
|
71
|
+
# The webhook signing secret, or nil when none is configured.
|
|
72
|
+
#
|
|
73
|
+
# @param app_config [Object] an object responding to `mailkube`, normally `::Rails.application.config`.
|
|
74
|
+
# @return [String, nil] the secret, or nil.
|
|
75
|
+
def self.webhook_secret(app_config)
|
|
76
|
+
value = options(app_config).webhook_secret
|
|
77
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The signature freshness tolerance, or nil to accept the SDK's documented default.
|
|
81
|
+
#
|
|
82
|
+
# @param app_config [Object] an object responding to `mailkube`.
|
|
83
|
+
# @return [Integer, nil] the window in seconds, or nil.
|
|
84
|
+
def self.webhook_tolerance(app_config)
|
|
85
|
+
value = options(app_config).webhook_tolerance
|
|
86
|
+
value.nil? ? nil : Integer(value)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The gem's own options block, tolerating an application that never set one.
|
|
90
|
+
#
|
|
91
|
+
# `ActiveSupport::OrderedOptions` answers nil for any unset key, so this returns an empty one
|
|
92
|
+
# rather than nil and every reader above stays a single expression.
|
|
93
|
+
#
|
|
94
|
+
# @param app_config [Object] an object responding to `mailkube`.
|
|
95
|
+
# @return [ActiveSupport::OrderedOptions] the options block.
|
|
96
|
+
def self.options(app_config)
|
|
97
|
+
app_config.mailkube || ActiveSupport::OrderedOptions.new
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
module Rails
|
|
5
|
+
# The ActionMailer delivery method: the outbound entry point, and error translation.
|
|
6
|
+
#
|
|
7
|
+
# ## The protocol this implements
|
|
8
|
+
#
|
|
9
|
+
# `Mail` instantiates a delivery method **once per message**, with one positional settings
|
|
10
|
+
# hash (`mail/message.rb`: `lookup_delivery_method(method).new(settings)`), then calls
|
|
11
|
+
# `deliver!(mail)`. Two consequences shape this class:
|
|
12
|
+
#
|
|
13
|
+
# - `settings` must be readable back off the instance. `Mail::Message#deliver!` evaluates
|
|
14
|
+
# `delivery_method.settings[:return_response]`, so a delivery method whose `settings` is nil
|
|
15
|
+
# raises there rather than delivering. It is a real requirement, not a convention.
|
|
16
|
+
# - The instance is per-message and short-lived, so the SDK client is memoized on it and that
|
|
17
|
+
# is the whole lifecycle. There is deliberately no open/close pair: the SDK client is frozen
|
|
18
|
+
# after construction and holds no pool to release, so a lifecycle module here would be
|
|
19
|
+
# ceremony modelled on a protocol Rails does not have.
|
|
20
|
+
class DeliveryMethod
|
|
21
|
+
# @return [Hash{Symbol => Object}] the settings ActionMailer built this instance with.
|
|
22
|
+
attr_reader :settings
|
|
23
|
+
|
|
24
|
+
# @param settings [Hash{Symbol => Object}, nil] the `mailkube_settings` hash.
|
|
25
|
+
def initialize(settings = {})
|
|
26
|
+
@settings = settings || {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Deliver one message.
|
|
30
|
+
#
|
|
31
|
+
# @param message [Mail::Message] the message to send.
|
|
32
|
+
# @return [Mailkube::Email] the SDK's accepted-send result.
|
|
33
|
+
# @raise [DeliveryError] when the SDK reports any failure.
|
|
34
|
+
def deliver!(message)
|
|
35
|
+
fields = Payload.build(message)
|
|
36
|
+
# The three required keywords are named rather than left inside the splat. Steep cannot
|
|
37
|
+
# prove a Hash carries them, so a bare `**fields` would report them missing — and silencing
|
|
38
|
+
# that would silence the one check worth having here, which is that this gem still calls
|
|
39
|
+
# the SDK the way the SDK declares. A renamed keyword has to be a red build, not a
|
|
40
|
+
# production TypeError inside somebody's mailer.
|
|
41
|
+
client.emails.send(from: fields[:from], to: fields[:to], subject: fields[:subject],
|
|
42
|
+
**fields.except(:from, :to, :subject))
|
|
43
|
+
rescue Mailkube::Error => e
|
|
44
|
+
# Translated at the boundary, with the SDK error kept as `cause` so nothing is lost. See
|
|
45
|
+
# DeliveryError for why this matters even though `do_delivery` already rescues broadly.
|
|
46
|
+
raise DeliveryError, e.message
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The SDK client for this delivery, built once.
|
|
50
|
+
#
|
|
51
|
+
# Lazy rather than built in the constructor: ActionMailer instantiates a delivery method
|
|
52
|
+
# while wrapping a message even when the message is never delivered — `Mail::TestMailer`
|
|
53
|
+
# substitution, `deliver_later` handing off to a job, an interceptor that aborts — and a
|
|
54
|
+
# missing API key must not raise on any of those paths.
|
|
55
|
+
#
|
|
56
|
+
# @return [Mailkube::Client] the client.
|
|
57
|
+
def client = @client ||= Config.build_client(@settings)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
module Rails
|
|
5
|
+
# Raised when a delivery fails, wrapping the SDK error that caused it.
|
|
6
|
+
#
|
|
7
|
+
# **One class, and deliberately no subclasses.** Re-encoding the SDK's status taxonomy here
|
|
8
|
+
# would mean deciding what an HTTP 429 means, which the contract puts in the SDK's repository.
|
|
9
|
+
# The SDK's own exception is preserved as `cause`, so a caller that wants the category rescues
|
|
10
|
+
# it there, where the categories are defined once.
|
|
11
|
+
#
|
|
12
|
+
# ## Why translate at all
|
|
13
|
+
#
|
|
14
|
+
# Not for silent failure: `Mail::Message#do_delivery` rescues `StandardError` gated on
|
|
15
|
+
# `raise_delivery_errors`, so *any* exception class is already swallowed when an application
|
|
16
|
+
# asks for that. The reason is `deliver_later`. A queued delivery runs inside
|
|
17
|
+
# `ActionMailer::MailDeliveryJob`, and an application's `retry_on` / `discard_on` has to name a
|
|
18
|
+
# class. Naming an SDK class would make that application's retry policy break the day the SDK
|
|
19
|
+
# reorganized its hierarchy; naming this one means the SDK's taxonomy can change without it
|
|
20
|
+
# being a breaking change for consumers.
|
|
21
|
+
#
|
|
22
|
+
# This is the contract's "translate SDK errors into the framework's own error type" clause, in
|
|
23
|
+
# the one form Rails allows: ActionMailer ships no delivery-error class to translate into, so
|
|
24
|
+
# this gem defines the type consumers name.
|
|
25
|
+
#
|
|
26
|
+
# class OrderMailer < ApplicationMailer
|
|
27
|
+
# retry_on Mailkube::Rails::DeliveryError, wait: :polynomially_longer
|
|
28
|
+
# end
|
|
29
|
+
class DeliveryError < StandardError; end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
module Rails
|
|
5
|
+
# The one place a `Mail::Message` becomes SDK send arguments.
|
|
6
|
+
#
|
|
7
|
+
# Every entry point calls {build}; nothing else maps a message. A gem with two entry points and
|
|
8
|
+
# two mappings has two behaviours, and only one of them is the one the tests cover.
|
|
9
|
+
#
|
|
10
|
+
# Scope is deliberately what ActionMailer's message type natively expresses: sender,
|
|
11
|
+
# recipients, subject, both bodies, attachments and custom headers. Tags, topics, templates,
|
|
12
|
+
# scheduling and idempotency keys are SDK features with no `Mail::Message` slot, so inventing a
|
|
13
|
+
# side channel for them here would create a second surface to document and test. Reach them by
|
|
14
|
+
# calling the SDK directly; the README says so.
|
|
15
|
+
module Payload
|
|
16
|
+
# Header names ActionMailer derives from the message itself.
|
|
17
|
+
#
|
|
18
|
+
# These are re-created by the API from the structured fields this mapping already sends, so
|
|
19
|
+
# forwarding them as "custom" headers would contradict what the API builds. `content-type` is
|
|
20
|
+
# the one that matters: it describes a MIME document that is never transmitted, because this
|
|
21
|
+
# gem hands over fields rather than a rendered message.
|
|
22
|
+
RESERVED_HEADERS = %w[
|
|
23
|
+
bcc cc content-transfer-encoding content-type date from
|
|
24
|
+
message-id mime-version reply-to subject to
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# Convert a message into the keyword arguments for `client.emails.send`.
|
|
28
|
+
#
|
|
29
|
+
# @param message [Mail::Message] the message ActionMailer built.
|
|
30
|
+
# @return [Hash{Symbol => Object}] the SDK send keywords.
|
|
31
|
+
def self.build(message)
|
|
32
|
+
{
|
|
33
|
+
from: addresses(message, :from).first,
|
|
34
|
+
to: addresses(message, :to),
|
|
35
|
+
subject: message.subject.to_s,
|
|
36
|
+
html: body_for(message, "text/html"),
|
|
37
|
+
text: body_for(message, "text/plain"),
|
|
38
|
+
cc: presence(addresses(message, :cc)),
|
|
39
|
+
bcc: presence(addresses(message, :bcc)),
|
|
40
|
+
reply_to: presence(addresses(message, :reply_to)),
|
|
41
|
+
headers: presence(custom_headers(message)),
|
|
42
|
+
attachments: presence(attachments(message))
|
|
43
|
+
}.compact
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Render one address field as RFC strings, keeping display names.
|
|
47
|
+
#
|
|
48
|
+
# **Read through `message[field]`, not through `message.to_addrs`.** The `*_addrs` readers
|
|
49
|
+
# return BARE addresses, so a display name is already gone by the time this sees them and no
|
|
50
|
+
# amount of re-parsing brings it back. `Mail::Field#addrs` returns the parsed address objects,
|
|
51
|
+
# and `.format` renders each one as the application wrote it.
|
|
52
|
+
#
|
|
53
|
+
# `*_addrs` is also incomplete: `from_addrs`, `to_addrs`, `cc_addrs` and `bcc_addrs` exist but
|
|
54
|
+
# there is no `reply_to_addrs`, so that one reaches `method_missing` and raises. Going through
|
|
55
|
+
# the field object treats all five identically and cannot develop that asymmetry.
|
|
56
|
+
#
|
|
57
|
+
# @param message [Mail::Message] the message.
|
|
58
|
+
# @param field [Symbol] the header field name.
|
|
59
|
+
# @return [Array<String>] the formatted addresses, empty when the field is absent.
|
|
60
|
+
def self.addresses(message, field)
|
|
61
|
+
header = message[field]
|
|
62
|
+
return [] if header.nil?
|
|
63
|
+
|
|
64
|
+
# The annotation is what makes this checkable. `header` is untyped (the framework side is
|
|
65
|
+
# deliberately shallow in sig/vendor/), so without it the element type erases to `bot` and
|
|
66
|
+
# Steep rejects `&:format` while RuboCop insists on it — the two gates deadlock. Naming the
|
|
67
|
+
# type resolves both, and it is the one place this gem asserts what `Mail` hands back.
|
|
68
|
+
addrs = header.addrs #: Array[::Mail::Address]
|
|
69
|
+
addrs.map(&:format)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Extract one body part as decoded text.
|
|
73
|
+
#
|
|
74
|
+
# `.decoded`, never `.raw_source`: a quoted-printable or base64 body would otherwise ship its
|
|
75
|
+
# transfer encoding to the API verbatim, and the recipient would read `=3D` where an equals
|
|
76
|
+
# sign belongs.
|
|
77
|
+
#
|
|
78
|
+
# @param message [Mail::Message] the message.
|
|
79
|
+
# @param mime_type [String] the part's MIME type.
|
|
80
|
+
# @return [String, nil] the decoded body, or nil when the message has no such part.
|
|
81
|
+
def self.body_for(message, mime_type)
|
|
82
|
+
part = part_for(message, mime_type)
|
|
83
|
+
return nil if part.nil?
|
|
84
|
+
|
|
85
|
+
# `.decoded` undoes the transfer encoding but hands back ASCII-8BIT bytes, with the part's
|
|
86
|
+
# charset recorded separately. Passing those to JSON either raises on invalid byte sequences
|
|
87
|
+
# or ships mojibake, so the bytes are reunited with their declared charset here. `charset`
|
|
88
|
+
# can be absent on a bare message, and UTF-8 is the only sane assumption when it is.
|
|
89
|
+
presence(transcode(part.body.decoded, part.charset))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Reinterpret decoded bytes in the charset the part declared.
|
|
93
|
+
#
|
|
94
|
+
# @param body [String] the decoded bytes.
|
|
95
|
+
# @param charset [String, nil] the part's declared charset.
|
|
96
|
+
# @return [String] the body as UTF-8 text.
|
|
97
|
+
def self.transcode(body, charset)
|
|
98
|
+
body.dup.force_encoding(charset || "UTF-8").encode("UTF-8")
|
|
99
|
+
rescue ArgumentError, EncodingError
|
|
100
|
+
# An unknown or lying charset must not stop the send. The bytes go out as they arrived,
|
|
101
|
+
# which is what any non-transcoding mapping would have done anyway.
|
|
102
|
+
body
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Find the part carrying one MIME type, or nil.
|
|
106
|
+
#
|
|
107
|
+
# Split out of {body_for} so the branch has somewhere to return `untyped` from: written
|
|
108
|
+
# inline, the `if`/`elsif` has no `else` arm and Steep infers `bot` for the result, making the
|
|
109
|
+
# decode below unreachable in its eyes.
|
|
110
|
+
#
|
|
111
|
+
# @param message [Mail::Message] the message.
|
|
112
|
+
# @param mime_type [String] the part's MIME type.
|
|
113
|
+
# @return [Object, nil] the matching part, or nil.
|
|
114
|
+
def self.part_for(message, mime_type)
|
|
115
|
+
return message.all_parts.find { |part| part.mime_type == mime_type && !part.attachment? } if message.multipart?
|
|
116
|
+
return message if message.mime_type == mime_type
|
|
117
|
+
return message if mime_type == "text/plain" && message.mime_type.nil?
|
|
118
|
+
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Convert the message's attachments into SDK attachments.
|
|
123
|
+
#
|
|
124
|
+
# `.body.decoded` hands over the original bytes; the SDK base64-encodes them for the wire.
|
|
125
|
+
# Encoding here would send them twice over.
|
|
126
|
+
#
|
|
127
|
+
# @param message [Mail::Message] the message.
|
|
128
|
+
# @return [Array<Mailkube::Attachment>] the attachments.
|
|
129
|
+
def self.attachments(message)
|
|
130
|
+
message.attachments.map do |attachment|
|
|
131
|
+
Mailkube::Attachment.new(
|
|
132
|
+
filename: attachment.filename.to_s,
|
|
133
|
+
content: attachment.body.decoded,
|
|
134
|
+
content_type: attachment.mime_type
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Collect the headers the application set itself.
|
|
140
|
+
#
|
|
141
|
+
# @param message [Mail::Message] the message.
|
|
142
|
+
# @return [Hash{String => String}] the custom headers.
|
|
143
|
+
def self.custom_headers(message)
|
|
144
|
+
headers = {} #: Hash[String, String]
|
|
145
|
+
message.header.fields.each do |field|
|
|
146
|
+
next if RESERVED_HEADERS.include?(field.name.to_s.downcase)
|
|
147
|
+
|
|
148
|
+
headers[field.name.to_s] = field.value.to_s
|
|
149
|
+
end
|
|
150
|
+
headers
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Treat an empty string, array or hash as absent.
|
|
154
|
+
#
|
|
155
|
+
# An unset field is **omitted** from the send rather than passed as an empty value: the SDK
|
|
156
|
+
# drops nils before serializing, so this is what keeps an empty `cc` off the wire instead of
|
|
157
|
+
# sending `"cc": []`.
|
|
158
|
+
#
|
|
159
|
+
# @param value [Object, nil] the mapped value.
|
|
160
|
+
# @return [Object, nil] the value, or nil when it is empty.
|
|
161
|
+
def self.presence(value)
|
|
162
|
+
return nil if value.nil?
|
|
163
|
+
return nil if value.respond_to?(:empty?) && value.empty?
|
|
164
|
+
|
|
165
|
+
value
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Mailkube
|
|
6
|
+
module Rails
|
|
7
|
+
# Registers the delivery method, and the gem's own options block, at boot.
|
|
8
|
+
#
|
|
9
|
+
# Note `::Rails::Railtie`, fully qualified. Inside this namespace the bare constant
|
|
10
|
+
# `Rails` is Mailkube::Rails, so `Rails::Railtie` would be a NameError at load. Every
|
|
11
|
+
# reference to the framework in this gem is written this way.
|
|
12
|
+
class Railtie < ::Rails::Railtie
|
|
13
|
+
# The gem's own settings block, so `config.mailkube.webhook_secret = ...` works in an
|
|
14
|
+
# initializer. `OrderedOptions` answers nil for anything unset, which is what lets
|
|
15
|
+
# {Config} read a key an application never assigned.
|
|
16
|
+
config.mailkube = ActiveSupport::OrderedOptions.new
|
|
17
|
+
|
|
18
|
+
# `before: "action_mailer.set_configs"` is load-bearing, not tidiness.
|
|
19
|
+
#
|
|
20
|
+
# `add_delivery_method` is what DEFINES the `mailkube_settings` class attribute
|
|
21
|
+
# (`action_mailer/delivery_methods.rb`: `class_attribute :"#{symbol}_settings"`). Rails then
|
|
22
|
+
# applies everything under `config.action_mailer` by calling those writers, with a bare
|
|
23
|
+
# `options.each { |k, v| send("#{k}=", v) }` at the end of `action_mailer.set_configs`.
|
|
24
|
+
#
|
|
25
|
+
# Register after that and an application setting `config.action_mailer.mailkube_settings`
|
|
26
|
+
# gets `NoMethodError: undefined method 'mailkube_settings='` at boot, which reads as a
|
|
27
|
+
# typo in their own config rather than as an ordering bug in this gem. `spec/naming_spec.rb`
|
|
28
|
+
# pins the declaration.
|
|
29
|
+
#
|
|
30
|
+
# The `on_load` hook is what defers the work until ActionMailer is actually loaded: naming
|
|
31
|
+
# the constant directly here would force it, and an application that does not use
|
|
32
|
+
# ActionMailer would pay for it at every boot.
|
|
33
|
+
initializer "mailkube-rails.add_delivery_method", before: "action_mailer.set_configs" do
|
|
34
|
+
ActiveSupport.on_load(:action_mailer) do
|
|
35
|
+
# steep:ignore:start
|
|
36
|
+
# `self` inside this block is `ActionMailer::Base`, which the framework substitutes at
|
|
37
|
+
# load time. No signature can express that, and modelling ActionMailer::Base in
|
|
38
|
+
# sig/vendor/ purely to satisfy one call would be a large stub for no checking value.
|
|
39
|
+
add_delivery_method :mailkube, Mailkube::Rails::DeliveryMethod
|
|
40
|
+
# steep:ignore:end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
module Rails
|
|
5
|
+
# This gem's version, and the only place it is written.
|
|
6
|
+
#
|
|
7
|
+
# The gemspec reads it, and so does the User-Agent suffix, so a release cannot report one
|
|
8
|
+
# version to RubyGems and a different one to the API. It stays at 0.0.0 in the repository:
|
|
9
|
+
# semantic-release rewrites this line in the release runner and never commits it back. See
|
|
10
|
+
# `.rules/RELEASE.md`.
|
|
11
|
+
VERSION = "1.0.0"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_controller"
|
|
4
|
+
|
|
5
|
+
require_relative "config"
|
|
6
|
+
|
|
7
|
+
module Mailkube
|
|
8
|
+
module Rails
|
|
9
|
+
# The inbound entry point: verify a delivery, then publish it.
|
|
10
|
+
#
|
|
11
|
+
# This file is **not** required by `lib/mailkube/rails.rb`. It is reached through the route an
|
|
12
|
+
# application writes, so a worker that only sends mail never loads ActionController.
|
|
13
|
+
#
|
|
14
|
+
# ## No engine, deliberately
|
|
15
|
+
#
|
|
16
|
+
# A mountable engine would force a path on every host application and drag `isolate_namespace`
|
|
17
|
+
# plus generator machinery along for one endpoint. Consumers write two lines instead, which
|
|
18
|
+
# also means they choose the path, the constraints and the middleware:
|
|
19
|
+
#
|
|
20
|
+
# require "mailkube/rails/webhooks_controller"
|
|
21
|
+
# post "/webhooks/mailkube", to: Mailkube::Rails::WebhooksController.action(:create)
|
|
22
|
+
#
|
|
23
|
+
# The `.action(:create)` form rather than the `"controller#action"` string: a gem's `lib/` is
|
|
24
|
+
# on `$LOAD_PATH` but is not an autoload path, so the string form would resolve to a constant
|
|
25
|
+
# Zeitwerk has never been told about and raise at the first request. The explicit `require` is
|
|
26
|
+
# what makes the constant exist, and is the reason this file can stay off the boot path.
|
|
27
|
+
#
|
|
28
|
+
# This differs from the Laravel integration, where a package registering a route is completely
|
|
29
|
+
# idiomatic and the route is registered for you behind an off-by-default setting.
|
|
30
|
+
#
|
|
31
|
+
# ## It contains no cryptography
|
|
32
|
+
#
|
|
33
|
+
# Verification is the SDK's, in one call. This class adapts Rails' request object to it and
|
|
34
|
+
# publishes the result. See `.rules/INTEGRATION_CONTRACT.md`.
|
|
35
|
+
class WebhooksController < ::ActionController::Base
|
|
36
|
+
# The notification this controller publishes on a verified delivery.
|
|
37
|
+
NOTIFICATION = "webhook.mailkube"
|
|
38
|
+
|
|
39
|
+
# A webhook POST is machine-to-machine and cannot carry a CSRF token, so the check is skipped
|
|
40
|
+
# rather than left to reject every delivery. The signature is what authenticates the request,
|
|
41
|
+
# and it is strictly stronger: CSRF protection proves a browser session, while the HMAC
|
|
42
|
+
# proves the sender holds the endpoint secret.
|
|
43
|
+
skip_forgery_protection
|
|
44
|
+
|
|
45
|
+
# Receive one webhook delivery.
|
|
46
|
+
#
|
|
47
|
+
# Answers 204 on success, 400 when verification fails, and 500 when no secret is configured.
|
|
48
|
+
#
|
|
49
|
+
# @return [void]
|
|
50
|
+
def create
|
|
51
|
+
secret = Config.webhook_secret(::Rails.application.config)
|
|
52
|
+
# 500, not 4xx. The sender is behaving correctly and the fault is entirely local, so this
|
|
53
|
+
# must read as an outage: the platform then keeps retrying, and the deliveries that arrive
|
|
54
|
+
# while the secret is missing are not silently discarded.
|
|
55
|
+
return head(:internal_server_error) if secret.nil?
|
|
56
|
+
|
|
57
|
+
event = Mailkube::Webhooks.verify(
|
|
58
|
+
payload: request.raw_post, headers: header_hash, secret: secret, **tolerance_argument
|
|
59
|
+
)
|
|
60
|
+
ActiveSupport::Notifications.instrument(NOTIFICATION, event: event)
|
|
61
|
+
head :no_content
|
|
62
|
+
rescue Mailkube::SignatureVerificationError, Mailkube::Error
|
|
63
|
+
# A malformed or unverifiable delivery is the sender's problem and a retry cannot fix it,
|
|
64
|
+
# so it is refused rather than retried. The reason is deliberately not reported: an
|
|
65
|
+
# endpoint that distinguishes "bad signature" from "stale timestamp" is an oracle.
|
|
66
|
+
head :bad_request
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# The freshness window, as a splattable hash, or empty to accept the SDK's default.
|
|
72
|
+
#
|
|
73
|
+
# Only the optional argument travels in a splat: the three required ones are named at the
|
|
74
|
+
# call site so Steep checks them against the SDK's own signature. Omitted rather than passed
|
|
75
|
+
# as nil, because nil would be a value the SDK has to interpret rather than an absent one.
|
|
76
|
+
#
|
|
77
|
+
# @return [Hash{Symbol => Integer}] `{tolerance: n}`, or empty.
|
|
78
|
+
def tolerance_argument
|
|
79
|
+
tolerance = Config.webhook_tolerance(::Rails.application.config)
|
|
80
|
+
tolerance.nil? ? {} : { tolerance: tolerance }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Convert the Rack environment into the plain Hash the SDK reads headers from.
|
|
84
|
+
#
|
|
85
|
+
# **`request.headers` cannot be passed through**, which is worth stating because it is the
|
|
86
|
+
# obvious thing to write and it fails twice over. `ActionDispatch::Http::Headers` includes
|
|
87
|
+
# `Enumerable` and nothing else, so it has no `transform_keys` and the SDK's normalization
|
|
88
|
+
# raises `NoMethodError` on it. And its `each` delegates to the Rack environment, so
|
|
89
|
+
# converting it to a Hash yields `HTTP_X_WEBHOOK_ID` rather than `X-Webhook-Id` — which
|
|
90
|
+
# downcases to something the SDK's lookup will never match, turning every delivery into a
|
|
91
|
+
# signature failure with no clue as to why.
|
|
92
|
+
#
|
|
93
|
+
# The conversion is generic rather than a list of the three headers the SDK reads today.
|
|
94
|
+
# Naming them would put a copy of the signature scheme's header set in this repository, and
|
|
95
|
+
# the copy would silently stop working the day the scheme grows a fourth.
|
|
96
|
+
#
|
|
97
|
+
# @return [Hash{String => String}] the request headers, in `x-webhook-id` form.
|
|
98
|
+
def header_hash
|
|
99
|
+
headers = {} #: Hash[String, String]
|
|
100
|
+
request.env.each do |name, value|
|
|
101
|
+
next unless name.is_a?(String) && name.start_with?("HTTP_") && value.is_a?(String)
|
|
102
|
+
|
|
103
|
+
headers[name.delete_prefix("HTTP_").downcase.tr("_", "-")] = value
|
|
104
|
+
end
|
|
105
|
+
headers
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mailkube"
|
|
4
|
+
|
|
5
|
+
require_relative "rails/version"
|
|
6
|
+
require_relative "rails/errors"
|
|
7
|
+
require_relative "rails/config"
|
|
8
|
+
require_relative "rails/payload"
|
|
9
|
+
require_relative "rails/delivery_method"
|
|
10
|
+
require_relative "rails/railtie"
|
|
11
|
+
|
|
12
|
+
module Mailkube
|
|
13
|
+
# ActionMailer delivery for mailkube, and an inbound webhook endpoint.
|
|
14
|
+
#
|
|
15
|
+
# This module is a **thin adapter**. The wire format, authentication, retry policy, error
|
|
16
|
+
# taxonomy and webhook signature scheme all belong to the `mailkube` gem; nothing here
|
|
17
|
+
# re-implements any of them. See `.rules/INTEGRATION_CONTRACT.md`.
|
|
18
|
+
#
|
|
19
|
+
# Requiring this file loads the delivery half only. {WebhooksController} is deliberately NOT
|
|
20
|
+
# required here: it subclasses `ActionController::Base`, and a worker process that only sends
|
|
21
|
+
# mail should not be made to load ActionController. Applications that receive webhooks reach it
|
|
22
|
+
# through the route they write, which autoloads it.
|
|
23
|
+
#
|
|
24
|
+
# ## The naming trap
|
|
25
|
+
#
|
|
26
|
+
# Inside this namespace the bare constant `Rails` resolves to **this module**, not to the
|
|
27
|
+
# framework. Every reference to the framework is therefore written `::Rails`, fully
|
|
28
|
+
# qualified. Ruby resolves the wrong one silently and the result is a `NoMethodError` a long way
|
|
29
|
+
# from the cause, so this is the most likely first bug in any change here. `spec/naming_spec.rb`
|
|
30
|
+
# pins it.
|
|
31
|
+
module Rails
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Signatures for this gem's public surface.
|
|
2
|
+
#
|
|
3
|
+
# These are checked two ways, and both are needed:
|
|
4
|
+
# `rbs validate` proves the signatures are internally coherent (every type they name exists).
|
|
5
|
+
# `steep check` proves the implementation matches them.
|
|
6
|
+
# `rbs validate` alone never loads `lib/`, so it would happily pass a `sig/` describing methods
|
|
7
|
+
# that do not exist, which is the exact drift this directory is here to prevent.
|
|
8
|
+
#
|
|
9
|
+
# The framework's own types are declared in `sig/vendor/`, which is NOT shipped in the gem. See
|
|
10
|
+
# the gemspec, and `.rules/RAILS_INTEGRATION.md`.
|
|
11
|
+
module Mailkube
|
|
12
|
+
module Rails
|
|
13
|
+
VERSION: String
|
|
14
|
+
|
|
15
|
+
class DeliveryError < StandardError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module Config
|
|
19
|
+
CLIENT_KEYS: Hash[Symbol, Symbol]
|
|
20
|
+
|
|
21
|
+
def self.build_client: (Hash[Symbol, untyped]?) -> Mailkube::Client
|
|
22
|
+
def self.client_kwargs: (Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
|
|
23
|
+
def self.user_agent_suffix: () -> String
|
|
24
|
+
def self.webhook_secret: (untyped) -> String?
|
|
25
|
+
def self.webhook_tolerance: (untyped) -> Integer?
|
|
26
|
+
def self.options: (untyped) -> untyped
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module Payload
|
|
30
|
+
RESERVED_HEADERS: Array[String]
|
|
31
|
+
|
|
32
|
+
def self.build: (untyped) -> Hash[Symbol, untyped]
|
|
33
|
+
def self.addresses: (untyped, Symbol) -> Array[String]
|
|
34
|
+
def self.body_for: (untyped, String) -> String?
|
|
35
|
+
def self.transcode: (String, String?) -> String
|
|
36
|
+
def self.part_for: (untyped, String) -> untyped
|
|
37
|
+
def self.attachments: (untyped) -> Array[Mailkube::Attachment]
|
|
38
|
+
def self.custom_headers: (untyped) -> Hash[String, String]
|
|
39
|
+
def self.presence: (untyped) -> untyped
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class DeliveryMethod
|
|
43
|
+
@client: Mailkube::Client?
|
|
44
|
+
|
|
45
|
+
attr_reader settings: Hash[Symbol, untyped]
|
|
46
|
+
|
|
47
|
+
def initialize: (?Hash[Symbol, untyped]?) -> void
|
|
48
|
+
def deliver!: (untyped) -> Mailkube::Email
|
|
49
|
+
def client: () -> Mailkube::Client
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class Railtie < ::Rails::Railtie
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class WebhooksController < ::ActionController::Base
|
|
56
|
+
NOTIFICATION: String
|
|
57
|
+
|
|
58
|
+
def create: () -> void
|
|
59
|
+
def tolerance_argument: () -> Hash[Symbol, Integer]
|
|
60
|
+
def header_hash: () -> Hash[String, String]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mailkube-rails
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Mailtactic, Corp.
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: actionmailer
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 7.2.3
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 7.2.3
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: mailkube
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: 1.1.0
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '2'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: 1.1.0
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '2'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: railties
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: 7.2.3
|
|
59
|
+
- - "<"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '9'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 7.2.3
|
|
69
|
+
- - "<"
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '9'
|
|
72
|
+
description: 'ActionMailer delivery method for mailkube. A thin adapter over the mailkube
|
|
73
|
+
gem: deliver through ActionMailer, receive webhooks as ActiveSupport notifications.'
|
|
74
|
+
executables: []
|
|
75
|
+
extensions: []
|
|
76
|
+
extra_rdoc_files: []
|
|
77
|
+
files:
|
|
78
|
+
- LICENSE
|
|
79
|
+
- NOTICE
|
|
80
|
+
- README.md
|
|
81
|
+
- lib/mailkube/rails.rb
|
|
82
|
+
- lib/mailkube/rails/config.rb
|
|
83
|
+
- lib/mailkube/rails/delivery_method.rb
|
|
84
|
+
- lib/mailkube/rails/errors.rb
|
|
85
|
+
- lib/mailkube/rails/payload.rb
|
|
86
|
+
- lib/mailkube/rails/railtie.rb
|
|
87
|
+
- lib/mailkube/rails/version.rb
|
|
88
|
+
- lib/mailkube/rails/webhooks_controller.rb
|
|
89
|
+
- sig/mailkube/rails.rbs
|
|
90
|
+
homepage: https://github.com/mailkube/mailkube-rails
|
|
91
|
+
licenses:
|
|
92
|
+
- Apache-2.0
|
|
93
|
+
metadata:
|
|
94
|
+
source_code_uri: https://github.com/mailkube/mailkube-rails
|
|
95
|
+
changelog_uri: https://github.com/mailkube/mailkube-rails/releases
|
|
96
|
+
bug_tracker_uri: https://github.com/mailkube/mailkube-rails/issues
|
|
97
|
+
documentation_uri: https://rubydoc.info/gems/mailkube-rails
|
|
98
|
+
rubygems_mfa_required: 'true'
|
|
99
|
+
rdoc_options: []
|
|
100
|
+
require_paths:
|
|
101
|
+
- lib
|
|
102
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
103
|
+
requirements:
|
|
104
|
+
- - ">="
|
|
105
|
+
- !ruby/object:Gem::Version
|
|
106
|
+
version: '3.4'
|
|
107
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
108
|
+
requirements:
|
|
109
|
+
- - ">="
|
|
110
|
+
- !ruby/object:Gem::Version
|
|
111
|
+
version: '0'
|
|
112
|
+
requirements: []
|
|
113
|
+
rubygems_version: 3.6.9
|
|
114
|
+
specification_version: 4
|
|
115
|
+
summary: ActionMailer delivery method for mailkube.
|
|
116
|
+
test_files: []
|