camelmailer 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: f9d144156fc057f5dede175ed29f43b9c6587a33ae84b7b25c097a814c1537b7
4
+ data.tar.gz: 482b37a49af5b42f3a527788a59c1dfaeb115797ed985c202deb7841c8fd8058
5
+ SHA512:
6
+ metadata.gz: 865924a5a43a2087e6e73abfb70e5fbb677b7cf9d961aa893bca18eec7859320f7a879d97a4d22d16e37d1b1840f1490ea1436692a357aa74aa24302a4d29801
7
+ data.tar.gz: a14c759e870581dec25f9a8eb3bd1cdf2e24ba4e9a9989d9128193e45858ee1f51022dc5a7886e33f31f7d2c61d6c5b60766f42fd95a498c85e892282cfb15b3
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-07-11
11
+
12
+ ### Added
13
+
14
+ - `CamelMailer::Client` on stdlib net/http — zero runtime dependencies.
15
+ - Global configuration via `CamelMailer.configure` (api_key, base_url for self-hosted instances).
16
+ - Messaging resources: `Emails` (send, send_batch, send_with_template, send_with_template_batch, get, list, deliveries, opens, clicks, raw), `Templates` (list, create, get, update, archive, render), `Streams` (list, create, get, update, archive), `Stats` (get, deliveries), `Bounces` (list, get), `Dmarc` (summary, reports, report).
17
+ - Typed error hierarchy with `code`, `message` and `status_code` (`UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ValidationError`, `ParameterMissingError`, `ServerError`, `ConnectionError`).
18
+ - Rails integration: `:camelmailer` ActionMailer delivery method with full MIME mapping (from/to/cc/bcc/reply_to, html+text parts, attachments, custom headers, tag/stream/metadata pseudo-headers).
19
+ - RBS type signatures.
20
+
21
+ [Unreleased]: https://github.com/camelmailer/camelmailer-ruby/compare/v0.1.0...HEAD
22
+ [0.1.0]: https://github.com/camelmailer/camelmailer-ruby/releases/tag/v0.1.0
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,34 @@
1
+ # Contributing
2
+
3
+ ## Setup
4
+
5
+ ```sh
6
+ git clone https://github.com/camelmailer/camelmailer-ruby
7
+ cd camelmailer-ruby
8
+ bundle install
9
+ ```
10
+
11
+ Requires Ruby >= 3.1.
12
+
13
+ ## Tests & lint
14
+
15
+ ```sh
16
+ bundle exec rspec # unit tests (WebMock, no network)
17
+ bundle exec rubocop # lint
18
+ bundle exec rake # both
19
+ ```
20
+
21
+ Integration tests run against a real instance and are skipped without credentials:
22
+
23
+ ```sh
24
+ CAMELMAILER_API_KEY=cm_... CAMELMAILER_BASE_URL=https://mail.example.com \
25
+ CAMELMAILER_FROM=billing@acme.com CAMELMAILER_TO=inbox@example.com \
26
+ bundle exec rspec spec/integration
27
+ ```
28
+
29
+ ## Conventions
30
+
31
+ - Test-first: every resource method and error path has a spec against a mocked HTTP layer.
32
+ - No runtime dependencies — stdlib `net/http` only.
33
+ - Follow the existing envelope handling in `CamelMailer::Client`; new endpoints go on the matching resource class plus an `expose` entry for the class-level API.
34
+ - Update `CHANGELOG.md` (Keep a Changelog) with user-visible changes.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Camelmailer contributors
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,167 @@
1
+ # camelmailer-ruby
2
+
3
+ [![CI](https://github.com/camelmailer/camelmailer-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/camelmailer/camelmailer-ruby/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/camelmailer)](https://rubygems.org/gems/camelmailer)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+
7
+ The Ruby and Rails SDK for [Camelmailer](https://camelmailer.com) — open-source transactional email. No runtime dependencies, Ruby >= 3.1.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ gem install camelmailer
13
+ # or in your Gemfile
14
+ gem "camelmailer"
15
+ ```
16
+
17
+ ## Quickstart
18
+
19
+ ```ruby
20
+ require "camelmailer"
21
+
22
+ CamelMailer.configure { |c| c.api_key = "cm_..." }
23
+
24
+ CamelMailer::Emails.send(
25
+ from: "billing@acme.com",
26
+ to: ["ada@example.com"],
27
+ subject: "Your receipt",
28
+ html_body: "<h1>Thanks!</h1>"
29
+ )
30
+ ```
31
+
32
+ Or without global state:
33
+
34
+ ```ruby
35
+ client = CamelMailer::Client.new(api_key: "cm_...")
36
+ client.emails.send(from: "billing@acme.com", to: ["ada@example.com"], subject: "Hi", text_body: "Hello")
37
+ ```
38
+
39
+ Every resource is available both ways: `CamelMailer::Emails.send(...)` uses the global configuration, `client.emails.send(...)` uses an explicit client.
40
+
41
+ ## Self-hosted
42
+
43
+ The base URL defaults to the Camelmailer cloud (`https://app.camelmailer.com`). Point it at your own instance:
44
+
45
+ ```ruby
46
+ CamelMailer.configure do |c|
47
+ c.api_key = "cm_..."
48
+ c.base_url = "https://mail.example.com"
49
+ end
50
+ ```
51
+
52
+ ## Rails
53
+
54
+ The gem registers the `:camelmailer` ActionMailer delivery method automatically:
55
+
56
+ ```ruby
57
+ # config/environments/production.rb
58
+ config.action_mailer.delivery_method = :camelmailer
59
+ config.action_mailer.camelmailer_settings = {
60
+ api_key: Rails.application.credentials.dig(:camelmailer, :api_key),
61
+ base_url: "https://mail.example.com" # optional, self-hosted only
62
+ }
63
+ ```
64
+
65
+ Then use ActionMailer as usual — including attachments, multipart bodies and custom headers:
66
+
67
+ ```ruby
68
+ class ReceiptMailer < ApplicationMailer
69
+ def receipt
70
+ attachments["invoice.pdf"] = File.read("invoice.pdf")
71
+ mail(
72
+ from: "billing@acme.com",
73
+ to: params[:to],
74
+ subject: "Your receipt",
75
+ tag: "receipt", # CamelMailer tag (filtering & stats)
76
+ stream: "transactional" # optional message stream
77
+ )
78
+ end
79
+ end
80
+ ```
81
+
82
+ ## Resources
83
+
84
+ ### Emails
85
+
86
+ ```ruby
87
+ CamelMailer::Emails.send(from:, to:, subject:, html_body:, text_body:, cc:, bcc:, reply_to:, headers:, attachments:, tag:, metadata:, stream:)
88
+ CamelMailer::Emails.send_batch([{ from:, to:, ... }, ...])
89
+ CamelMailer::Emails.send_with_template(from:, to:, template: "welcome", template_model: { name: "Ada" })
90
+ CamelMailer::Emails.send_with_template_batch([...])
91
+ CamelMailer::Emails.get(42)
92
+ CamelMailer::Emails.list(scope: "outgoing", status: "Sent", tag: "receipt", query: "ada", page: 1, per_page: 50)
93
+ CamelMailer::Emails.deliveries(42) # delivery attempts
94
+ CamelMailer::Emails.opens(42) # open events
95
+ CamelMailer::Emails.clicks(42) # click events
96
+ CamelMailer::Emails.raw(42) # raw RFC 5322 source
97
+ ```
98
+
99
+ Attachments are `{ name:, content_type:, data_base64: }`; addresses are either `"a@b.com"` or `{ email: "a@b.com", name: "Ada" }`.
100
+
101
+ ### Templates
102
+
103
+ ```ruby
104
+ CamelMailer::Templates.list
105
+ CamelMailer::Templates.create(name: "Welcome", subject: "Hi {{ name }}", html_body: "<p>Hi {{ name }}</p>")
106
+ CamelMailer::Templates.get("welcome")
107
+ CamelMailer::Templates.update("welcome", subject: "Hello {{ name }}")
108
+ CamelMailer::Templates.archive("welcome")
109
+ CamelMailer::Templates.render("welcome", { name: "Ada" }) # preview without sending
110
+ ```
111
+
112
+ ### Streams
113
+
114
+ ```ruby
115
+ CamelMailer::Streams.list
116
+ CamelMailer::Streams.create(name: "Broadcasts", stream_type: "broadcast")
117
+ CamelMailer::Streams.get("broadcasts")
118
+ CamelMailer::Streams.update("broadcasts", name: "News")
119
+ CamelMailer::Streams.archive("broadcasts")
120
+ ```
121
+
122
+ ### Stats & bounces
123
+
124
+ ```ruby
125
+ CamelMailer::Stats.get(from: "2026-01-01T00:00:00Z", to: "2026-02-01T00:00:00Z")
126
+ CamelMailer::Stats.deliveries
127
+ CamelMailer::Bounces.list(page: 1, per_page: 50)
128
+ CamelMailer::Bounces.get(42)
129
+ ```
130
+
131
+ ### DMARC
132
+
133
+ ```ruby
134
+ CamelMailer::Dmarc.summary(domain: "acme.com")
135
+ CamelMailer::Dmarc.reports(domain: "acme.com", page: 1)
136
+ CamelMailer::Dmarc.report(3)
137
+ ```
138
+
139
+ ## Error handling
140
+
141
+ All API errors raise typed exceptions with `code`, `message` and `status_code`:
142
+
143
+ ```ruby
144
+ begin
145
+ CamelMailer::Emails.send(from: "x@unverified.test", to: ["a@b.com"])
146
+ rescue CamelMailer::ValidationError => e
147
+ e.code # => "ValidationError"
148
+ e.message # => "from domain not verified"
149
+ e.status_code # => 422
150
+ rescue CamelMailer::UnauthorizedError
151
+ # bad API key
152
+ rescue CamelMailer::ConnectionError
153
+ # network problem
154
+ rescue CamelMailer::Error => e
155
+ # everything above inherits from this
156
+ end
157
+ ```
158
+
159
+ Hierarchy: `Error` → `ConnectionError`, `APIError` → `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ValidationError`, `ParameterMissingError`, `ServerError`.
160
+
161
+ ## Docs
162
+
163
+ Full API reference: [camelmailer.com/docs](https://camelmailer.com/docs)
164
+
165
+ ## License
166
+
167
+ [MIT](LICENSE)
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamelMailer
4
+ # Read bounce messages (/api/v2/server/bounces...).
5
+ class Bounces < Resource
6
+ expose :list, :get
7
+
8
+ # Lists bounces. Filters: page:, per_page: (max 100).
9
+ def list(**filters)
10
+ client.get("bounces", filters)
11
+ end
12
+
13
+ def get(id)
14
+ client.get("bounces/#{id}")
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ module CamelMailer
9
+ # HTTP client for the CamelMailer messaging API (/api/v2/server).
10
+ #
11
+ # client = CamelMailer::Client.new(api_key: "cm_...")
12
+ # client.emails.send(from: "a@acme.com", to: ["b@example.com"], subject: "Hi", text_body: "Hello")
13
+ #
14
+ # Built on net/http — the gem has no runtime dependencies.
15
+ class Client
16
+ API_PREFIX = "/api/v2/server"
17
+
18
+ NETWORK_ERRORS = [
19
+ SocketError, EOFError, Errno::ECONNREFUSED, Errno::ECONNRESET,
20
+ Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Errno::EPIPE,
21
+ Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
22
+ ].freeze
23
+
24
+ attr_reader :api_key, :base_url, :open_timeout, :read_timeout
25
+
26
+ def initialize(api_key:, base_url: nil, open_timeout: 10, read_timeout: 30)
27
+ if api_key.nil? || api_key.to_s.strip.empty?
28
+ raise Error, "No CamelMailer API key provided — set CamelMailer.api_key or pass api_key:"
29
+ end
30
+
31
+ @api_key = api_key
32
+ @base_url = (base_url || DEFAULT_BASE_URL).to_s.chomp("/")
33
+ @open_timeout = open_timeout
34
+ @read_timeout = read_timeout
35
+ end
36
+
37
+ def emails
38
+ @emails ||= Emails.new(self)
39
+ end
40
+
41
+ def templates
42
+ @templates ||= Templates.new(self)
43
+ end
44
+
45
+ def streams
46
+ @streams ||= Streams.new(self)
47
+ end
48
+
49
+ def stats
50
+ @stats ||= Stats.new(self)
51
+ end
52
+
53
+ def bounces
54
+ @bounces ||= Bounces.new(self)
55
+ end
56
+
57
+ def dmarc
58
+ @dmarc ||= Dmarc.new(self)
59
+ end
60
+
61
+ def get(path, query = nil)
62
+ perform(Net::HTTP::Get.new(build_uri(path, query)))
63
+ end
64
+
65
+ def post(path, body = nil)
66
+ req = Net::HTTP::Post.new(build_uri(path))
67
+ attach_body(req, body)
68
+ perform(req)
69
+ end
70
+
71
+ def patch(path, body = nil)
72
+ req = Net::HTTP::Patch.new(build_uri(path))
73
+ attach_body(req, body)
74
+ perform(req)
75
+ end
76
+
77
+ private
78
+
79
+ def build_uri(path, query = nil)
80
+ uri = URI.parse("#{base_url}#{API_PREFIX}/#{path}")
81
+ if query
82
+ params = query.compact
83
+ uri.query = URI.encode_www_form(params) unless params.empty?
84
+ end
85
+ uri
86
+ end
87
+
88
+ def attach_body(req, body)
89
+ return unless body
90
+
91
+ req["Content-Type"] = "application/json"
92
+ req.body = JSON.generate(body)
93
+ end
94
+
95
+ def perform(req)
96
+ req["X-Server-API-Key"] = api_key
97
+ req["Accept"] = "application/json"
98
+ req["User-Agent"] = "camelmailer-ruby/#{VERSION}"
99
+
100
+ handle(transport(req.uri).request(req))
101
+ rescue *NETWORK_ERRORS => e
102
+ raise ConnectionError, "Could not reach the CamelMailer API: #{e.class}: #{e.message}"
103
+ end
104
+
105
+ def transport(uri)
106
+ http = Net::HTTP.new(uri.host, uri.port)
107
+ http.use_ssl = uri.scheme == "https"
108
+ http.open_timeout = open_timeout
109
+ http.read_timeout = read_timeout
110
+ http
111
+ end
112
+
113
+ def handle(res)
114
+ status = res.code.to_i
115
+ body = parse_body(res.body)
116
+
117
+ case body
118
+ in { status: "success" }
119
+ body[:data] || {}
120
+ in { status: "error" }
121
+ error = body[:error] || {}
122
+ raise APIError.for(code: error[:code], message: error[:message], status_code: status)
123
+ else
124
+ return body.is_a?(Hash) ? body : {} if (200..299).cover?(status)
125
+
126
+ raise ServerError.new("Unexpected HTTP #{status} response from the CamelMailer API", status_code: status)
127
+ end
128
+ end
129
+
130
+ def parse_body(raw)
131
+ return nil if raw.nil? || raw.empty?
132
+
133
+ JSON.parse(raw, symbolize_names: true)
134
+ rescue JSON::ParserError
135
+ nil
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamelMailer
4
+ # DMARC compliance summary and aggregate reports (/api/v2/server/dmarc...).
5
+ class Dmarc < Resource
6
+ expose :summary, :reports, :report
7
+
8
+ # Compliance summary. Filters: domain:, from:, to: (ISO 8601).
9
+ def summary(domain: nil, from: nil, to: nil)
10
+ client.get("dmarc/summary", { domain: domain, from: from, to: to })
11
+ end
12
+
13
+ # Stored aggregate reports, newest report range first.
14
+ def reports(domain: nil, from: nil, to: nil, page: nil, per_page: nil)
15
+ client.get("dmarc/reports", { domain: domain, from: from, to: to, page: page, per_page: per_page })
16
+ end
17
+
18
+ # One report with its records.
19
+ def report(id)
20
+ client.get("dmarc/reports/#{id}")
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamelMailer
4
+ # Send and read messages (POST/GET /api/v2/server/messages...).
5
+ class Emails < Resource
6
+ expose :send, :send_batch, :send_with_template, :send_with_template_batch,
7
+ :get, :list, :deliveries, :opens, :clicks, :raw
8
+
9
+ # Sends one message. Required: from:, to:. See the API docs for the
10
+ # full SendRequest shape (html_body, text_body, cc, bcc, reply_to,
11
+ # headers, attachments, tag, metadata, stream).
12
+ def send(params)
13
+ client.post("messages", params)
14
+ end
15
+
16
+ # Sends a batch of SendRequest hashes; returns one result per entry.
17
+ def send_batch(messages)
18
+ client.post("messages/batch", { messages: messages })
19
+ end
20
+
21
+ # Renders a stored template against template_model:, then sends.
22
+ def send_with_template(params)
23
+ client.post("messages/with_template", params)
24
+ end
25
+
26
+ # Batch variant of #send_with_template.
27
+ def send_with_template_batch(messages)
28
+ client.post("messages/with_template/batch", { messages: messages })
29
+ end
30
+
31
+ # Shows one message by id.
32
+ def get(id)
33
+ client.get("messages/#{id}")
34
+ end
35
+
36
+ # Lists messages. Filters: scope:, status:, tag:, query:, stream:,
37
+ # page:, per_page: (max 100).
38
+ def list(**filters)
39
+ client.get("messages", filters)
40
+ end
41
+
42
+ # Delivery attempts of a message.
43
+ def deliveries(id)
44
+ client.get("messages/#{id}/deliveries")
45
+ end
46
+
47
+ # Open events of a message.
48
+ def opens(id)
49
+ client.get("messages/#{id}/opens")
50
+ end
51
+
52
+ # Click events of a message.
53
+ def clicks(id)
54
+ client.get("messages/#{id}/clicks")
55
+ end
56
+
57
+ # Raw RFC 5322 source of a message.
58
+ def raw(id)
59
+ client.get("messages/#{id}/raw")
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamelMailer
4
+ # Base error for everything raised by this gem.
5
+ #
6
+ # Exposes the stable API error +code+ (e.g. "ValidationError") and the
7
+ # HTTP +status_code+ of the response, when available.
8
+ class Error < StandardError
9
+ attr_reader :code, :status_code
10
+
11
+ def initialize(message = nil, code: nil, status_code: nil)
12
+ super(message)
13
+ @code = code
14
+ @status_code = status_code
15
+ end
16
+ end
17
+
18
+ # Raised when the API could not be reached at all (DNS, TCP, TLS, timeouts).
19
+ class ConnectionError < Error; end
20
+
21
+ # Raised for any error envelope returned by the API. Specific stable
22
+ # error codes map to subclasses; unknown codes raise APIError itself.
23
+ class APIError < Error
24
+ # Builds the most specific error class for an error envelope.
25
+ def self.for(code:, message:, status_code: nil)
26
+ klass = ERROR_CODE_CLASSES.fetch(code, APIError)
27
+ klass.new(message || code || "CamelMailer API error", code: code, status_code: status_code)
28
+ end
29
+ end
30
+
31
+ # 401 — missing or invalid API key.
32
+ class UnauthorizedError < APIError; end
33
+
34
+ # 403 — the key is valid but not allowed to do this.
35
+ class ForbiddenError < APIError; end
36
+
37
+ # 404 — no such resource.
38
+ class NotFoundError < APIError; end
39
+
40
+ # 422 — the request was understood but invalid.
41
+ class ValidationError < APIError; end
42
+
43
+ # 400 — a required parameter is missing.
44
+ class ParameterMissingError < APIError; end
45
+
46
+ # Unexpected non-envelope response (e.g. a 5xx from a proxy).
47
+ class ServerError < APIError; end
48
+
49
+ APIError::ERROR_CODE_CLASSES = {
50
+ "Unauthorized" => UnauthorizedError,
51
+ "Forbidden" => ForbiddenError,
52
+ "NotFound" => NotFoundError,
53
+ "ValidationError" => ValidationError,
54
+ "ParameterMissing" => ParameterMissingError
55
+ }.freeze
56
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "camelmailer"
4
+
5
+ module CamelMailer
6
+ # ActionMailer delivery method (:camelmailer).
7
+ #
8
+ # config.action_mailer.delivery_method = :camelmailer
9
+ # config.action_mailer.camelmailer_settings = {
10
+ # api_key: Rails.application.credentials.dig(:camelmailer, :api_key),
11
+ # base_url: "https://mail.example.com" # optional, for self-hosted
12
+ # }
13
+ #
14
+ # Falls back to the global CamelMailer.configure settings when the
15
+ # delivery-method settings do not provide api_key/base_url.
16
+ #
17
+ # Special mail headers `tag`, `stream` and `metadata` are lifted out of
18
+ # the MIME headers and sent as first-class API fields:
19
+ #
20
+ # mail(to: ..., subject: ..., tag: "receipt", stream: "transactional")
21
+ class Mailer
22
+ # MIME headers that are structural or sent as dedicated API fields —
23
+ # everything else is forwarded via the +headers+ API parameter.
24
+ IGNORED_HEADERS = %w[
25
+ from to cc bcc reply-to subject date message-id mime-version
26
+ content-type content-transfer-encoding return-path received
27
+ tag stream metadata headers
28
+ ].freeze
29
+
30
+ attr_accessor :settings
31
+
32
+ def initialize(settings = {})
33
+ @settings = settings || {}
34
+ end
35
+
36
+ # Delivers a Mail::Message through the CamelMailer API and assigns
37
+ # the resulting message id back onto the mail object.
38
+ def deliver!(mail)
39
+ data = Emails.new(client).send(build_params(mail))
40
+ mail.message_id = data[:message_id].to_s if data.is_a?(Hash) && data[:message_id]
41
+ data
42
+ end
43
+
44
+ # Maps a Mail::Message onto a CamelMailer SendRequest hash.
45
+ def build_params(mail)
46
+ params = {
47
+ from: single_address(mail[:from]),
48
+ to: address_list(mail[:to]),
49
+ cc: address_list(mail[:cc]),
50
+ bcc: address_list(mail[:bcc]),
51
+ reply_to: address_list(mail[:reply_to]),
52
+ subject: mail.subject
53
+ }
54
+ params.merge!(contents(mail))
55
+ params.merge!(pseudo_headers(mail))
56
+ headers = custom_headers(mail)
57
+ params[:headers] = headers unless headers.empty?
58
+ attachments = build_attachments(mail)
59
+ params[:attachments] = attachments unless attachments.empty?
60
+ params.compact
61
+ end
62
+
63
+ private
64
+
65
+ def client
66
+ Client.new(
67
+ api_key: settings[:api_key] || CamelMailer.api_key,
68
+ base_url: settings[:base_url] || CamelMailer.base_url
69
+ )
70
+ end
71
+
72
+ def single_address(field)
73
+ address_list(field)&.first
74
+ end
75
+
76
+ def address_list(field)
77
+ return nil unless field
78
+
79
+ field.addrs.map do |addr|
80
+ if addr.display_name
81
+ { email: addr.address, name: addr.display_name }
82
+ else
83
+ addr.address
84
+ end
85
+ end
86
+ end
87
+
88
+ def contents(mail)
89
+ case mail.mime_type
90
+ when "text/html"
91
+ { html_body: mail.body.decoded }
92
+ when "multipart/alternative", "multipart/mixed", "multipart/related"
93
+ multipart_contents(mail)
94
+ else
95
+ { text_body: mail.body.decoded }
96
+ end
97
+ end
98
+
99
+ def multipart_contents(mail)
100
+ params = {}
101
+ params[:text_body] = mail.text_part.decoded if mail.text_part
102
+ params[:html_body] = mail.html_part.decoded if mail.html_part
103
+ params
104
+ end
105
+
106
+ # Lifts the tag/stream/metadata pseudo-headers into API fields.
107
+ def pseudo_headers(mail)
108
+ params = {}
109
+ params[:tag] = mail["tag"].unparsed_value if mail["tag"]
110
+ params[:stream] = mail["stream"].unparsed_value if mail["stream"]
111
+ params[:metadata] = mail["metadata"].unparsed_value if mail["metadata"]
112
+ params
113
+ end
114
+
115
+ def custom_headers(mail)
116
+ mail.header_fields.each_with_object({}) do |field, headers|
117
+ next if IGNORED_HEADERS.include?(field.name.downcase)
118
+
119
+ value = field.unparsed_value
120
+ headers[field.name] = value.to_s unless value.nil?
121
+ end
122
+ end
123
+
124
+ def build_attachments(mail)
125
+ mail.attachments.map do |part|
126
+ {
127
+ name: part.filename,
128
+ content_type: part.mime_type,
129
+ data_base64: [part.body.decoded].pack("m0")
130
+ }
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "camelmailer"
4
+ require "camelmailer/mailer"
5
+
6
+ module CamelMailer
7
+ # Registers the :camelmailer ActionMailer delivery method in Rails apps.
8
+ class Railtie < ::Rails::Railtie
9
+ ActiveSupport.on_load(:action_mailer) do
10
+ add_delivery_method :camelmailer, CamelMailer::Mailer
11
+ ActiveSupport.run_load_hooks(:camelmailer_mailer, CamelMailer::Mailer)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamelMailer
4
+ # Base class for API resources.
5
+ #
6
+ # Resources are used through a client instance (client.emails.send ...)
7
+ # or through class-level convenience methods (CamelMailer::Emails.send ...)
8
+ # that use the globally configured client.
9
+ class Resource
10
+ # Defines class-level methods that delegate to an instance built
11
+ # from the global configuration (CamelMailer.configure).
12
+ def self.expose(*names)
13
+ names.each do |name|
14
+ define_singleton_method(name) do |*args, **kwargs|
15
+ new.public_send(name, *args, **kwargs)
16
+ end
17
+ end
18
+ end
19
+
20
+ attr_reader :client
21
+
22
+ def initialize(client = CamelMailer.client)
23
+ @client = client
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamelMailer
4
+ # Message counters and delivery statistics (/api/v2/server/stats...).
5
+ class Stats < Resource
6
+ expose :get, :deliveries
7
+
8
+ # Message counters, optionally windowed with from:/to: (ISO 8601).
9
+ def get(from: nil, to: nil)
10
+ client.get("stats", { from: from, to: to })
11
+ end
12
+
13
+ # Delivery/queue statistics.
14
+ def deliveries
15
+ client.get("stats/deliveries")
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamelMailer
4
+ # Manage message streams (/api/v2/server/streams...).
5
+ class Streams < Resource
6
+ expose :list, :create, :get, :update, :archive
7
+
8
+ def list
9
+ client.get("streams")
10
+ end
11
+
12
+ # Creates a stream. Required: name:. Optional: stream_type:
13
+ # ("transactional" or "broadcast").
14
+ def create(params)
15
+ client.post("streams", params)
16
+ end
17
+
18
+ def get(permalink)
19
+ client.get("streams/#{permalink}")
20
+ end
21
+
22
+ def update(permalink, params)
23
+ client.patch("streams/#{permalink}", params)
24
+ end
25
+
26
+ def archive(permalink)
27
+ client.post("streams/#{permalink}/archive")
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamelMailer
4
+ # Manage stored message templates (/api/v2/server/templates...).
5
+ class Templates < Resource
6
+ expose :list, :create, :get, :update, :archive, :render
7
+
8
+ def list
9
+ client.get("templates")
10
+ end
11
+
12
+ # Creates a template. Required: name:. Optional: subject:, html_body:,
13
+ # text_body: — all may contain {{ variables }}.
14
+ def create(params)
15
+ client.post("templates", params)
16
+ end
17
+
18
+ def get(permalink)
19
+ client.get("templates/#{permalink}")
20
+ end
21
+
22
+ def update(permalink, params)
23
+ client.patch("templates/#{permalink}", params)
24
+ end
25
+
26
+ def archive(permalink)
27
+ client.post("templates/#{permalink}/archive")
28
+ end
29
+
30
+ # Renders the template against a model without sending anything.
31
+ def render(permalink, template_model = {})
32
+ client.post("templates/#{permalink}/render", { template_model: template_model })
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamelMailer
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "camelmailer/version"
4
+ require "camelmailer/errors"
5
+ require "camelmailer/client"
6
+ require "camelmailer/resource"
7
+ require "camelmailer/emails"
8
+ require "camelmailer/templates"
9
+ require "camelmailer/streams"
10
+ require "camelmailer/stats"
11
+ require "camelmailer/bounces"
12
+ require "camelmailer/dmarc"
13
+
14
+ # The Ruby SDK for CamelMailer — https://camelmailer.com
15
+ #
16
+ # CamelMailer.configure do |config|
17
+ # config.api_key = "cm_..."
18
+ # config.base_url = "https://mail.example.com" # optional, for self-hosted
19
+ # end
20
+ #
21
+ # CamelMailer::Emails.send(from: "a@acme.com", to: ["b@example.com"],
22
+ # subject: "Hello", text_body: "Hi!")
23
+ #
24
+ # Or with an explicit client (no global state):
25
+ #
26
+ # client = CamelMailer::Client.new(api_key: "cm_...")
27
+ # client.emails.send(...)
28
+ module CamelMailer
29
+ DEFAULT_BASE_URL = "https://app.camelmailer.com"
30
+
31
+ class << self
32
+ attr_accessor :api_key
33
+ attr_writer :base_url
34
+
35
+ def base_url
36
+ @base_url || DEFAULT_BASE_URL
37
+ end
38
+
39
+ def configure
40
+ yield self if block_given?
41
+ self
42
+ end
43
+ alias config configure
44
+
45
+ # A client built from the global configuration.
46
+ def client
47
+ Client.new(api_key: api_key, base_url: base_url)
48
+ end
49
+
50
+ # Clears the global configuration (used in tests).
51
+ def reset!
52
+ @api_key = nil
53
+ @base_url = nil
54
+ end
55
+ end
56
+ end
57
+
58
+ require "camelmailer/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,170 @@
1
+ module CamelMailer
2
+ VERSION: String
3
+ DEFAULT_BASE_URL: String
4
+
5
+ type payload = Hash[Symbol | String, untyped]
6
+ type response = Hash[Symbol, untyped]
7
+
8
+ def self.api_key: () -> String?
9
+ def self.api_key=: (String?) -> String?
10
+ def self.base_url: () -> String
11
+ def self.base_url=: (String?) -> String?
12
+ def self.configure: () { (module) -> void } -> module
13
+ def self.config: () { (module) -> void } -> module
14
+ def self.client: () -> Client
15
+ def self.reset!: () -> void
16
+
17
+ class Error < StandardError
18
+ attr_reader code: String?
19
+ attr_reader status_code: Integer?
20
+
21
+ def initialize: (?String? message, ?code: String?, ?status_code: Integer?) -> void
22
+ end
23
+
24
+ class ConnectionError < Error
25
+ end
26
+
27
+ class APIError < Error
28
+ ERROR_CODE_CLASSES: Hash[String, singleton(APIError)]
29
+
30
+ def self.for: (code: String?, message: String?, ?status_code: Integer?) -> APIError
31
+ end
32
+
33
+ class UnauthorizedError < APIError
34
+ end
35
+
36
+ class ForbiddenError < APIError
37
+ end
38
+
39
+ class NotFoundError < APIError
40
+ end
41
+
42
+ class ValidationError < APIError
43
+ end
44
+
45
+ class ParameterMissingError < APIError
46
+ end
47
+
48
+ class ServerError < APIError
49
+ end
50
+
51
+ class Client
52
+ API_PREFIX: String
53
+ NETWORK_ERRORS: Array[singleton(Exception)]
54
+
55
+ attr_reader api_key: String
56
+ attr_reader base_url: String
57
+ attr_reader open_timeout: Integer
58
+ attr_reader read_timeout: Integer
59
+
60
+ def initialize: (api_key: String?, ?base_url: String?, ?open_timeout: Integer, ?read_timeout: Integer) -> void
61
+
62
+ def emails: () -> Emails
63
+ def templates: () -> Templates
64
+ def streams: () -> Streams
65
+ def stats: () -> Stats
66
+ def bounces: () -> Bounces
67
+ def dmarc: () -> Dmarc
68
+
69
+ def get: (String path, ?payload? query) -> response
70
+ def post: (String path, ?payload? body) -> response
71
+ def patch: (String path, ?payload? body) -> response
72
+ end
73
+
74
+ class Resource
75
+ attr_reader client: Client
76
+
77
+ def self.expose: (*Symbol names) -> void
78
+ def initialize: (?Client client) -> void
79
+ end
80
+
81
+ class Emails < Resource
82
+ def send: (payload params) -> response
83
+ def send_batch: (Array[payload] messages) -> response
84
+ def send_with_template: (payload params) -> response
85
+ def send_with_template_batch: (Array[payload] messages) -> response
86
+ def get: (Integer | String id) -> response
87
+ def list: (**untyped filters) -> response
88
+ def deliveries: (Integer | String id) -> response
89
+ def opens: (Integer | String id) -> response
90
+ def clicks: (Integer | String id) -> response
91
+ def raw: (Integer | String id) -> response
92
+
93
+ def self.send: (payload params) -> response
94
+ def self.send_batch: (Array[payload] messages) -> response
95
+ def self.send_with_template: (payload params) -> response
96
+ def self.send_with_template_batch: (Array[payload] messages) -> response
97
+ def self.get: (Integer | String id) -> response
98
+ def self.list: (**untyped filters) -> response
99
+ def self.deliveries: (Integer | String id) -> response
100
+ def self.opens: (Integer | String id) -> response
101
+ def self.clicks: (Integer | String id) -> response
102
+ def self.raw: (Integer | String id) -> response
103
+ end
104
+
105
+ class Templates < Resource
106
+ def list: () -> response
107
+ def create: (payload params) -> response
108
+ def get: (String permalink) -> response
109
+ def update: (String permalink, payload params) -> response
110
+ def archive: (String permalink) -> response
111
+ def render: (String permalink, ?payload template_model) -> response
112
+
113
+ def self.list: () -> response
114
+ def self.create: (payload params) -> response
115
+ def self.get: (String permalink) -> response
116
+ def self.update: (String permalink, payload params) -> response
117
+ def self.archive: (String permalink) -> response
118
+ def self.render: (String permalink, ?payload template_model) -> response
119
+ end
120
+
121
+ class Streams < Resource
122
+ def list: () -> response
123
+ def create: (payload params) -> response
124
+ def get: (String permalink) -> response
125
+ def update: (String permalink, payload params) -> response
126
+ def archive: (String permalink) -> response
127
+
128
+ def self.list: () -> response
129
+ def self.create: (payload params) -> response
130
+ def self.get: (String permalink) -> response
131
+ def self.update: (String permalink, payload params) -> response
132
+ def self.archive: (String permalink) -> response
133
+ end
134
+
135
+ class Stats < Resource
136
+ def get: (?from: String?, ?to: String?) -> response
137
+ def deliveries: () -> response
138
+
139
+ def self.get: (?from: String?, ?to: String?) -> response
140
+ def self.deliveries: () -> response
141
+ end
142
+
143
+ class Bounces < Resource
144
+ def list: (**untyped filters) -> response
145
+ def get: (Integer | String id) -> response
146
+
147
+ def self.list: (**untyped filters) -> response
148
+ def self.get: (Integer | String id) -> response
149
+ end
150
+
151
+ class Dmarc < Resource
152
+ def summary: (?domain: String?, ?from: String?, ?to: String?) -> response
153
+ def reports: (?domain: String?, ?from: String?, ?to: String?, ?page: Integer?, ?per_page: Integer?) -> response
154
+ def report: (Integer | String id) -> response
155
+
156
+ def self.summary: (?domain: String?, ?from: String?, ?to: String?) -> response
157
+ def self.reports: (?domain: String?, ?from: String?, ?to: String?, ?page: Integer?, ?per_page: Integer?) -> response
158
+ def self.report: (Integer | String id) -> response
159
+ end
160
+
161
+ class Mailer
162
+ IGNORED_HEADERS: Array[String]
163
+
164
+ attr_accessor settings: Hash[Symbol, untyped]
165
+
166
+ def initialize: (?Hash[Symbol, untyped]? settings) -> void
167
+ def deliver!: (untyped mail) -> response
168
+ def build_params: (untyped mail) -> payload
169
+ end
170
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: camelmailer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Camelmailer contributors
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-13 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Ruby SDK for the Camelmailer transactional email API — send messages,
14
+ manage templates, streams and stats, and plug into Rails via the :camelmailer ActionMailer
15
+ delivery method.
16
+ email:
17
+ - hello@camelmailer.com
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - CONTRIBUTING.md
24
+ - LICENSE
25
+ - README.md
26
+ - lib/camelmailer.rb
27
+ - lib/camelmailer/bounces.rb
28
+ - lib/camelmailer/client.rb
29
+ - lib/camelmailer/dmarc.rb
30
+ - lib/camelmailer/emails.rb
31
+ - lib/camelmailer/errors.rb
32
+ - lib/camelmailer/mailer.rb
33
+ - lib/camelmailer/railtie.rb
34
+ - lib/camelmailer/resource.rb
35
+ - lib/camelmailer/stats.rb
36
+ - lib/camelmailer/streams.rb
37
+ - lib/camelmailer/templates.rb
38
+ - lib/camelmailer/version.rb
39
+ - sig/camelmailer.rbs
40
+ homepage: https://camelmailer.com
41
+ licenses:
42
+ - MIT
43
+ metadata:
44
+ homepage_uri: https://camelmailer.com
45
+ source_code_uri: https://github.com/camelmailer/camelmailer-ruby
46
+ changelog_uri: https://github.com/camelmailer/camelmailer-ruby/blob/main/CHANGELOG.md
47
+ documentation_uri: https://camelmailer.com/docs
48
+ bug_tracker_uri: https://github.com/camelmailer/camelmailer-ruby/issues
49
+ rubygems_mfa_required: 'true'
50
+ post_install_message:
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '3.1'
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 3.5.22
66
+ signing_key:
67
+ specification_version: 4
68
+ summary: The Ruby and Rails SDK for Camelmailer
69
+ test_files: []