mailgazelle 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/CHANGELOG.md +6 -0
- data/LICENSE +21 -0
- data/README.md +265 -0
- data/docs/api.md +183 -0
- data/lib/mailgazelle/address.rb +33 -0
- data/lib/mailgazelle/attachment.rb +158 -0
- data/lib/mailgazelle/client.rb +61 -0
- data/lib/mailgazelle/configuration.rb +16 -0
- data/lib/mailgazelle/domain.rb +66 -0
- data/lib/mailgazelle/domains_resource.rb +32 -0
- data/lib/mailgazelle/email.rb +291 -0
- data/lib/mailgazelle/emails/attachment_meta.rb +50 -0
- data/lib/mailgazelle/emails/email_record.rb +91 -0
- data/lib/mailgazelle/emails/email_status.rb +19 -0
- data/lib/mailgazelle/emails/queued_email.rb +41 -0
- data/lib/mailgazelle/emails_resource.rb +35 -0
- data/lib/mailgazelle/error.rb +69 -0
- data/lib/mailgazelle/error_mapper.rb +57 -0
- data/lib/mailgazelle/http/http_client.rb +84 -0
- data/lib/mailgazelle/http/net_http_transport.rb +55 -0
- data/lib/mailgazelle/http/request.rb +10 -0
- data/lib/mailgazelle/http/response.rb +8 -0
- data/lib/mailgazelle/mailer.rb +212 -0
- data/lib/mailgazelle/railtie.rb +14 -0
- data/lib/mailgazelle/version.rb +5 -0
- data/lib/mailgazelle.rb +49 -0
- metadata +72 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e3db3d2ef7db7297af73576fb689bec36b50040164b1a9aee85285940afa53ed
|
|
4
|
+
data.tar.gz: 9edad81d53947dc2ed0a3356ed5e018ccce2bf5dc792747644f74b616efde0aa
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 85f5da70c808884739954a9b9bea04844db89c967dd205490d1b96671ce9f4d6b49999b2dedb454256ebfba4960464079576ca05a66a639e67ac9501df062379
|
|
7
|
+
data.tar.gz: e9673cadefc6f9014e34bc023fed5dd957d22b438ca2f1ad1d006204a080af83814059cc7dce45a5410e852fdd927ce6195a0821931e01aa05d8dbb743cf1d94
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mailgazelle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# Mail Gazelle Ruby and Rails SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby client for the [Mail Gazelle](https://mailgazelle.com) transactional email API, with an ActionMailer delivery method for Rails.
|
|
4
|
+
|
|
5
|
+
A gem is a packaged Ruby library. Add this one to an application and that application can send mail through Mail Gazelle without talking to the HTTP API itself.
|
|
6
|
+
|
|
7
|
+
The HTTP contract lives in [docs/api.md](docs/api.md). Keep that file and this gem in the same change when endpoints, error codes, or attachment limits change.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Ruby 3.2 or newer
|
|
12
|
+
- A Mail Gazelle API token for a product that is ready to send (`tes_…`)
|
|
13
|
+
|
|
14
|
+
There are no runtime gem dependencies. Rails is not required. The ActionMailer delivery method loads only when the host application has already loaded Rails.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Add the gem to the application's `Gemfile`:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "mailgazelle"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then install:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bundle install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or install it for the whole machine, outside an application:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
gem install mailgazelle
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Create a client
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
require "mailgazelle"
|
|
40
|
+
|
|
41
|
+
client = MailGazelle::Client.new(api_token: ENV.fetch("MAILGAZELLE_API_TOKEN"))
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The default API root is `https://mailgazelle.com/api/v1`. Tokens are minted in the Mail Gazelle admin UI for a product that is ready to send. The raw token is shown once.
|
|
45
|
+
|
|
46
|
+
Reuse one client per token. Do not construct a new client for every send.
|
|
47
|
+
|
|
48
|
+
| Argument | Default | Purpose |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `api_token` | none | Bearer token (`tes_…`). Required. |
|
|
51
|
+
| `base_url` | `https://mailgazelle.com/api/v1` | Override the API root. A trailing slash is ignored. |
|
|
52
|
+
| `transport` | `Net::HTTP` | Inject an object that implements `perform` and returns a response. Useful in tests. |
|
|
53
|
+
| `timeout` | `30` seconds | Read and write timeout. |
|
|
54
|
+
| `connect_timeout` | `10` seconds | Connection timeout. |
|
|
55
|
+
| `user_agent_suffix` | none | Appended to `mailgazelle-ruby/{version}`. |
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
client = MailGazelle::Client.new(
|
|
59
|
+
api_token: ENV.fetch("MAILGAZELLE_API_TOKEN"),
|
|
60
|
+
user_agent_suffix: "MyApp/2.4"
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
You can also set process-wide defaults. `MailGazelle.client` builds one client from them and raises `MailGazelle::ApiTokenMissing` if the token is blank:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
MailGazelle.configure do |config|
|
|
68
|
+
config.api_token = ENV.fetch("MAILGAZELLE_API_TOKEN")
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Send email
|
|
73
|
+
|
|
74
|
+
Either HTML or plain text is required. Both may be sent together. `to` is one or more recipients. Together with `cc` and `bcc`, a message can include at most 50 addresses.
|
|
75
|
+
|
|
76
|
+
### Plain text
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
queued = client.emails.send(
|
|
80
|
+
MailGazelle::Email.to("user@example.com", "Ada")
|
|
81
|
+
.subject("Welcome")
|
|
82
|
+
.text("Thanks for signing up.")
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
queued.id # Mail Gazelle message id
|
|
86
|
+
queued.status # "queued"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`POST /emails` returns `202` immediately. The message is stored and queued. Delivery happens asynchronously.
|
|
90
|
+
|
|
91
|
+
### HTML, recipients, and attachments
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
client.emails.send(
|
|
95
|
+
MailGazelle::Email.to("user@example.com", "Ada")
|
|
96
|
+
.add_to("other@example.com")
|
|
97
|
+
.cc("billing@example.com")
|
|
98
|
+
.bcc("audit@example.com")
|
|
99
|
+
.subject("Invoice")
|
|
100
|
+
.html("<p>Your invoice is attached.</p><img src=\"cid:logo\">")
|
|
101
|
+
.text("Your invoice is attached.")
|
|
102
|
+
.from("notif@example.com", "App")
|
|
103
|
+
.reply_to("support@example.com")
|
|
104
|
+
.header("Message-ID", "<invoice-42@example.com>")
|
|
105
|
+
.tag("campaign", "welcome")
|
|
106
|
+
.idempotency_key("invoice-42")
|
|
107
|
+
.attach(MailGazelle::Attachment.from_path("/tmp/invoice.pdf"))
|
|
108
|
+
.attach(MailGazelle::Attachment.from_contents("logo.png", png_bytes, content_id: "logo"))
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`from` and `reply_to` are omitted when you do not set them, so the product defaults apply. A custom From address must use the product's primary or sending domain. `without_reply_to` sends `"reply_to": []`, which removes Reply-To. `bcc` is stored and delivered, and omitted from the visible MIME headers.
|
|
113
|
+
|
|
114
|
+
An idempotency key is unique per team. The same key returns the original message and does not send again. A replay is not checked against quota. The SDK does not retry failed requests. Set an idempotency key when a caller may repeat a send.
|
|
115
|
+
|
|
116
|
+
HTML must be 512 KB or smaller. The SDK checks that before it calls the API.
|
|
117
|
+
|
|
118
|
+
### Attachments
|
|
119
|
+
|
|
120
|
+
`Attachment.from_path` reads a file. `from_contents` takes raw bytes. `from_base64` takes content that is already base64-encoded.
|
|
121
|
+
|
|
122
|
+
Filenames must be a basename. Path segments are rejected. `content_type` is guessed from the filename when omitted. `content_id` marks an inline part. Reference it from HTML as `cid:{content_id}`. A leading `cid:` and surrounding angle brackets are stripped. Empty or duplicate ids are rejected.
|
|
123
|
+
|
|
124
|
+
The SDK enforces the 7 MB decoded platform ceiling. How many files a plan allows, and any lower size limit, are enforced by the API. The assembled raw MIME must be 10 MB or smaller (`message_too_large`).
|
|
125
|
+
|
|
126
|
+
### Fetch a message
|
|
127
|
+
|
|
128
|
+
`GET /emails/{id}` returns status, timestamps, the provider message id, the last event type, and attachment metadata. It does not return HTML, text, or file bytes.
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
record = client.emails.get(queued.id)
|
|
132
|
+
|
|
133
|
+
record.status # for example "queued" or "rejected"
|
|
134
|
+
record.known_status # "queued" or "rejected" when this gem knows the value, otherwise nil
|
|
135
|
+
record.message_id # provider id after accept, or nil
|
|
136
|
+
record.last_event_type
|
|
137
|
+
record.created_at
|
|
138
|
+
record.attachments # filename, content type, size
|
|
139
|
+
record.to_h # full payload, including fields added later
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### List domains
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
client.domains.list.each do |domain|
|
|
146
|
+
domain.name
|
|
147
|
+
domain.type
|
|
148
|
+
domain.verification_statuses
|
|
149
|
+
domain.to_h
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Rails
|
|
154
|
+
|
|
155
|
+
Add the gem to the Gemfile, then select the delivery method. Mailers stay normal ActionMailer classes. `deliver_now` and `deliver_now!` perform the HTTP call.
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# config/environments/production.rb
|
|
159
|
+
config.action_mailer.delivery_method = :mailgazelle
|
|
160
|
+
config.action_mailer.mailgazelle_settings = {
|
|
161
|
+
api_token: ENV.fetch("MAILGAZELLE_API_TOKEN")
|
|
162
|
+
# api_base: ENV["MAILGAZELLE_BASE_URL"] # optional
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`api_base`, `base_url`, `timeout`, and `connect_timeout` are optional. A token in `MailGazelle.configure` is used when the delivery settings do not include one. A missing token raises `MailGazelle::ApiTokenMissing` on the first send.
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
# app/mailers/user_mailer.rb
|
|
170
|
+
class UserMailer < ApplicationMailer
|
|
171
|
+
def welcome
|
|
172
|
+
mail(
|
|
173
|
+
to: "Ada <ada@example.com>",
|
|
174
|
+
subject: "Welcome",
|
|
175
|
+
tags: { campaign: "welcome" },
|
|
176
|
+
"MailGazelle-Idempotency-Key" => "welcome-42"
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
UserMailer.welcome.deliver_now!
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`MAIL_FROM` / `default from:` on the mailer is sent as `from` when present. Omit it to keep the product From. Reply-To on the message is sent as `reply_to`. Inline attachments (`attachments.inline`) include a content id.
|
|
187
|
+
|
|
188
|
+
`tags:` becomes the tags object. Names and values must match `[A-Za-z0-9_-]`. `team_id` and `product_id` are reserved.
|
|
189
|
+
|
|
190
|
+
`MailGazelle-Idempotency-Key` is not sent as a header. It becomes `idempotency_key`.
|
|
191
|
+
|
|
192
|
+
Custom headers are forwarded, except `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Sender`, `Subject`, `Content-Type`, `Content-Transfer-Encoding`, `Return-Path`, `MIME-Version`, and `Date`. `Message-ID` is forwarded.
|
|
193
|
+
|
|
194
|
+
`deliver_now!` returns the queued result because the delivery method asks Mail for the response. The Mail Gazelle id is also stored on the message as `X-MailGazelle-Message-Id`, and as the message id.
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
message = UserMailer.welcome.deliver_now!
|
|
198
|
+
message.id # Mail Gazelle message id
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Errors
|
|
202
|
+
|
|
203
|
+
All failures subclass `MailGazelle::Error`. Catch that type when you only need to know that a call failed. Use a subclass when the recovery path depends on the cause.
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
begin
|
|
207
|
+
client.emails.send(email)
|
|
208
|
+
rescue MailGazelle::RecipientSuppressedError
|
|
209
|
+
# Stored as rejected. Do not retry this address.
|
|
210
|
+
rescue MailGazelle::RateLimitError
|
|
211
|
+
# 60 requests per minute per token. Back off and retry later.
|
|
212
|
+
rescue MailGazelle::Error => error
|
|
213
|
+
error.message
|
|
214
|
+
error.code # API error code
|
|
215
|
+
error.http_status # 0 when no HTTP response was received
|
|
216
|
+
error.details
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The SDK validates documented limits before it calls the API. Local and remote failures of the same kind raise the same class. Plan-specific limits that are lower than the platform ceiling are left to the API.
|
|
221
|
+
|
|
222
|
+
| API `code` | Error class | Typical HTTP |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| `unauthenticated` | `AuthenticationError` | 401 |
|
|
225
|
+
| `product_not_ready` | `ProductNotReadyError` | 403 |
|
|
226
|
+
| `validation_error` | `ValidationError` | 422 |
|
|
227
|
+
| `from_not_allowed` | `FromNotAllowedError` | 422 |
|
|
228
|
+
| `recipient_suppressed` | `RecipientSuppressedError` | 422 |
|
|
229
|
+
| `attachment_invalid` | `AttachmentError` | 422 |
|
|
230
|
+
| `attachment_too_large` | `AttachmentTooLargeError` | 422 |
|
|
231
|
+
| `quota_exceeded` | `QuotaExceededError` | 422 |
|
|
232
|
+
| `html_too_large` | `HtmlTooLargeError` | 422 |
|
|
233
|
+
| `message_too_large` | `MessageTooLargeError` | 422 |
|
|
234
|
+
| `rate_limited` | `RateLimitError` | 429 |
|
|
235
|
+
|
|
236
|
+
`unauthenticated` also covers a token for an archived product. `recipient_suppressed` does not consume quota. `quota_exceeded` means the send would pass the team's monthly email allowance, daily send limit, or monthly attachment allowance. The message says which limit was hit.
|
|
237
|
+
|
|
238
|
+
Unknown codes become `ApiError`. Network, DNS, TLS, and timeout failures become `TransportError`.
|
|
239
|
+
|
|
240
|
+
## Development
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
bundle install
|
|
244
|
+
bundle exec rspec
|
|
245
|
+
bundle exec rubocop
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Tests stub HTTP. They do not call Mail Gazelle.
|
|
249
|
+
|
|
250
|
+
## Release
|
|
251
|
+
|
|
252
|
+
The published gem is [mailgazelle on RubyGems](https://rubygems.org/gems/mailgazelle). Shipping a version is a separate step from installing it.
|
|
253
|
+
|
|
254
|
+
1. Sign in at [rubygems.org](https://rubygems.org/sign_up) with the account that should own the gem, and turn on multi-factor authentication. This gemspec requires MFA for every push.
|
|
255
|
+
2. Create an API key at [rubygems.org/profile/api_keys](https://rubygems.org/profile/api_keys) with the **Push rubygem** scope. RubyGems shows the key once.
|
|
256
|
+
3. From this repository, with a clean git tree and the version you want in `lib/mailgazelle/version.rb`:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
gem build mailgazelle.gemspec
|
|
260
|
+
gem push mailgazelle-1.0.0.gem
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`gem push` asks for the API key the first time and stores it in `~/.gem/credentials`. The first successful push reserves the name `mailgazelle` for that account. A version that has been pushed cannot be uploaded again, including after a yank.
|
|
264
|
+
|
|
265
|
+
`bundle exec rake release` does the same push, and also creates a git tag and pushes it. Use that only after the commit you are releasing is already on `main`.
|
data/docs/api.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Mail Gazelle public API
|
|
2
|
+
|
|
3
|
+
Client integration contract for sending transactional email through Mail Gazelle. Update this file in the same change as any public API, auth, error-code, or attachment-limit change.
|
|
4
|
+
|
|
5
|
+
Operator environment names live in `.env.example`. Do not put AWS keys or raw tokens in this file.
|
|
6
|
+
|
|
7
|
+
## Base URL and auth
|
|
8
|
+
|
|
9
|
+
- Base URL: `{APP_URL}/api/v1`
|
|
10
|
+
- Authenticate with `Authorization: Bearer tes_…`
|
|
11
|
+
- Tokens are minted on a **ready** product (sending DKIM verified and MAIL FROM success). The raw token is shown once in the admin UI.
|
|
12
|
+
- Rate limit: 60 requests per minute per token.
|
|
13
|
+
- A disabled or paused team, or a product that is not `ready`, cannot send (`403`, `product_not_ready`).
|
|
14
|
+
- An archived product is treated as gone: its tokens return `401` `unauthenticated` (same as an invalid token).
|
|
15
|
+
|
|
16
|
+
## Onboarding
|
|
17
|
+
|
|
18
|
+
1. Create a product with a name and primary domain. Sending identity and MAIL FROM default to `notif.{primary}` and `bounce.notif.{primary}`; both can be edited on create.
|
|
19
|
+
2. Add the DNS records from the product checklist for that sending identity and MAIL FROM.
|
|
20
|
+
3. Click **Check now** (or wait for the minute poll). The product becomes `ready` only when sending DKIM and MAIL FROM are both success.
|
|
21
|
+
4. Create an API token.
|
|
22
|
+
|
|
23
|
+
The apex domain is not created as an SES identity on the default path. DMARC is recommended, not a ready-gate.
|
|
24
|
+
|
|
25
|
+
## Endpoints
|
|
26
|
+
|
|
27
|
+
### `POST /emails`
|
|
28
|
+
|
|
29
|
+
Persists the message, stores attachments on disk, and queues a send. Returns `202` immediately. SES is not called in the request.
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"id": "01J…",
|
|
34
|
+
"status": "queued"
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
JSON body:
|
|
39
|
+
|
|
40
|
+
| Field | Required | Notes |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `to` | yes | One or more `{ "email", "name?" }`. Together with `cc` and `bcc`, at most 50 addresses |
|
|
43
|
+
| `cc` | no | Same shape as `to` |
|
|
44
|
+
| `bcc` | no | Same shape as `to`. Stored and delivered, omitted from the visible MIME headers |
|
|
45
|
+
| `subject` | yes | |
|
|
46
|
+
| `html` or `text` | one required | HTML ≤ 512 KB. Both may be sent together |
|
|
47
|
+
| `from` | no | Defaults to the product From. Domain must be that product's `primary` or `sending` host |
|
|
48
|
+
| `reply_to` | no | Defaults to the product Reply-To. Send `[]` to omit Reply-To |
|
|
49
|
+
| `headers` | no | Object of header name to string. `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Sender`, `Subject`, `Content-Type`, and `Return-Path` are ignored. `Message-ID` is kept when it is present |
|
|
50
|
+
| `tags` | no | Object of names and values, each matching `[A-Za-z0-9_-]`, name ≤ 64 characters, value ≤ 256. At most 48. Invalid tags are `validation_error` (they are not dropped). `team_id` and `product_id` are reserved |
|
|
51
|
+
| `idempotency_key` | no | Unique per team. Same key returns the original message and does not send again. A replay is not checked against the monthly quota |
|
|
52
|
+
| `attachments` | no | See below |
|
|
53
|
+
|
|
54
|
+
### `GET /emails/{id}`
|
|
55
|
+
|
|
56
|
+
Returns status, timestamps, SES message id, last event type, and attachment metadata (filename, content type, size). Does not return HTML, text, or file bytes.
|
|
57
|
+
|
|
58
|
+
### `GET /domains`
|
|
59
|
+
|
|
60
|
+
Lists the token's product domains and verification statuses.
|
|
61
|
+
|
|
62
|
+
## Attachments
|
|
63
|
+
|
|
64
|
+
Optional JSON array, same shape as Resend/Postmark:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
"attachments": [
|
|
68
|
+
{
|
|
69
|
+
"filename": "invoice.pdf",
|
|
70
|
+
"content": "<base64>",
|
|
71
|
+
"content_type": "application/pdf",
|
|
72
|
+
"content_id": null
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `filename` and `content` are required.
|
|
78
|
+
- `content_type` is optional (guessed from the filename).
|
|
79
|
+
- `content_id` is optional. When set, the part is inline (`multipart/related`). Reference it from HTML as `cid:{content_id}`. Angle brackets and a leading `cid:` are stripped. Empty or duplicate ids on one message are `attachment_invalid`.
|
|
80
|
+
- Filename must be a basename — path segments are rejected (`attachment_invalid`).
|
|
81
|
+
- The attachment count and decoded total per message are set by the team's plan. Defaults are 10 attachments and 7 MB. More attachments than allowed is `validation_error`. A larger decoded total is `attachment_too_large`, and `message` states the team's size limit. 7 MB is the platform ceiling on every plan.
|
|
82
|
+
- Empty or invalid base64 is `attachment_invalid`.
|
|
83
|
+
- A team may also have a monthly decoded-byte allowance. Crossing it is `quota_exceeded` (below), which is separate from this per-message limit.
|
|
84
|
+
- The assembled raw MIME (HTML, text, headers, and base64 attachments) must be 10 MB or smaller. A larger message is `message_too_large` and is not queued.
|
|
85
|
+
|
|
86
|
+
SES Simple cannot carry attachments. Mail Gazelle always sends `Content.Raw`.
|
|
87
|
+
|
|
88
|
+
## Suppressions
|
|
89
|
+
|
|
90
|
+
The send path checks the **team** (account) and global suppression lists against every `to`, `cc`, and `bcc` address. A suppressed recipient is stored as a `rejected` message (no SES send) and returns `422`:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{ "message": "This recipient is suppressed.", "code": "recipient_suppressed" }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Permanent bounces and complaints write the address to the team list and the SES account suppression list. A suppressed reject does not consume the monthly email or attachment quota.
|
|
97
|
+
|
|
98
|
+
## Quotas
|
|
99
|
+
|
|
100
|
+
Each team's plan sets how many emails and how many decoded attachment bytes are included each UTC month, plus an optional daily send limit (UTC day). The platform admin can change these per team, so they are not a single platform-wide limit.
|
|
101
|
+
|
|
102
|
+
When a new send would pass a limit, the API returns `422` and does not queue the message. `message` says which limit was hit:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{ "message": "Monthly email quota exceeded.", "code": "quota_exceeded" }
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{ "message": "Daily email limit exceeded.", "code": "quota_exceeded" }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{ "message": "Monthly attachment quota exceeded.", "code": "quota_exceeded" }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Plans that allow additional sending, or additional attachment size, keep sending after the monthly amount is used. For those plans the monthly checks never return `quota_exceeded`. The daily limit applies to every plan that has one.
|
|
117
|
+
|
|
118
|
+
Each distinct `to`, `cc`, and `bcc` address on an accepted message counts as one send. The same address listed more than once counts once. Decoded attachment bytes count once per recipient. `recipient_suppressed` rejects are not counted, and their attachment bytes are not counted. Replaying an idempotency key that is already stored returns that message and does not re-check any limit. A send with no attachments is not blocked by the attachment allowance. A request that would pass the remaining monthly, daily, or attachment allowance is rejected whole.
|
|
119
|
+
|
|
120
|
+
## Errors
|
|
121
|
+
|
|
122
|
+
All API errors use:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{ "message": "…", "code": "…", "details": {} }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`details` is optional. Responses never include AWS stack traces, message bodies, or attachment bytes.
|
|
129
|
+
|
|
130
|
+
| Code | Typical status |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `unauthenticated` | 401 |
|
|
133
|
+
| `product_not_ready` | 403 |
|
|
134
|
+
| `validation_error` | 422 |
|
|
135
|
+
| `from_not_allowed` | 422 |
|
|
136
|
+
| `recipient_suppressed` | 422 |
|
|
137
|
+
| `attachment_invalid` | 422 |
|
|
138
|
+
| `attachment_too_large` | 422 |
|
|
139
|
+
| `quota_exceeded` | 422 |
|
|
140
|
+
| `html_too_large` | 422 |
|
|
141
|
+
| `message_too_large` | 422 |
|
|
142
|
+
| `rate_limited` | 429 |
|
|
143
|
+
|
|
144
|
+
## Sandbox vs production
|
|
145
|
+
|
|
146
|
+
Until AWS takes the SES account out of sandbox, you can only send to verified identities. Simulator addresses work in sandbox:
|
|
147
|
+
|
|
148
|
+
- `success@simulator.amazonses.com`
|
|
149
|
+
- `bounce@simulator.amazonses.com`
|
|
150
|
+
- `complaint@simulator.amazonses.com`
|
|
151
|
+
|
|
152
|
+
## Examples
|
|
153
|
+
|
|
154
|
+
Text send:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
curl -X POST "$APP_URL/api/v1/emails" \
|
|
158
|
+
-H "Authorization: Bearer tes_…" \
|
|
159
|
+
-H "Content-Type: application/json" \
|
|
160
|
+
-d '{
|
|
161
|
+
"to": [{"email": "user@example.com"}],
|
|
162
|
+
"subject": "Welcome",
|
|
163
|
+
"text": "Thanks for signing up."
|
|
164
|
+
}'
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Send with a PDF:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
curl -X POST "$APP_URL/api/v1/emails" \
|
|
171
|
+
-H "Authorization: Bearer tes_…" \
|
|
172
|
+
-H "Content-Type: application/json" \
|
|
173
|
+
-d '{
|
|
174
|
+
"to": [{"email": "user@example.com"}],
|
|
175
|
+
"subject": "Invoice",
|
|
176
|
+
"html": "<p>Your invoice is attached.</p>",
|
|
177
|
+
"attachments": [{
|
|
178
|
+
"filename": "invoice.pdf",
|
|
179
|
+
"content": "'"$PDF_BASE64"'",
|
|
180
|
+
"content_type": "application/pdf"
|
|
181
|
+
}]
|
|
182
|
+
}'
|
|
183
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "mailgazelle/error"
|
|
5
|
+
|
|
6
|
+
module MailGazelle
|
|
7
|
+
# An email address with an optional display name.
|
|
8
|
+
class Address
|
|
9
|
+
attr_reader :email, :name
|
|
10
|
+
|
|
11
|
+
def initialize(email, name = nil)
|
|
12
|
+
normalized = email.to_s.strip
|
|
13
|
+
unless URI::MailTo::EMAIL_REGEXP.match?(normalized)
|
|
14
|
+
raise ValidationError.new(
|
|
15
|
+
"\"#{email}\" is not a valid email address.",
|
|
16
|
+
code: "validation_error",
|
|
17
|
+
http_status: 422
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@email = normalized
|
|
22
|
+
cleaned = name.to_s.strip
|
|
23
|
+
@name = cleaned.empty? ? nil : cleaned
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
payload = { email: email }
|
|
29
|
+
payload[:name] = name unless name.nil?
|
|
30
|
+
payload
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "mailgazelle/error"
|
|
5
|
+
|
|
6
|
+
module MailGazelle
|
|
7
|
+
# A file attached to an email, encoded as the API expects.
|
|
8
|
+
#
|
|
9
|
+
# Use the named constructors so callers encode bytes only when they already
|
|
10
|
+
# have base64 content.
|
|
11
|
+
class Attachment
|
|
12
|
+
CONTENT_TYPES = {
|
|
13
|
+
"pdf" => "application/pdf",
|
|
14
|
+
"png" => "image/png",
|
|
15
|
+
"jpg" => "image/jpeg",
|
|
16
|
+
"jpeg" => "image/jpeg",
|
|
17
|
+
"gif" => "image/gif",
|
|
18
|
+
"webp" => "image/webp",
|
|
19
|
+
"svg" => "image/svg+xml",
|
|
20
|
+
"txt" => "text/plain",
|
|
21
|
+
"html" => "text/html",
|
|
22
|
+
"htm" => "text/html",
|
|
23
|
+
"csv" => "text/csv",
|
|
24
|
+
"json" => "application/json",
|
|
25
|
+
"xml" => "application/xml",
|
|
26
|
+
"zip" => "application/zip",
|
|
27
|
+
"ics" => "text/calendar",
|
|
28
|
+
"doc" => "application/msword",
|
|
29
|
+
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
30
|
+
"xls" => "application/vnd.ms-excel",
|
|
31
|
+
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :filename, :content, :content_type, :content_id, :decoded_size
|
|
35
|
+
|
|
36
|
+
def self.from_path(path, filename: nil, content_type: nil, content_id: nil)
|
|
37
|
+
contents = File.binread(path)
|
|
38
|
+
from_contents(
|
|
39
|
+
filename || File.basename(path),
|
|
40
|
+
contents,
|
|
41
|
+
content_type: content_type,
|
|
42
|
+
content_id: content_id
|
|
43
|
+
)
|
|
44
|
+
rescue SystemCallError
|
|
45
|
+
raise AttachmentError.new(
|
|
46
|
+
"Unable to read attachment from \"#{path}\".",
|
|
47
|
+
code: "attachment_invalid",
|
|
48
|
+
http_status: 422
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.from_contents(filename, contents, content_type: nil, content_id: nil)
|
|
53
|
+
binary = contents.to_s.b
|
|
54
|
+
create(
|
|
55
|
+
filename,
|
|
56
|
+
Base64.strict_encode64(binary),
|
|
57
|
+
binary.bytesize,
|
|
58
|
+
content_type,
|
|
59
|
+
content_id
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.from_base64(filename, encoded, content_type: nil, content_id: nil)
|
|
64
|
+
decoded = strict_decode(encoded)
|
|
65
|
+
if decoded.nil? || decoded.empty?
|
|
66
|
+
raise AttachmentError.new(
|
|
67
|
+
"Attachment \"#{filename}\" is not valid base64.",
|
|
68
|
+
code: "attachment_invalid",
|
|
69
|
+
http_status: 422
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
create(filename, encoded, decoded.bytesize, content_type, content_id)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def to_h
|
|
77
|
+
payload = { filename: filename, content: content }
|
|
78
|
+
payload[:content_type] = content_type unless content_type.nil?
|
|
79
|
+
payload[:content_id] = content_id unless content_id.nil?
|
|
80
|
+
payload
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.create(filename, encoded, decoded_size, content_type, content_id)
|
|
84
|
+
name = filename.to_s.strip
|
|
85
|
+
assert_basename(name)
|
|
86
|
+
|
|
87
|
+
if decoded_size < 1
|
|
88
|
+
raise AttachmentError.new(
|
|
89
|
+
"Attachment \"#{name}\" is empty.",
|
|
90
|
+
code: "attachment_invalid",
|
|
91
|
+
http_status: 422
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
type = content_type.to_s.strip
|
|
96
|
+
type = guess_content_type(name) if type.empty?
|
|
97
|
+
|
|
98
|
+
new(name, encoded, type, normalize_content_id(content_id), decoded_size)
|
|
99
|
+
end
|
|
100
|
+
private_class_method :create
|
|
101
|
+
|
|
102
|
+
def self.assert_basename(filename)
|
|
103
|
+
return unless filename.empty? || filename == "." || filename == ".." ||
|
|
104
|
+
filename.include?("/") || filename.include?("\\") ||
|
|
105
|
+
filename != File.basename(filename)
|
|
106
|
+
|
|
107
|
+
raise AttachmentError.new(
|
|
108
|
+
"Attachment filenames must be a basename without path segments.",
|
|
109
|
+
code: "attachment_invalid",
|
|
110
|
+
http_status: 422
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
private_class_method :assert_basename
|
|
114
|
+
|
|
115
|
+
def self.normalize_content_id(content_id)
|
|
116
|
+
return nil if content_id.nil?
|
|
117
|
+
|
|
118
|
+
value = content_id.to_s.strip
|
|
119
|
+
return nil if value.empty?
|
|
120
|
+
|
|
121
|
+
value = value[4..] if value.downcase.start_with?("cid:")
|
|
122
|
+
value = value.gsub(/\A[ \t<>]+|[ \t<>]+\z/, "")
|
|
123
|
+
if value.empty? || value.match?(/\s/)
|
|
124
|
+
raise AttachmentError.new(
|
|
125
|
+
"Attachment content ids must not be empty.",
|
|
126
|
+
code: "attachment_invalid",
|
|
127
|
+
http_status: 422
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
value
|
|
132
|
+
end
|
|
133
|
+
private_class_method :normalize_content_id
|
|
134
|
+
|
|
135
|
+
def self.guess_content_type(filename)
|
|
136
|
+
extension = File.extname(filename).delete_prefix(".").downcase
|
|
137
|
+
CONTENT_TYPES.fetch(extension, "application/octet-stream")
|
|
138
|
+
end
|
|
139
|
+
private_class_method :guess_content_type
|
|
140
|
+
|
|
141
|
+
def self.strict_decode(encoded)
|
|
142
|
+
Base64.strict_decode64(encoded.to_s)
|
|
143
|
+
rescue ArgumentError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
private_class_method :strict_decode
|
|
147
|
+
|
|
148
|
+
def initialize(filename, content, content_type, content_id, decoded_size)
|
|
149
|
+
@filename = filename
|
|
150
|
+
@content = content
|
|
151
|
+
@content_type = content_type
|
|
152
|
+
@content_id = content_id
|
|
153
|
+
@decoded_size = decoded_size
|
|
154
|
+
freeze
|
|
155
|
+
end
|
|
156
|
+
private_class_method :new
|
|
157
|
+
end
|
|
158
|
+
end
|