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