1 # frozen_string_literal: true
2
3 require 'json'
4 require_relative 'email/version'
5 require_relative 'email/email'
6
7 module Email
8 end
9
10 module SmbCloud
11 # Ruby bindings for the smbCloud transactional email API, powered by the shared
12 # Rust SDK (smbcloud-email-sdk) and a native Magnus extension.
13 #
14 # @example
15 # client = SmbCloud::Email.client(environment: "production", api_key: "smb_mail_…")
16 # sent = client.send(
17 # from: "billing@example.com",
18 # to: ["customer@acme.com"],
19 # subject: "Your receipt",
20 # html: "<h1>Thanks!</h1>",
21 # idempotency_key: "receipt-2026-0001",
22 # )
23 # sent[:id] # => "eml_…"
24 module Email
25 class Error < StandardError
26 attr_reader :payload
27
28 def initialize(message = nil, payload: nil)
29 super(message)
30 @payload = payload
31 end
32 end
33
34 module Environment
35 DEV = 'dev'
36 PRODUCTION = 'production'
37 end
38
39 class Client
40 attr_reader :environment, :api_key
41
42 def initialize(environment:, api_key:)
43 @environment = environment
44 @api_key = api_key
45 end
46
47 # Send a transactional email. `from` must be on the API key's verified
48 # domain; provide at least one of `html`/`text`. `attachments` is an array
49 # of { filename:, content_base64: }. Returns the created message hash.
50 def send(from:, to:, subject: nil, html: nil, text: nil, cc: nil, bcc: nil,
51 reply_to: nil, attachments: nil, headers: nil, tags: nil,
52 idempotency_key: nil)
53 message = {
54 from: from,
55 to: Array(to),
56 cc: Array(cc),
57 bcc: Array(bcc),
58 reply_to: Array(reply_to),
59 subject: subject,
60 html: html,
61 text: text,
62 attachments: Array(attachments),
63 headers: headers || {},
64 tags: tags || {},
65 idempotency_key: idempotency_key
66 }.compact
67
68 Email.send(:parse_json, Email.__send(environment, api_key, JSON.generate(message)))
69 rescue RuntimeError => e
70 raise Email.send(:normalize_error, e)
71 end
72
73 # Fetch one message by id, including its delivery-event timeline. Requires
74 # a read-scope key.
75 def get_message(id)
76 Email.send(:parse_json, Email.__get_message(environment, api_key, id))
77 rescue RuntimeError => e
78 raise Email.send(:normalize_error, e)
79 end
80
81 # List recent messages. `status` filters by delivery status name; `limit`
82 # is clamped server-side to 1..=100. Requires a read-scope key.
83 def list_messages(status: nil, limit: nil)
84 Email.send(:parse_json,
85 Email.__list_messages(environment, api_key, status.to_s, limit.to_i))
86 rescue RuntimeError => e
87 raise Email.send(:normalize_error, e)
88 end
89 end
90
91 class << self
92 def client(environment:, api_key:)
93 Client.new(environment:, api_key:)
94 end
95
96 def send_with_client(environment:, api_key:, **kwargs)
97 client(environment:, api_key:).send(**kwargs)
98 end
99
100 def get_message_with_client(environment:, api_key:, id:)
101 client(environment:, api_key:).get_message(id)
102 end
103
104 def list_messages_with_client(environment:, api_key:, status: nil, limit: nil)
105 client(environment:, api_key:).list_messages(status:, limit:)
106 end
107
108 private
109
110 def parse_json(payload)
111 return payload unless payload.is_a?(String)
112
113 JSON.parse(payload, symbolize_names: true)
114 end
115
116 def normalize_error(error)
117 payload = parse_json(error.message)
118 message = payload.is_a?(Hash) ? (payload[:message] || error.message) : error.message
119 Error.new(message, payload: payload)
120 rescue JSON::ParserError, NoMethodError, TypeError
121 Error.new(error.message)
122 end
123 end
124 end
125 end