smbcloud-email 1.0.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.
data/Cargo.toml ADDED
@@ -0,0 +1,8 @@
1
+ # This Cargo.toml is here to let external tools (IDEs, etc.) know that this is a
2
+ # Rust project. Your extension dependencies should be added to the Cargo.toml in
3
+ # the ext/ directory.
4
+
5
+ [workspace]
6
+ members = ["./ext/email"]
7
+ resolver = "2"
8
+
data/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # smbcloud-email (Ruby)
2
+
3
+ Ruby bindings for the **smbCloud transactional email API**, powered by the shared
4
+ Rust SDK ([`smbcloud-email-sdk`](../../../crates/smbcloud-email-sdk)) and a native
5
+ [Magnus](https://github.com/matsadler/magnus) extension.
6
+
7
+ ## Install
8
+
9
+ ```ruby
10
+ gem 'smbcloud-email'
11
+ ```
12
+
13
+ The gem ships a native extension; `bundle install` compiles it (Rust toolchain
14
+ required).
15
+
16
+ ## Usage
17
+
18
+ ```ruby
19
+ require 'email'
20
+
21
+ client = SmbCloud::Email.client(
22
+ environment: SmbCloud::Email::Environment::PRODUCTION,
23
+ api_key: 'smb_mail_your_key',
24
+ )
25
+
26
+ # Send
27
+ sent = client.send(
28
+ from: 'billing@example.com',
29
+ to: ['customer@acme.com'],
30
+ subject: 'Your receipt',
31
+ html: '<h1>Thanks!</h1>',
32
+ text: 'Thanks!',
33
+ idempotency_key: 'receipt-2026-0001',
34
+ )
35
+ sent[:id] # => "eml_…"
36
+ sent[:status] # => 1 (sent)
37
+
38
+ # Attachments, cc/bcc, headers, tags
39
+ client.send(
40
+ from: 'billing@example.com',
41
+ to: ['customer@acme.com'],
42
+ subject: 'Invoice',
43
+ html: '<p>See attached.</p>',
44
+ cc: ['accounts@acme.com'],
45
+ attachments: [{ filename: 'invoice.pdf', content_base64: 'JVBERi0xLjQ...' }],
46
+ headers: { 'X-Entity-Ref-ID' => 'inv_123' },
47
+ tags: { 'category' => 'invoice' },
48
+ )
49
+
50
+ # Delivery status (needs a read-scope key)
51
+ message = client.get_message('eml_…')
52
+ message[:status]
53
+ message[:events]
54
+
55
+ client.list_messages(status: 'bounced', limit: 20)
56
+ ```
57
+
58
+ Mint an API key for your Mail app in the smbCloud console. Sending is scoped to
59
+ that app's verified domain; reading messages needs a read-scope key.
60
+
61
+ ## Errors
62
+
63
+ Non-2xx API responses and transport failures raise `SmbCloud::Email::Error`, with
64
+ the parsed response body available on `#payload`.
65
+
66
+ ## License
67
+
68
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rb_sys/extensiontask'
5
+
6
+ task build: :compile
7
+
8
+ GEMSPEC = Gem::Specification.load('email.gemspec')
9
+
10
+ RbSys::ExtensionTask.new('email', GEMSPEC) do |ext|
11
+ ext.lib_dir = 'lib/email'
12
+ ext.cross_compile = true
13
+ ext.cross_platform = %w[x86_64-linux aarch64-linux arm64-darwin x86_64-darwin]
14
+ end
15
+
16
+ task default: :compile
data/ext/.keep ADDED
File without changes
@@ -0,0 +1,18 @@
1
+ [package]
2
+ name = "email"
3
+ version = "1.0.0"
4
+ edition = "2024"
5
+ authors = ["Seto Elkahfi <seto@smbcloud.xyz>"]
6
+ publish = false
7
+
8
+ [lib]
9
+ crate-type = ["cdylib"]
10
+
11
+ [dependencies]
12
+ magnus = "0.8"
13
+ serde_json = "1"
14
+ tokio = { version = "1", features = ["rt-multi-thread"] }
15
+
16
+ # The published transactional-email SDK. For local development this is patched
17
+ # to the in-repo crate via `[patch.crates-io]` in the gem workspace Cargo.toml.
18
+ smbcloud-email-sdk = { version = "1.0" }
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mkmf'
4
+ require 'rb_sys/mkmf'
5
+
6
+ create_rust_makefile('email/email')
@@ -0,0 +1,95 @@
1
+ use magnus::{function, prelude::*, Error, Ruby};
2
+ use smbcloud_email_sdk::{EmailClient, EmailCredentials, Environment, SendEmail};
3
+ use tokio::runtime::Runtime;
4
+
5
+ // Every helper here runs on the Ruby thread that called into the extension
6
+ // (tokio's `block_on` stays on the calling thread), so the handle is available.
7
+ fn ruby() -> Ruby {
8
+ Ruby::get().expect("smbcloud-email called outside a Ruby thread")
9
+ }
10
+
11
+ fn parse_environment(value: String) -> Result<Environment, Error> {
12
+ value.parse().map_err(|_| {
13
+ Error::new(
14
+ ruby().exception_arg_error(),
15
+ format!("invalid environment `{value}`, expected `dev` or `production`"),
16
+ )
17
+ })
18
+ }
19
+
20
+ fn with_runtime<T, F>(f: F) -> Result<T, Error>
21
+ where
22
+ F: FnOnce(&Runtime) -> Result<T, Error>,
23
+ {
24
+ let runtime = Runtime::new().map_err(|err| {
25
+ Error::new(
26
+ ruby().exception_runtime_error(),
27
+ format!("failed to initialize tokio runtime: {err}"),
28
+ )
29
+ })?;
30
+ f(&runtime)
31
+ }
32
+
33
+ fn runtime_error(message: impl std::fmt::Display) -> Error {
34
+ Error::new(ruby().exception_runtime_error(), message.to_string())
35
+ }
36
+
37
+ fn client(environment: Environment, api_key: &str) -> EmailClient {
38
+ EmailClient::from_credentials(environment, EmailCredentials { api_key })
39
+ }
40
+
41
+ /// `message_json` is the SendEmail payload built on the Ruby side. Returns the
42
+ /// created message as JSON.
43
+ fn send_json(environment: String, api_key: String, message_json: String) -> Result<String, Error> {
44
+ let env = parse_environment(environment)?;
45
+ let message: SendEmail = serde_json::from_str(&message_json)
46
+ .map_err(|err| Error::new(ruby().exception_arg_error(), format!("invalid message: {err}")))?;
47
+
48
+ with_runtime(|runtime| {
49
+ match runtime.block_on(client(env, &api_key).send(&message)) {
50
+ Ok(result) => serde_json::to_string(&result).map_err(runtime_error),
51
+ Err(error) => Err(runtime_error(error)),
52
+ }
53
+ })
54
+ }
55
+
56
+ fn get_message_json(environment: String, api_key: String, id: String) -> Result<String, Error> {
57
+ let env = parse_environment(environment)?;
58
+ with_runtime(|runtime| {
59
+ match runtime.block_on(client(env, &api_key).get_message(&id)) {
60
+ Ok(result) => serde_json::to_string(&result).map_err(runtime_error),
61
+ Err(error) => Err(runtime_error(error)),
62
+ }
63
+ })
64
+ }
65
+
66
+ /// `status` empty string means "no filter"; `limit` <= 0 means "server default".
67
+ fn list_messages_json(
68
+ environment: String,
69
+ api_key: String,
70
+ status: String,
71
+ limit: i64,
72
+ ) -> Result<String, Error> {
73
+ let env = parse_environment(environment)?;
74
+ let status = if status.trim().is_empty() { None } else { Some(status) };
75
+ let limit = if limit > 0 { Some(limit as u32) } else { None };
76
+
77
+ with_runtime(|runtime| {
78
+ match runtime.block_on(client(env, &api_key).list_messages(status.as_deref(), limit)) {
79
+ Ok(result) => serde_json::to_string(&result).map_err(runtime_error),
80
+ Err(error) => Err(runtime_error(error)),
81
+ }
82
+ })
83
+ }
84
+
85
+ #[magnus::init]
86
+ fn init(ruby: &Ruby) -> Result<(), Error> {
87
+ let smbcloud = ruby.define_module("SmbCloud")?;
88
+ let email = smbcloud.define_module("Email")?;
89
+
90
+ email.define_singleton_method("__send", function!(send_json, 3))?;
91
+ email.define_singleton_method("__get_message", function!(get_message_json, 3))?;
92
+ email.define_singleton_method("__list_messages", function!(list_messages_json, 4))?;
93
+
94
+ Ok(())
95
+ }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Email
4
+ VERSION = '1.0.0'
5
+ end
data/lib/email.rb ADDED
@@ -0,0 +1,132 @@
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
data/sig/email.rbs ADDED
@@ -0,0 +1,28 @@
1
+ module Email
2
+ VERSION: String
3
+ end
4
+
5
+ module SmbCloud
6
+ module Email
7
+ class Error < StandardError
8
+ attr_reader payload: untyped
9
+ end
10
+
11
+ module Environment
12
+ DEV: String
13
+ PRODUCTION: String
14
+ end
15
+
16
+ class Client
17
+ attr_reader environment: String
18
+ attr_reader api_key: String
19
+
20
+ def initialize: (environment: String, api_key: String) -> void
21
+ def send: (from: String, to: untyped, ?subject: String?, ?html: String?, ?text: String?, ?cc: untyped, ?bcc: untyped, ?reply_to: untyped, ?attachments: untyped, ?headers: untyped, ?tags: untyped, ?idempotency_key: String?) -> Hash[Symbol, untyped]
22
+ def get_message: (String) -> Hash[Symbol, untyped]
23
+ def list_messages: (?status: String?, ?limit: Integer?) -> Array[Hash[Symbol, untyped]]
24
+ end
25
+
26
+ def self.client: (environment: String, api_key: String) -> Client
27
+ end
28
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: smbcloud-email
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Seto Elkahfi
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-10-31 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rb_sys
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.9'
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: 0.9.130
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - "~>"
41
+ - !ruby/object:Gem::Version
42
+ version: '0.9'
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: 0.9.130
46
+ description: Send transactional email from a verified domain and read delivery status,
47
+ powered by the shared Rust SDK and a native Magnus extension.
48
+ email:
49
+ - seto@smbcloud.xyz
50
+ executables: []
51
+ extensions:
52
+ - ext/email/extconf.rb
53
+ extra_rdoc_files: []
54
+ files:
55
+ - CHANGELOG.md
56
+ - Cargo.lock
57
+ - Cargo.toml
58
+ - README.md
59
+ - Rakefile
60
+ - ext/.keep
61
+ - ext/email/Cargo.toml
62
+ - ext/email/extconf.rb
63
+ - ext/email/src/lib.rs
64
+ - lib/email.rb
65
+ - lib/email/version.rb
66
+ - sig/email.rbs
67
+ homepage: https://github.com/smbcloudXYZ/smbcloud-cli/tree/main/sdk/gems/email
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ allowed_push_host: https://rubygems.org
72
+ homepage_uri: https://github.com/smbcloudXYZ/smbcloud-cli/tree/main/sdk/gems/email
73
+ source_code_uri: https://github.com/smbcloudXYZ/smbcloud-cli/tree/main/sdk/gems/email
74
+ changelog_uri: https://github.com/smbcloudXYZ/smbcloud-cli/tree/main/sdk/gems/email/CHANGELOG.md
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 3.1.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 3.3.11
88
+ requirements: []
89
+ rubygems_version: 3.6.2
90
+ specification_version: 4
91
+ summary: Ruby bindings for the smbCloud transactional email API.
92
+ test_files: []