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 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
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release: the `:coss` Action Mailer delivery method, `coss(...)` in mailers for
6
+ coss.com's own fields (category, tags, scheduled sends, tracking, idempotency keys), and
7
+ `message.coss_email` with the email coss.com accepted.
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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coss
4
+ module Rails
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "coss/rails"
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: []