coss-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/lib/coss/rails/client.rb +248 -0
- data/lib/coss/rails/delivery_method.rb +55 -0
- data/lib/coss/rails/errors.rb +63 -0
- data/lib/coss/rails/mailer.rb +35 -0
- data/lib/coss/rails/message.rb +24 -0
- data/lib/coss/rails/message_mapper.rb +250 -0
- data/lib/coss/rails/mime.rb +109 -0
- data/lib/coss/rails/options.rb +103 -0
- data/lib/coss/rails/railtie.rb +13 -0
- data/lib/coss/rails/version.rb +7 -0
- data/lib/coss/rails.rb +32 -0
- data/lib/coss-rails.rb +3 -0
- metadata +99 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3ecae82b15bffcfb63e8c7caa4871128f57256006afa3a7c2db264211c74a5bb
|
|
4
|
+
data.tar.gz: f1a21cfd38cdd9a3276dbde2028d75d8a3a00b06c6b2227f1a3451cbbd00c12e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 657b8a2f826b6bcce227f8274471bbda62a161ca174c2ae02ada26d3a9d8c567a854ed616e95785ec45926e47a5ef9531f1c80d7cecac6320e30cc0dd2c451a2
|
|
7
|
+
data.tar.gz: c5b184a8880696688ddf0c319e6df7b4103d55d57f56ab3627b3fa169356798ca5e6d781848de51dc5c91442a400c6011784bee144d5a485103998fbd11abb25
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cal.com, Inc.
|
|
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,101 @@
|
|
|
1
|
+
# coss-rails
|
|
2
|
+
|
|
3
|
+
An Action Mailer delivery method that sends your Rails app's emails through the
|
|
4
|
+
[coss.com](https://coss.com) API. Switch by changing configuration, not call sites.
|
|
5
|
+
|
|
6
|
+
Rails 7.2–8.1, Ruby 3.2+. Docs: [coss.com/docs/integrations/rails](https://coss.com/docs/integrations/rails).
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
# Gemfile
|
|
12
|
+
gem "coss-rails"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Configure
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# config/environments/production.rb
|
|
19
|
+
config.action_mailer.delivery_method = :coss
|
|
20
|
+
config.action_mailer.coss_settings = {
|
|
21
|
+
api_key: Rails.application.credentials.coss_api_key, # default: ENV["COSS_API_KEY"]
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| Setting | Default | |
|
|
26
|
+
| ----------------- | ----------------------- | ---------------------------------------------------------------- |
|
|
27
|
+
| `api_key` | `ENV["COSS_API_KEY"]` | A missing key fails the delivery (`Coss::Rails::ConfigurationError`), never the boot. |
|
|
28
|
+
| `base_url` | `ENV["COSS_BASE_URL"]`, else `https://api.coss.com` | |
|
|
29
|
+
| `timeout` | `30` | Seconds per attempt (Net::HTTP's open, read and write timeouts). |
|
|
30
|
+
| `max_retries` | `2` | Network errors, timeouts, 409, 429 and 5xx are retried with backoff (and `Retry-After`), with the same `Idempotency-Key`. |
|
|
31
|
+
| `return_response` | `false` | Makes `deliver_now!` return the email coss.com accepted (see below). |
|
|
32
|
+
|
|
33
|
+
The Railtie registers `:coss` before Action Mailer applies `config.action_mailer.*`. Without
|
|
34
|
+
Rails, call `Coss::Rails.install(ActionMailer::Base)`, or use the delivery method with Mail
|
|
35
|
+
directly: `mail.delivery_method Coss::Rails::DeliveryMethod, api_key: "coss_live_…"`.
|
|
36
|
+
|
|
37
|
+
## Send
|
|
38
|
+
|
|
39
|
+
Mailers stay as they are: from, to, cc, bcc, reply-to, subject, the text and HTML bodies,
|
|
40
|
+
attachments (`attachments[...]` and `attachments.inline[...]`, referenced as
|
|
41
|
+
`attachments["logo.png"].url`) and custom headers (`headers["X-Campaign"] = "spring"`) all go
|
|
42
|
+
through. `deliver_later` works as usual.
|
|
43
|
+
|
|
44
|
+
### coss.com options
|
|
45
|
+
|
|
46
|
+
The Railtie adds `coss(...)` to every mailer (it includes `Coss::Rails::Mailer` into
|
|
47
|
+
`ActionMailer::Base`). Call it in an action:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class ReceiptMailer < ApplicationMailer
|
|
51
|
+
def receipt(order)
|
|
52
|
+
coss category: "receipts",
|
|
53
|
+
tags: { plan: order.plan }, # or [{ name: "plan", value: "pro" }]
|
|
54
|
+
scheduled_at: 1.hour.from_now, # Time/DateTime/TimeWithZone (sent as UTC) or a String
|
|
55
|
+
tracking: { opens: true, clicks: false },
|
|
56
|
+
idempotency_key: "receipt-#{order.id}" # default: a random key per message
|
|
57
|
+
mail to: order.email, subject: "Your receipt"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The options are stored on the message (`message.coss_options`), so they survive `deliver_later`
|
|
63
|
+
(the job runs the action again). A later `coss` call replaces the options it gives; unknown
|
|
64
|
+
options raise `ArgumentError`.
|
|
65
|
+
|
|
66
|
+
### What a delivery returns
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
message = ReceiptMailer.receipt(order).deliver_now
|
|
70
|
+
message.coss_email # => { "object" => "email", "id" => "email_01k…", "status" => "queued", … }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
With `return_response: true`, `deliver_now!` (and `Mail::Message#deliver!`) return that Hash
|
|
74
|
+
instead of the message. (`deliver_now` always returns the message: that is Action Mailer's.)
|
|
75
|
+
|
|
76
|
+
## Errors
|
|
77
|
+
|
|
78
|
+
Everything this gem raises is a `Coss::Rails::Error`; `raise_delivery_errors` decides, as for any
|
|
79
|
+
delivery method, whether `deliver_now` raises it.
|
|
80
|
+
|
|
81
|
+
- `Coss::Rails::DeliveryError`: coss.com refused the email or couldn't be reached. Readers: `type`
|
|
82
|
+
(`invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error`,
|
|
83
|
+
`idempotency_error`, `rate_limit_error`, `billing_error`, `api_error`, or `connection_error` /
|
|
84
|
+
`timeout_error` without a response), `code` (e.g. `domain_not_verified`), `status` (`nil` without
|
|
85
|
+
a response), `request_id` (`req_…`, quote it to support), `param`, `issues` and
|
|
86
|
+
`error_message`. Its message reads
|
|
87
|
+
`coss.com could not send the email: <message> (<code>)`.
|
|
88
|
+
- `Coss::Rails::UnsupportedMessageError`: the message uses something coss.com can't send, so it
|
|
89
|
+
isn't sent at all: a `Sender` header, Return-Path or SMTP envelope (`smtp_envelope_from`,
|
|
90
|
+
`smtp_envelope_to`) other than the From address and recipients, no To address, a header given
|
|
91
|
+
more than once, or a part that is
|
|
92
|
+
neither the text body, the one HTML body nor an attachment (e.g. a `text/calendar`
|
|
93
|
+
alternative; attach invites instead).
|
|
94
|
+
- `Coss::Rails::ConfigurationError`: no API key.
|
|
95
|
+
|
|
96
|
+
Headers coss.com sets itself (`Date`, `Message-ID`, `MIME-Version`, `Content-Type`, …) are
|
|
97
|
+
dropped. Non-ASCII header values are sent as RFC 2047 encoded-words.
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "time"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module Coss
|
|
11
|
+
module Rails
|
|
12
|
+
# A small client for `POST /v1/emails`: auth + JSON, a timeout per attempt, retries with
|
|
13
|
+
# backoff (`integrations/README.md`, "Retries"), and every failure raised as a DeliveryError.
|
|
14
|
+
class Client
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.coss.com"
|
|
16
|
+
DEFAULT_TIMEOUT = 30
|
|
17
|
+
DEFAULT_MAX_RETRIES = 2
|
|
18
|
+
API_VERSION = "2026-10-10"
|
|
19
|
+
USER_AGENT = "coss-rails/#{VERSION}".freeze
|
|
20
|
+
|
|
21
|
+
INITIAL_RETRY_DELAY_MS = 500
|
|
22
|
+
MAX_RETRY_DELAY_MS = 8_000
|
|
23
|
+
# A server asking us to wait longer than this gets its error returned instead of a stalled send.
|
|
24
|
+
MAX_RETRY_AFTER_MS = 60_000
|
|
25
|
+
|
|
26
|
+
TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error].freeze
|
|
27
|
+
NETWORK_ERRORS = [
|
|
28
|
+
SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse,
|
|
29
|
+
Net::ProtocolError
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# The response of one attempt: `status` (Integer), `headers` (lowercase names → values) and
|
|
33
|
+
# `body` (String).
|
|
34
|
+
Response = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
35
|
+
|
|
36
|
+
# The default transport: one Net::HTTP connection per attempt.
|
|
37
|
+
class NetHttpTransport
|
|
38
|
+
# Sends one POST and returns a Response. Raises Net::HTTP's own errors (timeouts, network).
|
|
39
|
+
def call(uri:, headers:, body:, timeout:)
|
|
40
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
41
|
+
http.use_ssl = uri.scheme == "https"
|
|
42
|
+
http.open_timeout = timeout
|
|
43
|
+
http.read_timeout = timeout
|
|
44
|
+
http.write_timeout = timeout
|
|
45
|
+
http.max_retries = 0
|
|
46
|
+
request = Net::HTTP::Post.new(uri.request_uri, headers)
|
|
47
|
+
request.body = body
|
|
48
|
+
response = http.start { |connection| connection.request(request) }
|
|
49
|
+
Response.new(
|
|
50
|
+
status: response.code.to_i,
|
|
51
|
+
headers: response.each_header.to_h { |name, value| [name.downcase, value] },
|
|
52
|
+
body: response.body.to_s
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
attr_reader :base_url, :timeout, :max_retries
|
|
58
|
+
|
|
59
|
+
# - `api_key`: required.
|
|
60
|
+
# - `base_url`: defaults to `https://api.coss.com` (trailing slashes trimmed).
|
|
61
|
+
# - `timeout`: seconds per attempt (Net::HTTP's open, read and write timeouts), default 30.
|
|
62
|
+
# - `max_retries`: default 2, so at most 3 attempts.
|
|
63
|
+
# - `sleep`: called with the wait before a retry **in seconds** (a Float), like `Kernel#sleep`.
|
|
64
|
+
# - `random`: returns a Float in [0, 1) for the backoff's jitter.
|
|
65
|
+
# - `transport`: responds to `call(uri:, headers:, body:, timeout:)` and returns a Response.
|
|
66
|
+
def initialize(api_key:, base_url: nil, timeout: nil, max_retries: nil, sleep: nil, random: nil, transport: nil)
|
|
67
|
+
@api_key = api_key
|
|
68
|
+
@base_url = (base_url || DEFAULT_BASE_URL).to_s.sub(%r{/+\z}, "")
|
|
69
|
+
@timeout = timeout || DEFAULT_TIMEOUT
|
|
70
|
+
@max_retries = Integer(max_retries || DEFAULT_MAX_RETRIES)
|
|
71
|
+
@sleep = sleep || ->(seconds) { Kernel.sleep(seconds) }
|
|
72
|
+
@random = random || -> { Kernel.rand }
|
|
73
|
+
@transport = transport || NetHttpTransport.new
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Sends one email (`body`: the request as a Hash) and returns the API's email as a Hash.
|
|
77
|
+
# `idempotency_key` defaults to a random UUID; every attempt reuses it.
|
|
78
|
+
def send_email(body, idempotency_key: nil)
|
|
79
|
+
key = idempotency_key || SecureRandom.uuid
|
|
80
|
+
check_header_value!("Idempotency-Key", key)
|
|
81
|
+
headers = {
|
|
82
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
83
|
+
"Content-Type" => "application/json",
|
|
84
|
+
"Accept" => "application/json",
|
|
85
|
+
"Coss-Version" => API_VERSION,
|
|
86
|
+
"User-Agent" => USER_AGENT,
|
|
87
|
+
"Idempotency-Key" => key
|
|
88
|
+
}
|
|
89
|
+
check_header_value!("Authorization", headers["Authorization"])
|
|
90
|
+
request(URI("#{@base_url}/v1/emails"), headers, JSON.generate(body))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Exponential backoff (500 ms, 1 s, 2 s … capped at 8 s) minus up to 25% random jitter, in ms.
|
|
94
|
+
def self.backoff_delay(retry_index, random)
|
|
95
|
+
delay = [INITIAL_RETRY_DELAY_MS * (2**retry_index), MAX_RETRY_DELAY_MS].min
|
|
96
|
+
delay * (1 - (random * 0.25))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# `Retry-After` in milliseconds: delay-seconds or an HTTP date; `nil` when absent or invalid.
|
|
100
|
+
def self.parse_retry_after(value, now: Time.now)
|
|
101
|
+
trimmed = value.to_s.strip
|
|
102
|
+
return nil if trimmed.empty?
|
|
103
|
+
|
|
104
|
+
seconds = Float(trimmed, exception: false)
|
|
105
|
+
return (seconds >= 0 ? seconds * 1000 : nil) if seconds&.finite?
|
|
106
|
+
|
|
107
|
+
date = parse_http_date(trimmed)
|
|
108
|
+
date && [0, (date - now) * 1000].max
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.parse_http_date(value)
|
|
112
|
+
Time.httpdate(value)
|
|
113
|
+
rescue ArgumentError
|
|
114
|
+
begin
|
|
115
|
+
Time.rfc2822(value)
|
|
116
|
+
rescue ArgumentError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
private_class_method :parse_http_date
|
|
121
|
+
|
|
122
|
+
# 409 (an idempotent request still in progress), 429 and 5xx are transient.
|
|
123
|
+
def self.retryable_status?(status)
|
|
124
|
+
status == 409 || status == 429 || status >= 500
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def request(uri, headers, body)
|
|
130
|
+
retry_index = 0
|
|
131
|
+
loop do
|
|
132
|
+
error, result, retryable, retry_after_ms = attempt(uri, headers, body)
|
|
133
|
+
return result unless error
|
|
134
|
+
raise error if !retryable || retry_index >= @max_retries
|
|
135
|
+
|
|
136
|
+
delay = retry_after_ms || self.class.backoff_delay(retry_index, @random.call)
|
|
137
|
+
raise error if delay > MAX_RETRY_AFTER_MS
|
|
138
|
+
|
|
139
|
+
@sleep.call(delay / 1000.0)
|
|
140
|
+
retry_index += 1
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Returns `[error, result, retryable, retry_after_ms]`.
|
|
145
|
+
def attempt(uri, headers, body)
|
|
146
|
+
begin
|
|
147
|
+
response = @transport.call(uri: uri, headers: headers, body: body, timeout: @timeout)
|
|
148
|
+
rescue *TIMEOUT_ERRORS
|
|
149
|
+
return [timeout_error, nil, true, nil]
|
|
150
|
+
rescue *NETWORK_ERRORS => e
|
|
151
|
+
return [network_error(e), nil, true, nil]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
request_id = response.headers["x-request-id"]
|
|
155
|
+
text = Mime.utf8(response.body)
|
|
156
|
+
unless (200..299).cover?(response.status)
|
|
157
|
+
return [
|
|
158
|
+
error_from_response(response.status, text, request_id),
|
|
159
|
+
nil,
|
|
160
|
+
self.class.retryable_status?(response.status),
|
|
161
|
+
self.class.parse_retry_after(response.headers["retry-after"])
|
|
162
|
+
]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
begin
|
|
166
|
+
[nil, JSON.parse(text), false, nil]
|
|
167
|
+
rescue JSON::ParserError
|
|
168
|
+
error = DeliveryError.new(
|
|
169
|
+
type: "api_error",
|
|
170
|
+
code: "invalid_response",
|
|
171
|
+
message: "The coss.com API returned an HTTP #{response.status} response that is not valid JSON.",
|
|
172
|
+
status: response.status,
|
|
173
|
+
request_id: request_id
|
|
174
|
+
)
|
|
175
|
+
[error, nil, false, nil]
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def error_from_response(status, text, header_request_id)
|
|
180
|
+
envelope = begin
|
|
181
|
+
parsed = JSON.parse(text)
|
|
182
|
+
parsed.is_a?(Hash) ? parsed["error"] : nil
|
|
183
|
+
rescue JSON::ParserError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
unless envelope.is_a?(Hash) && envelope["message"].is_a?(String)
|
|
188
|
+
return DeliveryError.new(
|
|
189
|
+
type: "api_error",
|
|
190
|
+
code: "unexpected_response",
|
|
191
|
+
message: "The coss.com API returned an unexpected HTTP #{status} response.",
|
|
192
|
+
status: status,
|
|
193
|
+
request_id: header_request_id
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
type = envelope["type"]
|
|
198
|
+
DeliveryError.new(
|
|
199
|
+
type: DeliveryError::API_ERROR_TYPES.include?(type) ? type : "api_error",
|
|
200
|
+
code: envelope["code"].is_a?(String) ? envelope["code"] : "unknown_error",
|
|
201
|
+
message: envelope["message"],
|
|
202
|
+
status: status,
|
|
203
|
+
request_id: envelope["request_id"].is_a?(String) ? envelope["request_id"] : header_request_id,
|
|
204
|
+
param: envelope["param"].is_a?(String) ? envelope["param"] : nil,
|
|
205
|
+
issues: parse_issues(envelope["issues"])
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def parse_issues(value)
|
|
210
|
+
return nil unless value.is_a?(Array)
|
|
211
|
+
|
|
212
|
+
value.filter_map do |issue|
|
|
213
|
+
next unless issue.is_a?(Hash) && issue["path"].is_a?(String) && issue["message"].is_a?(String)
|
|
214
|
+
|
|
215
|
+
{ "path" => issue["path"], "message" => issue["message"] }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def timeout_error
|
|
220
|
+
DeliveryError.new(
|
|
221
|
+
type: "timeout_error",
|
|
222
|
+
code: "request_timeout",
|
|
223
|
+
message: "Request timed out after #{@timeout} seconds."
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def network_error(cause)
|
|
228
|
+
DeliveryError.new(
|
|
229
|
+
type: "connection_error",
|
|
230
|
+
code: "network_error",
|
|
231
|
+
message: "Could not reach the coss.com API at #{@base_url}: #{cause.message} (#{cause.class})"
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Net::HTTP refuses (or mangles) header values with line breaks or non-ASCII bytes; fail
|
|
236
|
+
# before sending instead of with an error that looks like a network failure.
|
|
237
|
+
def check_header_value!(name, value)
|
|
238
|
+
return if value.is_a?(String) && value.match?(/\A[ -~]*\z/)
|
|
239
|
+
|
|
240
|
+
raise DeliveryError.new(
|
|
241
|
+
type: "invalid_request_error",
|
|
242
|
+
code: "invalid_header",
|
|
243
|
+
message: "Invalid request header: #{name} must be printable ASCII."
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coss
|
|
4
|
+
module Rails
|
|
5
|
+
# The `:coss` Action Mailer delivery method (also usable with Mail directly:
|
|
6
|
+
# `mail.delivery_method Coss::Rails::DeliveryMethod, api_key: "…"`).
|
|
7
|
+
#
|
|
8
|
+
# Settings (`config.action_mailer.coss_settings`):
|
|
9
|
+
#
|
|
10
|
+
# - `api_key`: defaults to `ENV["COSS_API_KEY"]`; a missing key fails the delivery, not the boot.
|
|
11
|
+
# - `base_url`: defaults to `https://api.coss.com`.
|
|
12
|
+
# - `timeout`: seconds per attempt, default 30.
|
|
13
|
+
# - `max_retries`: default 2.
|
|
14
|
+
# - `return_response`: when true, `deliver_now!` / `Mail::Message#deliver!` return the email Hash.
|
|
15
|
+
#
|
|
16
|
+
# `sleep`, `random` and `transport` are passed to Client (for tests).
|
|
17
|
+
class DeliveryMethod
|
|
18
|
+
CLIENT_SETTINGS = %i[base_url timeout max_retries sleep random transport].freeze
|
|
19
|
+
|
|
20
|
+
attr_accessor :settings
|
|
21
|
+
|
|
22
|
+
def initialize(settings = {})
|
|
23
|
+
@settings = (settings || {}).to_h.transform_keys(&:to_sym)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Sends `mail` and returns the email as the API returned it (a Hash), also stored as
|
|
27
|
+
# `mail.coss_email`. Raises UnsupportedMessageError, ConfigurationError or DeliveryError.
|
|
28
|
+
def deliver!(mail)
|
|
29
|
+
body, idempotency_key = MessageMapper.new(mail).to_request
|
|
30
|
+
email = client.send_email(body, idempotency_key: idempotency_key)
|
|
31
|
+
mail.coss_email = email
|
|
32
|
+
email
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def client
|
|
38
|
+
options = settings.slice(*CLIENT_SETTINGS)
|
|
39
|
+
base_url = ENV.fetch("COSS_BASE_URL", nil)
|
|
40
|
+
options[:base_url] = base_url if options[:base_url].to_s.empty? && !base_url.to_s.empty?
|
|
41
|
+
Client.new(api_key: api_key, **options)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def api_key
|
|
45
|
+
key = settings[:api_key]
|
|
46
|
+
key = ENV.fetch("COSS_API_KEY", nil) if key.nil? || key.to_s.empty?
|
|
47
|
+
return key unless key.nil? || key.empty?
|
|
48
|
+
|
|
49
|
+
raise ConfigurationError,
|
|
50
|
+
"No coss.com API key: set config.action_mailer.coss_settings[:api_key] " \
|
|
51
|
+
"(coss_settings[:api_key]) or the COSS_API_KEY environment variable."
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coss
|
|
4
|
+
module Rails
|
|
5
|
+
# The base of every error this gem raises: `rescue Coss::Rails::Error` catches them all.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# The delivery method isn't configured (no API key).
|
|
9
|
+
class ConfigurationError < Error; end
|
|
10
|
+
|
|
11
|
+
# The message uses something coss.com can't send (a `Sender` header, a separate envelope, a
|
|
12
|
+
# header given twice, an alternative part other than one HTML part …). Raised before any request.
|
|
13
|
+
class UnsupportedMessageError < Error; end
|
|
14
|
+
|
|
15
|
+
# coss.com refused the email, or couldn't be reached.
|
|
16
|
+
class DeliveryError < Error
|
|
17
|
+
# The API's error types; anything else a response says becomes `api_error`.
|
|
18
|
+
API_ERROR_TYPES = %w[
|
|
19
|
+
invalid_request_error authentication_error permission_error not_found_error
|
|
20
|
+
idempotency_error rate_limit_error billing_error api_error
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# `invalid_request_error`, `authentication_error`, … or, without a response,
|
|
24
|
+
# `connection_error` / `timeout_error`.
|
|
25
|
+
attr_reader :type
|
|
26
|
+
# Machine-readable reason, e.g. `domain_not_verified`.
|
|
27
|
+
attr_reader :code
|
|
28
|
+
# The HTTP status, `nil` when there was no response (network error, timeout).
|
|
29
|
+
attr_reader :status
|
|
30
|
+
# The API's request id (`req_…`) to quote to support, `nil` when unknown.
|
|
31
|
+
attr_reader :request_id
|
|
32
|
+
# The request parameter the error is about, when the API names one.
|
|
33
|
+
attr_reader :param
|
|
34
|
+
# Validation issues, `[{ "path" => "to", "message" => "…" }]`, when the API gives them.
|
|
35
|
+
attr_reader :issues
|
|
36
|
+
# The API's own message (without this gem's prefix).
|
|
37
|
+
attr_reader :error_message
|
|
38
|
+
|
|
39
|
+
def initialize(type:, code:, message:, status: nil, request_id: nil, param: nil, issues: nil)
|
|
40
|
+
@type = type
|
|
41
|
+
@code = code
|
|
42
|
+
@error_message = message
|
|
43
|
+
@status = status
|
|
44
|
+
@request_id = request_id
|
|
45
|
+
@param = param
|
|
46
|
+
@issues = issues
|
|
47
|
+
details = issues&.map { |issue| "#{issue['path']}: #{issue['message']}" }&.join("; ")
|
|
48
|
+
text = "coss.com could not send the email: #{message} (#{code})"
|
|
49
|
+
text += " #{details}" if details && !details.empty?
|
|
50
|
+
super(text)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The error as the API's envelope fields (`param` and `issues` only when present).
|
|
54
|
+
def to_h
|
|
55
|
+
hash = { "type" => type, "code" => code, "message" => error_message, "status" => status,
|
|
56
|
+
"request_id" => request_id }
|
|
57
|
+
hash["param"] = param unless param.nil?
|
|
58
|
+
hash["issues"] = issues unless issues.nil?
|
|
59
|
+
hash
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coss
|
|
4
|
+
module Rails
|
|
5
|
+
# Adds `coss(...)` to Action Mailer mailers (the Railtie includes it into `ActionMailer::Base`).
|
|
6
|
+
#
|
|
7
|
+
# class ReceiptMailer < ApplicationMailer
|
|
8
|
+
# def receipt(order)
|
|
9
|
+
# coss category: "receipts", tags: { plan: order.plan }, idempotency_key: "receipt-#{order.id}"
|
|
10
|
+
# mail to: order.email, subject: "Your receipt"
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# The options are stored on the message (`message.coss_options`), so they survive
|
|
15
|
+
# `deliver_later`: the job runs the action again.
|
|
16
|
+
module Mailer
|
|
17
|
+
# Sets coss.com's fields for the message this action builds. Calling it again merges: a
|
|
18
|
+
# later call replaces the options it gives. Raises ArgumentError for invalid values.
|
|
19
|
+
#
|
|
20
|
+
# - `category`: what kind of email this is, e.g. `"marketing"`.
|
|
21
|
+
# - `tags`: `{ plan: "pro" }` or `[{ name: "plan", value: "pro" }]`.
|
|
22
|
+
# - `scheduled_at`: a Time, DateTime or ActiveSupport::TimeWithZone (sent as UTC), or an
|
|
23
|
+
# ISO 8601 String sent as given; at most 30 days ahead.
|
|
24
|
+
# - `tracking`: `{ opens: true, clicks: false }`.
|
|
25
|
+
# - `idempotency_key`: sent as `Idempotency-Key` (default: a random key per message).
|
|
26
|
+
def coss(category: nil, tags: nil, scheduled_at: nil, tracking: nil, idempotency_key: nil)
|
|
27
|
+
given = Options.normalize(
|
|
28
|
+
category: category, tags: tags, scheduled_at: scheduled_at, tracking: tracking,
|
|
29
|
+
idempotency_key: idempotency_key
|
|
30
|
+
)
|
|
31
|
+
message.coss_options = message.coss_options.merge(given)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coss
|
|
4
|
+
module Rails
|
|
5
|
+
# Adds coss.com's accessors to `Mail::Message`.
|
|
6
|
+
module MessageExtension
|
|
7
|
+
# The email as the coss.com API returned it (a Hash: `"id" => "email_…"`, `"status"`, …),
|
|
8
|
+
# set once the message is delivered.
|
|
9
|
+
attr_accessor :coss_email
|
|
10
|
+
|
|
11
|
+
# coss.com's fields for this message (`category`, `tags`, `scheduled_at`, `tracking`,
|
|
12
|
+
# `idempotency_key`), normally set with `coss(...)` in a mailer action.
|
|
13
|
+
def coss_options
|
|
14
|
+
@coss_options ||= {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def coss_options=(options)
|
|
18
|
+
@coss_options = options.nil? ? {} : Options.normalize(options)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
::Mail::Message.include(MessageExtension)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coss
|
|
4
|
+
module Rails
|
|
5
|
+
# Turns a `Mail::Message` into the `POST /v1/emails` body (`integrations/README.md`,
|
|
6
|
+
# "The message"), refusing what coss.com can't send.
|
|
7
|
+
class MessageMapper
|
|
8
|
+
# Headers read into the body's own fields (all reserved by the API as headers too).
|
|
9
|
+
ADDRESS_FIELDS = { to: :to, cc: :cc, bcc: :bcc, reply_to: :reply_to }.freeze
|
|
10
|
+
# Not sent as custom headers: the part's own Content-ID at the top level (single-part messages).
|
|
11
|
+
SKIPPED_HEADERS = %w[content-id].freeze
|
|
12
|
+
BODY_TYPES = %w[text/plain text/html].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(mail)
|
|
15
|
+
@mail = mail
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns `[body, idempotency_key]`; `idempotency_key` is nil unless the message sets one.
|
|
19
|
+
def to_request
|
|
20
|
+
refuse_unsupported_envelope!
|
|
21
|
+
options = Options.normalize(@mail.coss_options || {})
|
|
22
|
+
|
|
23
|
+
to = addresses(:to)
|
|
24
|
+
if to.empty?
|
|
25
|
+
refuse!("a message without a To address",
|
|
26
|
+
"coss.com needs at least one and never moves Cc or Bcc addresses into To")
|
|
27
|
+
end
|
|
28
|
+
body = { "from" => from, "to" => to }
|
|
29
|
+
%i[cc bcc reply_to].each { |field| put(body, field.to_s, addresses(field)) }
|
|
30
|
+
body["subject"] = Mime.utf8(@mail.subject.to_s)
|
|
31
|
+
html, text, attachments = contents
|
|
32
|
+
put(body, "html", html)
|
|
33
|
+
put(body, "text", text)
|
|
34
|
+
put(body, "attachments", attachments)
|
|
35
|
+
put(body, "headers", custom_headers)
|
|
36
|
+
%i[category tags scheduled_at tracking].each { |key| put(body, key.to_s, options[key]) }
|
|
37
|
+
|
|
38
|
+
[body, options[:idempotency_key]]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Sets `body[key]` unless `value` is nil or empty: empty fields are left out.
|
|
44
|
+
def put(body, key, value)
|
|
45
|
+
body[key] = value unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def refuse!(what, instead)
|
|
49
|
+
raise UnsupportedMessageError, "coss-rails: #{what} can't be sent through coss.com; #{instead}."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --- addresses ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def address_list(name)
|
|
55
|
+
field = @mail.header[name]
|
|
56
|
+
return [] if field.nil?
|
|
57
|
+
|
|
58
|
+
if field.is_a?(Array)
|
|
59
|
+
refuse!("a `#{field.first.name}` header given more than once", "give one header with every address")
|
|
60
|
+
end
|
|
61
|
+
unless field.respond_to?(:addrs)
|
|
62
|
+
refuse!("a `#{field.name}` header that isn't a valid address list", "fix its addresses")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
field.addrs
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def addresses(name)
|
|
69
|
+
address_list(name).filter_map do |address|
|
|
70
|
+
next if address.address.nil? || address.address.empty?
|
|
71
|
+
|
|
72
|
+
Mime.format_address(Mime.utf8(address.address), address.display_name)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def from
|
|
77
|
+
list = address_list(:from)
|
|
78
|
+
raise UnsupportedMessageError, "coss-rails: the message has no From address." if list.empty?
|
|
79
|
+
|
|
80
|
+
refuse!("more than one From address", "send from one address") if list.size > 1
|
|
81
|
+
|
|
82
|
+
Mime.format_address(Mime.utf8(list.first.address), list.first.display_name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A Sender naming the From address is harmless (dropped like other reserved headers).
|
|
86
|
+
def refuse_foreign_sender!
|
|
87
|
+
sender = @mail.header[:sender] && @mail.sender
|
|
88
|
+
return if sender.nil? || sender.to_s.empty? || sender.to_s.downcase == from_address
|
|
89
|
+
|
|
90
|
+
refuse!("a `Sender` header other than the From address",
|
|
91
|
+
"coss.com sets the Sender itself; set `from` instead")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def from_address
|
|
95
|
+
@from_address ||= address_list(:from).first&.address.to_s.downcase
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# --- envelope ----------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def refuse_unsupported_envelope!
|
|
101
|
+
refuse_foreign_sender!
|
|
102
|
+
|
|
103
|
+
return_path = @mail.return_path
|
|
104
|
+
if return_path && !return_path.to_s.empty? && return_path.to_s.downcase != from_address
|
|
105
|
+
refuse!("a Return-Path other than the From address", "coss.com handles bounces itself")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Mail derives the envelope from the headers unless it was set explicitly (an instance
|
|
109
|
+
# variable of its own; the readers can't tell an explicit value from the default).
|
|
110
|
+
envelope_from = @mail.instance_variable_get(:@smtp_envelope_from)
|
|
111
|
+
if envelope_from && envelope_address(envelope_from) != from_address
|
|
112
|
+
refuse!("an SMTP envelope sender other than the From address", "coss.com sends from the From address")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
envelope_to = @mail.instance_variable_get(:@smtp_envelope_to)
|
|
116
|
+
return unless envelope_to
|
|
117
|
+
|
|
118
|
+
given = Array(envelope_to).map { |address| envelope_address(address) }.uniq.sort
|
|
119
|
+
destinations = Array(@mail.destinations).map { |address| address.to_s.downcase }.uniq.sort
|
|
120
|
+
return if given == destinations
|
|
121
|
+
|
|
122
|
+
refuse!("SMTP envelope recipients other than to + cc + bcc", "coss.com sends to the message's recipients")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def envelope_address(value)
|
|
126
|
+
string = value.to_s
|
|
127
|
+
address = string.include?("<") ? Mail::Address.new(string).address : string
|
|
128
|
+
address.to_s.strip.downcase
|
|
129
|
+
rescue Mail::Field::ParseError
|
|
130
|
+
string.strip.downcase
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# --- bodies and attachments --------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
# `[html, text, attachments]` from the message's leaf parts (the message itself when it is
|
|
136
|
+
# single-part). Anything that is neither the one text part, the one HTML part nor an
|
|
137
|
+
# attachment is refused.
|
|
138
|
+
def contents
|
|
139
|
+
html = nil
|
|
140
|
+
text = nil
|
|
141
|
+
attachments = []
|
|
142
|
+
leaves(@mail).each do |part|
|
|
143
|
+
if attachment?(part)
|
|
144
|
+
attachments << attachment(part, attachments.size + 1)
|
|
145
|
+
next
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
case part.mime_type&.downcase
|
|
149
|
+
when nil, "text/plain"
|
|
150
|
+
refuse!("a second text/plain part", "send one text and one HTML body") unless text.nil?
|
|
151
|
+
text = body_text(part)
|
|
152
|
+
when "text/html"
|
|
153
|
+
refuse!("a second text/html part", "send one HTML body") unless html.nil?
|
|
154
|
+
html = body_text(part)
|
|
155
|
+
else
|
|
156
|
+
refuse!("a #{part.mime_type} part that isn't an attachment",
|
|
157
|
+
"send one text and one HTML body, and attach anything else")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
[html, text, attachments]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Mail only sets a multipart Content-Type when it encodes the message, so a message with
|
|
164
|
+
# parts counts as multipart whatever its header says so far.
|
|
165
|
+
def leaves(part)
|
|
166
|
+
return (part.multipart? ? [] : [part]) if part.parts.empty?
|
|
167
|
+
|
|
168
|
+
# A multipart attachment (an attached multipart/mixed tree) would otherwise be taken
|
|
169
|
+
# apart below and its text sent as the email's body.
|
|
170
|
+
refuse!("a multipart attachment", "attach its files one by one") if !part.equal?(@mail) && attachment?(part)
|
|
171
|
+
part.parts.flat_map { |child| leaves(child) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def attachment?(part)
|
|
175
|
+
return true if part.attachment?
|
|
176
|
+
|
|
177
|
+
disposition = disposition_type(part)
|
|
178
|
+
return true if disposition == "attachment"
|
|
179
|
+
|
|
180
|
+
(disposition == "inline" || content_id?(part)) && !BODY_TYPES.include?(part.mime_type&.downcase)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def disposition_type(part)
|
|
184
|
+
field = part.header[:content_disposition]
|
|
185
|
+
field.respond_to?(:disposition_type) ? field.disposition_type.to_s.downcase : nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def body_text(part)
|
|
189
|
+
Mime.utf8(part.decoded)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def attachment(part, position)
|
|
193
|
+
filename = part.filename
|
|
194
|
+
params = {
|
|
195
|
+
"filename" => filename.nil? || filename.to_s.empty? ? "attachment-#{position}" : Mime.utf8(filename),
|
|
196
|
+
# The bytes as sent: body.decoded undoes the transfer encoding without transcoding text.
|
|
197
|
+
"content" => Mime.base64(part.body.decoded)
|
|
198
|
+
}
|
|
199
|
+
content_type = Mime.media_type(part.mime_type)
|
|
200
|
+
params["content_type"] = content_type if content_type
|
|
201
|
+
content_id = content_id(part)
|
|
202
|
+
params["content_id"] = content_id if content_id
|
|
203
|
+
params
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def content_id(part)
|
|
207
|
+
if disposition_type(part) == "inline" && !content_id?(part) && part.respond_to?(:add_content_id)
|
|
208
|
+
# Mail would generate one when encoding the message; do it now so it can be referenced.
|
|
209
|
+
part.add_content_id
|
|
210
|
+
end
|
|
211
|
+
content_id?(part) ? Mime.bare_content_id(part.header[:content_id].value) : nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def content_id?(part)
|
|
215
|
+
!part.header[:content_id].nil?
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# --- headers -----------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def custom_headers
|
|
221
|
+
headers = {}
|
|
222
|
+
seen = {}
|
|
223
|
+
@mail.header.fields.each do |field|
|
|
224
|
+
name = field.name.to_s.strip
|
|
225
|
+
lower = name.downcase
|
|
226
|
+
next if name.empty? || Mime.reserved_header?(name) || SKIPPED_HEADERS.include?(lower)
|
|
227
|
+
|
|
228
|
+
value = header_value(field)
|
|
229
|
+
refuse!("the `#{name}` header given more than once", "coss.com sends each header once") if seen[lower]
|
|
230
|
+
seen[lower] = true
|
|
231
|
+
|
|
232
|
+
encoded = Mime.encode_header_value(value)
|
|
233
|
+
headers[name] = encoded unless encoded.empty?
|
|
234
|
+
end
|
|
235
|
+
headers
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# The value as the app gave it (before Mail's encoding), so the README's encoding applies.
|
|
239
|
+
def header_value(field)
|
|
240
|
+
value = field.unparsed_value
|
|
241
|
+
if value.is_a?(Array)
|
|
242
|
+
refuse!("the `#{field.name}` header with several values", "coss.com sends each header once") if value.size > 1
|
|
243
|
+
value = value.first
|
|
244
|
+
end
|
|
245
|
+
value = field.decoded if value.nil? && field.respond_to?(:decoded)
|
|
246
|
+
Mime.utf8(value.to_s)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coss
|
|
4
|
+
module Rails
|
|
5
|
+
# The message helpers every coss.com driver shares (`integrations/README.md`, "The message"),
|
|
6
|
+
# mirroring `packages/sdk/src/core/mime.ts`.
|
|
7
|
+
module Mime
|
|
8
|
+
# Headers the API refuses because coss.com or the provider sets them (`RESERVED_HEADERS` in
|
|
9
|
+
# `packages/email/src/schemas.ts`). Mail sets several on every message, so they are dropped.
|
|
10
|
+
RESERVED_HEADERS = %w[
|
|
11
|
+
from to cc bcc reply-to subject sender return-path date message-id mime-version
|
|
12
|
+
content-type content-transfer-encoding content-disposition dkim-signature received
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
# RFC 5322 specials (minus "."): the API refuses them in an unquoted display name.
|
|
16
|
+
NAME_SPECIALS = /["<>,;:@()\[\]\\]/
|
|
17
|
+
|
|
18
|
+
PRINTABLE_ASCII = /\A[ -~]*\z/
|
|
19
|
+
|
|
20
|
+
# One encoded-word holds at most 75 characters: `=?UTF-8?B?` + base64 + `?=` leaves 63 for
|
|
21
|
+
# the base64, i.e. 45 bytes of UTF-8 (rounded down to whole characters).
|
|
22
|
+
ENCODED_WORD_BYTES = 45
|
|
23
|
+
|
|
24
|
+
# What JavaScript's `String#trim` removes (Ruby's `strip` only knows ASCII whitespace).
|
|
25
|
+
TRIM_LEADING = /\A[[:space:]\u{FEFF}]+/
|
|
26
|
+
TRIM_TRAILING = /[[:space:]\u{FEFF}]+\z/
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Whether the API would refuse a custom header with this name.
|
|
31
|
+
def reserved_header?(name)
|
|
32
|
+
lower = name.to_s.downcase
|
|
33
|
+
RESERVED_HEADERS.include?(lower) || lower.start_with?("x-ses-")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# `addr`, or `Name <addr>` with the name quoted when it needs to be.
|
|
37
|
+
def format_address(address, name = nil)
|
|
38
|
+
trimmed = name.nil? ? "" : trim(utf8(name))
|
|
39
|
+
return address if trimmed.empty?
|
|
40
|
+
|
|
41
|
+
display = if NAME_SPECIALS.match?(trimmed)
|
|
42
|
+
%("#{trimmed.gsub(/["\\]/) { |char| "\\#{char}" }}")
|
|
43
|
+
else
|
|
44
|
+
trimmed
|
|
45
|
+
end
|
|
46
|
+
"#{display} <#{address}>"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A header value as the API takes it: one line of printable ASCII. Folded lines are unfolded,
|
|
50
|
+
# other control characters become spaces, and anything non-ASCII becomes RFC 2047
|
|
51
|
+
# encoded-words (`=?UTF-8?B?…?=`). Returns `""` for a value that ends up empty.
|
|
52
|
+
def encode_header_value(value)
|
|
53
|
+
unfolded = trim(utf8(value.to_s).gsub(/\r?\n[ \t]*/, " ").gsub(/\p{Cc}/, " "))
|
|
54
|
+
return unfolded if PRINTABLE_ASCII.match?(unfolded)
|
|
55
|
+
|
|
56
|
+
words = []
|
|
57
|
+
chunk = +"".b
|
|
58
|
+
unfolded.each_char do |char|
|
|
59
|
+
if chunk.bytesize + char.bytesize > ENCODED_WORD_BYTES
|
|
60
|
+
words << encoded_word(chunk)
|
|
61
|
+
chunk = +"".b
|
|
62
|
+
end
|
|
63
|
+
chunk << char.b
|
|
64
|
+
end
|
|
65
|
+
words << encoded_word(chunk) unless chunk.empty?
|
|
66
|
+
words.join(" ")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Standard, padded base64 without line breaks.
|
|
70
|
+
def base64(bytes)
|
|
71
|
+
[bytes.to_s].pack("m0")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# `type/subtype` without parameters, lowercase (`text/plain; charset=utf-8` → `text/plain`),
|
|
75
|
+
# `nil` when there is none.
|
|
76
|
+
def media_type(content_type)
|
|
77
|
+
type = content_type.to_s.split(";").first.to_s.strip.downcase
|
|
78
|
+
type.empty? ? nil : type
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A Content-ID without its angle brackets (`<logo@acme>` → `logo@acme`), `nil` when empty.
|
|
82
|
+
def bare_content_id(content_id)
|
|
83
|
+
id = content_id.to_s.strip.delete_prefix("<").delete_suffix(">")
|
|
84
|
+
id.empty? ? nil : id
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# `value` as a valid UTF-8 string: text in another known encoding is transcoded, bytes of
|
|
88
|
+
# unknown encoding (binary) are read as UTF-8, and invalid bytes are replaced.
|
|
89
|
+
def utf8(value)
|
|
90
|
+
string = value.to_s
|
|
91
|
+
if string.encoding == Encoding::BINARY
|
|
92
|
+
string = string.dup.force_encoding(Encoding::UTF_8)
|
|
93
|
+
elsif string.encoding != Encoding::UTF_8
|
|
94
|
+
string = string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
95
|
+
end
|
|
96
|
+
string.valid_encoding? ? string : string.scrub
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def trim(string)
|
|
100
|
+
string.sub(TRIM_LEADING, "").sub(TRIM_TRAILING, "")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def encoded_word(bytes)
|
|
104
|
+
"=?UTF-8?B?#{base64(bytes)}?="
|
|
105
|
+
end
|
|
106
|
+
private_class_method :encoded_word
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Coss
|
|
6
|
+
module Rails
|
|
7
|
+
# coss.com's own fields for one message (`category`, `tags`, `scheduled_at`, `tracking`, the
|
|
8
|
+
# idempotency key), validated and turned into what the API takes.
|
|
9
|
+
module Options
|
|
10
|
+
KEYS = %i[category tags scheduled_at tracking idempotency_key].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Validates `options` (a Hash with `KEYS`) and returns them ready for the request body:
|
|
15
|
+
# `tags` as `[{ "name" => …, "value" => … }]`, `scheduled_at` as a UTC
|
|
16
|
+
# `YYYY-MM-DDTHH:MM:SSZ` string (a String is kept as given), `tracking` with string keys.
|
|
17
|
+
# `nil` values are left out. Raises ArgumentError for anything else.
|
|
18
|
+
def normalize(options)
|
|
19
|
+
raise ArgumentError, "coss.com options must be a Hash, got #{options.class}" unless options.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
unknown = options.keys.map(&:to_sym) - KEYS
|
|
22
|
+
unless unknown.empty?
|
|
23
|
+
raise ArgumentError, "unknown coss.com option(s): #{unknown.join(', ')} (known: #{KEYS.join(', ')})"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
options.each_with_object({}) do |(key, value), normalized|
|
|
27
|
+
next if value.nil?
|
|
28
|
+
|
|
29
|
+
key = key.to_sym
|
|
30
|
+
normalized[key] = send(:"normalize_#{key}", value)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def normalize_category(value)
|
|
35
|
+
string(value, "category")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def normalize_idempotency_key(value)
|
|
39
|
+
string(value, "idempotency_key")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def normalize_tags(value)
|
|
43
|
+
case value
|
|
44
|
+
when Hash
|
|
45
|
+
value.map { |name, tag_value| tag(name, tag_value) }
|
|
46
|
+
when Array
|
|
47
|
+
value.map do |entry|
|
|
48
|
+
unless entry.is_a?(Hash)
|
|
49
|
+
raise ArgumentError, "coss.com tags must be a Hash or an Array of { name:, value: } Hashes"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
tag(entry[:name] || entry["name"], entry[:value] || entry["value"])
|
|
53
|
+
end
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError, "coss.com tags must be a Hash or an Array of { name:, value: } Hashes"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize_scheduled_at(value)
|
|
60
|
+
case value
|
|
61
|
+
when String
|
|
62
|
+
value
|
|
63
|
+
when DateTime
|
|
64
|
+
value.to_time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
65
|
+
when Time
|
|
66
|
+
value.getutc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
67
|
+
else
|
|
68
|
+
# ActiveSupport::TimeWithZone is_a?(Time) already; this covers other time-likes.
|
|
69
|
+
return value.utc.strftime("%Y-%m-%dT%H:%M:%SZ") if value.respond_to?(:utc) && !value.is_a?(Date)
|
|
70
|
+
|
|
71
|
+
raise ArgumentError,
|
|
72
|
+
"coss.com scheduled_at must be a Time, DateTime, ActiveSupport::TimeWithZone or String, " \
|
|
73
|
+
"got #{value.class}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def normalize_tracking(value)
|
|
78
|
+
unless value.is_a?(Hash)
|
|
79
|
+
raise ArgumentError,
|
|
80
|
+
"coss.com tracking must be a Hash like { opens: true, clicks: false }"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
value.to_h { |key, setting| [key.to_s, setting] }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def tag(name, value)
|
|
87
|
+
raise ArgumentError, "a coss.com tag needs a name and a value" if name.nil? || value.nil?
|
|
88
|
+
|
|
89
|
+
{ "name" => name.to_s, "value" => value.to_s }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def string(value, field)
|
|
93
|
+
return value if value.is_a?(String)
|
|
94
|
+
return value.to_s if value.is_a?(Symbol)
|
|
95
|
+
|
|
96
|
+
raise ArgumentError, "coss.com #{field} must be a String, got #{value.class}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private_class_method :normalize_category, :normalize_idempotency_key, :normalize_tags,
|
|
100
|
+
:normalize_scheduled_at, :normalize_tracking, :tag, :string
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coss
|
|
4
|
+
module Rails
|
|
5
|
+
# Registers `:coss` before Action Mailer applies `config.action_mailer.*`, so
|
|
6
|
+
# `config.action_mailer.coss_settings = { ... }` has somewhere to go.
|
|
7
|
+
class Railtie < ::Rails::Railtie
|
|
8
|
+
initializer "coss_rails.add_delivery_method", before: "action_mailer.set_configs" do
|
|
9
|
+
ActiveSupport.on_load(:action_mailer) { Coss::Rails.install(self) }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/coss/rails.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mail"
|
|
4
|
+
|
|
5
|
+
require_relative "rails/version"
|
|
6
|
+
require_relative "rails/errors"
|
|
7
|
+
require_relative "rails/mime"
|
|
8
|
+
require_relative "rails/options"
|
|
9
|
+
require_relative "rails/client"
|
|
10
|
+
require_relative "rails/message"
|
|
11
|
+
require_relative "rails/message_mapper"
|
|
12
|
+
require_relative "rails/delivery_method"
|
|
13
|
+
require_relative "rails/mailer"
|
|
14
|
+
|
|
15
|
+
module Coss
|
|
16
|
+
# Sends Action Mailer messages through the coss.com API (`POST /v1/emails`).
|
|
17
|
+
#
|
|
18
|
+
# # config/environments/production.rb
|
|
19
|
+
# config.action_mailer.delivery_method = :coss
|
|
20
|
+
# config.action_mailer.coss_settings = { api_key: ENV["COSS_API_KEY"] }
|
|
21
|
+
module Rails
|
|
22
|
+
# Registers the `:coss` delivery method on `base` (normally `ActionMailer::Base`) and adds
|
|
23
|
+
# `coss(...)` to its mailers. The Railtie calls this; call it yourself without Rails.
|
|
24
|
+
def self.install(base)
|
|
25
|
+
base.add_delivery_method(:coss, DeliveryMethod) unless base.delivery_methods.key?(:coss)
|
|
26
|
+
base.include(Mailer) unless base < Mailer
|
|
27
|
+
base
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
require_relative "rails/railtie" if defined?(Rails::Railtie)
|
data/lib/coss-rails.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: coss-rails
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Cal.com, Inc.
|
|
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'
|
|
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'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: railties
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '7.2'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '9'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '7.2'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '9'
|
|
52
|
+
description: Send your Rails app's Action Mailer emails through coss.com by changing
|
|
53
|
+
configuration, not call sites.
|
|
54
|
+
executables: []
|
|
55
|
+
extensions: []
|
|
56
|
+
extra_rdoc_files: []
|
|
57
|
+
files:
|
|
58
|
+
- CHANGELOG.md
|
|
59
|
+
- LICENSE
|
|
60
|
+
- README.md
|
|
61
|
+
- lib/coss-rails.rb
|
|
62
|
+
- lib/coss/rails.rb
|
|
63
|
+
- lib/coss/rails/client.rb
|
|
64
|
+
- lib/coss/rails/delivery_method.rb
|
|
65
|
+
- lib/coss/rails/errors.rb
|
|
66
|
+
- lib/coss/rails/mailer.rb
|
|
67
|
+
- lib/coss/rails/message.rb
|
|
68
|
+
- lib/coss/rails/message_mapper.rb
|
|
69
|
+
- lib/coss/rails/mime.rb
|
|
70
|
+
- lib/coss/rails/options.rb
|
|
71
|
+
- lib/coss/rails/railtie.rb
|
|
72
|
+
- lib/coss/rails/version.rb
|
|
73
|
+
homepage: https://coss.com/docs/integrations/rails
|
|
74
|
+
licenses:
|
|
75
|
+
- MIT
|
|
76
|
+
metadata:
|
|
77
|
+
homepage_uri: https://coss.com/docs/integrations/rails
|
|
78
|
+
source_code_uri: https://github.com/cosscom/coss/tree/main/integrations/rails
|
|
79
|
+
changelog_uri: https://github.com/cosscom/coss/blob/main/integrations/rails/CHANGELOG.md
|
|
80
|
+
bug_tracker_uri: https://github.com/cosscom/coss/issues
|
|
81
|
+
rubygems_mfa_required: 'true'
|
|
82
|
+
rdoc_options: []
|
|
83
|
+
require_paths:
|
|
84
|
+
- lib
|
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.2'
|
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0'
|
|
95
|
+
requirements: []
|
|
96
|
+
rubygems_version: 3.6.9
|
|
97
|
+
specification_version: 4
|
|
98
|
+
summary: An Action Mailer delivery method that sends through the coss.com API.
|
|
99
|
+
test_files: []
|