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 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
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ - Send and fetch transactional email, and list product domains, through the Mail Gazelle API.
6
+ - Deliver Rails mail with ActionMailer's `:mailgazelle` delivery method.
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