Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 243 additions & 12 deletions README.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion lib/millionsend.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require "millionsend/error"
require "millionsend/request"
require "millionsend/emails"
require "millionsend/receiving"
require "millionsend/batch"
require "millionsend/contacts"
require "millionsend/contact_properties"
Expand All @@ -18,13 +19,14 @@
require "millionsend/templates"
require "millionsend/usage"
require "millionsend/deliverability"
require "millionsend/inboxes"

# Ruby client for the MillionSend HTTP API. Configure once, then call the
# resource modules:
#
# Millionsend.api_key = "ms_..."
# Millionsend.base_url = "https://mail.acme.dev"
# Millionsend::Emails.send(from: "onboarding@acme.dev", to: "you@example.com",
# Millionsend::Emails.send(from: "onboarding@acme.dev", to: "success@simulator.amazonses.com",
# subject: "Hi", html: "<strong>it works</strong>")
#
# api_key falls back to the MILLIONSEND_API_KEY env var; base_url to
Expand Down
4 changes: 3 additions & 1 deletion lib/millionsend/api_keys.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ module Millionsend
module ApiKeys
class << self
# POST /api-keys — name, permission ("full_access" default or
# "sending_access"), domain_id. Returns { id:, token: }.
# "sending_access"), domain_id (a Domains.list row with shared: false),
# inbox_id (a full_access key that reads and sends for that one inbox).
# Returns { id:, token: }.
def create(params)
Millionsend::Request.new(method: :post, path: "/api-keys", body: params).perform
end
Expand Down
4 changes: 3 additions & 1 deletion lib/millionsend/batch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ class << self
# idempotency_key and batch_validation ("strict", the default, rejects the
# whole batch on one invalid item; "permissive" sends the valid subset and
# lists the rest under errors[]) — either flat or under options:, like
# Emails.send.
# Emails.send. An item from a shared-domain address is refused
# (inbox_send_policy): the whole batch in strict mode, that item in
# permissive mode.
def send(list, options = {})
Millionsend::Request.new(
method: :post, path: "/emails/batch", body: list, **Millionsend::Util.request_options(options)
Expand Down
3 changes: 2 additions & 1 deletion lib/millionsend/broadcasts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

module Millionsend
# Broadcasts — one email sent to a segment, a topic's subscribers, or every
# contact (pass neither segment_id nor topic_id).
# contact (pass neither segment_id nor topic_id). Never from the shared
# domain: create(send: true) and send answer 403 inbox_send_policy.
module Broadcasts
class << self
# POST /broadcasts
Expand Down
3 changes: 2 additions & 1 deletion lib/millionsend/contacts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ class << self
# "skip", "upsert") for emails that already belong to a contact,
# batch_validation ("strict" default, "permissive" writes the valid
# subset and lists failures under errors[]), idempotency_key. Returns
# { data: [{ index:, id:, status: }], counts: {...}, errors: [...] }.
# { data: [{ index:, id:, status: }], counts: {...}, errors: [{ index:,
# statusCode:, name:, message: }] }.
def create(list, options = {})
options = options[:options] if options.is_a?(Hash) && options.key?(:options)
Millionsend::Request.new(
Expand Down
14 changes: 11 additions & 3 deletions lib/millionsend/domains.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
# frozen_string_literal: true

module Millionsend
# Sending domains and their DNS records.
# Sending domains and their DNS records. Rows carry shared: (absent before
# server v0.14.0, so nil reads as false). The team's subdomain of the shared
# inbox domain is a read-only row with shared: true, sending_mode: ("open",
# "reply_only" or "receive_only"), sending_mode_reason: ("plan", "instance"
# or nil when open) and records: []; update, verify and remove raise
# ForbiddenError on it. It only sends from an inbox's own address, so skip
# it when picking a sender domain.
module Domains
class << self
# POST /domains — name, region, custom_return_path, open_tracking,
Expand All @@ -15,13 +21,15 @@ def get(id)
Millionsend::Request.new(method: :get, path: member_path(id)).perform
end

# GET /domains — accepts limit:/after:/before:.
# GET /domains — accepts limit:/after:/before:. The shared row comes
# last, after all of the team's own domains.
def list(options = {})
Millionsend::Request.new(method: :get, path: "/domains", query: Millionsend::Util.list_query(options)).perform
end

# PATCH /domains/:id — open_tracking, click_tracking, tracking_subdomain
# (nil or "" clears it).
# (nil or "" clears it), receiving_catch_all (mail to addresses without
# an inbox is kept rather than refused; paid plans on Cloud).
def update(id, params)
Millionsend::Request.new(method: :patch, path: member_path(id), body: params).perform
end
Expand Down
6 changes: 6 additions & 0 deletions lib/millionsend/emails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ class << self
# `options: { idempotency_key: "k" }`. No keyword parameters are declared
# on purpose — Ruby 3 keyword separation would otherwise reject the
# bare-keyword call shape.
#
# from may be an inbox's address on the shared domain while that domain's
# Domains.list row has sending_mode "open": the inbox's exact
# email_address (no +tag), no display name or its from_name, no
# reply_to, attachments, headers or scheduled_at, and at most 10 distinct
# recipients across to, cc and bcc.
def send(params = {}, options = {})
Millionsend::Request.new(
method: :post, path: "/emails", body: params, **Millionsend::Util.request_options(options)
Expand Down
28 changes: 23 additions & 5 deletions lib/millionsend/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,22 @@
module Millionsend
# Base class for everything this SDK raises. Mirrors the API's wire error body
# { statusCode, name, message }; #status_code is nil for client-side and
# transport failures that never reached the API.
# transport failures that never reached the API. #retry_after is the
# response's retry-after header in seconds, nil when it sent none.
class Error < StandardError
attr_reader :status_code, :name
attr_reader :status_code, :name, :retry_after

def initialize(message, status_code = nil, name = nil)
def initialize(message, status_code = nil, name = nil, retry_after = nil)
super(message)
@status_code = status_code
@name = name
@retry_after = retry_after
end

# Build the right subclass from a non-2xx response, keyed on the stable
# `name` discriminant. Falls back to ApplicationError for unknown names or a
# body that is not the canonical error shape.
def self.from_response(status, body)
def self.from_response(status, body, retry_after = nil)
if body.is_a?(Hash)
name = body[:name].is_a?(String) ? body[:name] : "application_error"
message = body[:message].is_a?(String) ? body[:message] : "Request failed with status #{status}"
Expand All @@ -26,7 +28,7 @@ def self.from_response(status, body)
message = "Request failed with status #{status}"
code = status
end
(ERROR_TYPES[name] || ApplicationError).new(message, code, name)
(ERROR_TYPES[name] || ApplicationError).new(message, code, name, retry_after)
end
end

Expand All @@ -36,12 +38,20 @@ class NotFoundError < Error; end
class MissingApiKeyError < Error; end
class InvalidApiKeyError < Error; end
class RestrictedApiKeyError < Error; end
class ForbiddenError < Error; end
class SendingPausedError < Error; end
class BroadcastsPausedError < Error; end
class RateLimitExceededError < Error; end
class DailyQuotaExceededError < Error; end
class InvalidIdempotentRequestError < Error; end
class ConcurrentIdempotentRequestsError < Error; end
class InboxSendPolicyError < Error; end
class InboxSendCapReachedError < Error; end
class ApprovalRequiredError < Error; end
class EmailQuarantinedError < Error; end
class EmailBlockedError < Error; end
class TooManyWaitsError < Error; end
class NameReviewUnavailableError < Error; end
class InternalServerError < Error; end
class ApplicationError < Error; end

Expand All @@ -52,12 +62,20 @@ class ApplicationError < Error; end
"missing_api_key" => MissingApiKeyError,
"invalid_api_key" => InvalidApiKeyError,
"restricted_api_key" => RestrictedApiKeyError,
"forbidden" => ForbiddenError,
"sending_paused" => SendingPausedError,
"broadcasts_paused" => BroadcastsPausedError,
"rate_limit_exceeded" => RateLimitExceededError,
"daily_quota_exceeded" => DailyQuotaExceededError,
"invalid_idempotent_request" => InvalidIdempotentRequestError,
"concurrent_idempotent_requests" => ConcurrentIdempotentRequestsError,
"inbox_send_policy" => InboxSendPolicyError,
"inbox_send_cap_reached" => InboxSendCapReachedError,
"approval_required" => ApprovalRequiredError,
"email_quarantined" => EmailQuarantinedError,
"email_blocked" => EmailBlockedError,
"too_many_waits" => TooManyWaitsError,
"name_review_unavailable" => NameReviewUnavailableError,
"internal_server_error" => InternalServerError,
"application_error" => ApplicationError,
}.freeze
Expand Down
Loading
Loading