zapsmtp 0.1.0.beta3

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.
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ZapSMTP
6
+ # A JSON object the API answered. Every documented field has a reader;
7
+ # every field, documented or not, is kept and can be read with #[] or
8
+ # #to_h. A missing field reads as nil; nothing about a response's shape is
9
+ # ever fatal.
10
+ #
11
+ # email = zap.emails.get(id)
12
+ # email.status # => "delivered"
13
+ # email[:status] # the same
14
+ # email.to_h # every field, keys as symbols
15
+ # case email
16
+ # in { status: "bounced" } then ...
17
+ # end
18
+ class Model
19
+ class << self
20
+ # The documented fields and how each is converted (see .cast).
21
+ #
22
+ # @api private
23
+ def attribute_types
24
+ @attribute_types ||= {}
25
+ end
26
+
27
+ # Declares a documented field and its reader. A field whose name is
28
+ # already a method of every model (such as +method+ or +hash+) gets no
29
+ # reader: read it with #[].
30
+ #
31
+ # @api private
32
+ def attribute(name, type = nil)
33
+ attribute_types[name] = type
34
+ return if Model.public_method_defined?(name)
35
+
36
+ define_method(name) { read(name) }
37
+ end
38
+
39
+ # The model of a parsed JSON value: a Hash becomes an instance of
40
+ # +model+, anything else (or no model) is returned as it is.
41
+ #
42
+ # @api private
43
+ def build(model, value, http_response = nil)
44
+ return value unless model && value.is_a?(Hash)
45
+
46
+ model.new(value, http_response: http_response)
47
+ end
48
+
49
+ # Converts a field's value: type is nil (as parsed), a model name, or
50
+ # [:array, type] / [:map, type]. A value of another shape than expected
51
+ # is returned as it is.
52
+ #
53
+ # @api private
54
+ def cast(type, value)
55
+ case type
56
+ when nil then value
57
+ when String then build(Models.const_get(type, false), value)
58
+ else
59
+ kind, inner = type
60
+ if kind == :array
61
+ value.is_a?(Array) ? value.map { |v| cast(inner, v) } : value
62
+ else
63
+ value.is_a?(Hash) ? value.transform_values { |v| cast(inner, v) } : value
64
+ end
65
+ end
66
+ end
67
+
68
+ # @api private
69
+ def deep_dup(value)
70
+ case value
71
+ when Hash then value.to_h { |k, v| [k, deep_dup(v)] }
72
+ when Array then value.map { |v| deep_dup(v) }
73
+ when String then value.dup
74
+ else value
75
+ end
76
+ end
77
+ end
78
+
79
+ # @return [ZapSMTP::Response, nil] the status, headers and request id of the
80
+ # answer this model came from (nil for a model nested in another)
81
+ attr_reader :http_response
82
+
83
+ # @param attributes [Hash] the parsed JSON object (or its fields as keyword arguments)
84
+ def initialize(attributes = {}, http_response: nil, **fields)
85
+ @attributes = attributes.merge(fields).transform_keys(&:to_sym)
86
+ @http_response = http_response
87
+ @cache = {}
88
+ end
89
+
90
+ # A field's value, by name (a String or Symbol). Documented fields are
91
+ # converted like their readers; others are as parsed.
92
+ def [](key)
93
+ key = key.to_sym
94
+ self.class.attribute_types.key?(key) ? read(key) : @attributes[key]
95
+ end
96
+
97
+ # Whether the API answered this field.
98
+ def key?(key)
99
+ @attributes.key?(key.to_sym)
100
+ end
101
+
102
+ # @return [Array<Symbol>] the names of the fields the API answered
103
+ def keys
104
+ @attributes.keys
105
+ end
106
+
107
+ def dig(key, *rest)
108
+ value = self[key]
109
+ rest.empty? || value.nil? ? value : value.dig(*rest)
110
+ end
111
+
112
+ # Every field as parsed, keys as symbols (a copy).
113
+ #
114
+ # @return [Hash]
115
+ def to_h
116
+ Model.deep_dup(@attributes)
117
+ end
118
+
119
+ def to_json(*args)
120
+ @attributes.to_json(*args)
121
+ end
122
+
123
+ def deconstruct_keys(_keys)
124
+ @attributes
125
+ end
126
+
127
+ def ==(other)
128
+ other.instance_of?(self.class) && other.to_h == @attributes
129
+ end
130
+ alias eql? ==
131
+
132
+ def hash
133
+ [self.class, @attributes].hash
134
+ end
135
+
136
+ def inspect
137
+ "#<#{self.class.name} #{@attributes.inspect}>"
138
+ end
139
+ alias to_s inspect
140
+
141
+ private
142
+
143
+ def read(name)
144
+ return @cache[name] if @cache.key?(name)
145
+
146
+ @cache[name] = Model.cast(self.class.attribute_types[name], @attributes[name])
147
+ end
148
+ end
149
+
150
+ # A webhook request's body: see ZapSMTP::Webhook.verify.
151
+ class WebhookEvent < Model
152
+ # @!attribute [r] id
153
+ # @return [String, nil] evt_...; absent on email.received, thread.created and thread.replied (see Zap-Event-Id)
154
+ attribute :id
155
+ # @!attribute [r] type
156
+ # @return [String] e.g. "email.delivered"
157
+ attribute :type
158
+ # @!attribute [r] created_at
159
+ # @return [String] RFC 3339
160
+ attribute :created_at
161
+ # @!attribute [r] data
162
+ # @return [Hash] the event's data, keys as symbols
163
+ attribute :data
164
+ end
165
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZapSMTP
4
+ # One page of a list. #each walks this page; #auto_paging_each walks every
5
+ # page, asking for the next one only when the previous is used up.
6
+ #
7
+ # page = zap.emails.list(limit: 50)
8
+ # page.data # this page's emails
9
+ # page.next_page # the next page, or nil
10
+ # zap.emails.list(status: "bounced").auto_paging_each { |email| ... }
11
+ # zap.emails.list.auto_paging_each.lazy.select { ... }.first(10)
12
+ class Page
13
+ include Enumerable
14
+
15
+ # @return [Array<ZapSMTP::Model>] this page's items
16
+ attr_reader :data
17
+ # @return [String, nil] the cursor of the next page, for lists paged by cursor
18
+ attr_reader :next_cursor
19
+ # @return [ZapSMTP::Response, nil] the status, headers and request id of this page's answer
20
+ attr_reader :http_response
21
+
22
+ # @api private
23
+ def initialize(body, model:, query:, paging:, loader:, http_response: nil)
24
+ @body = body.is_a?(Hash) ? body : {}
25
+ items = @body[:data].is_a?(Array) ? @body[:data] : []
26
+ @data = items.map { |item| Model.build(model, item) }
27
+ @has_more = @body[:has_more] == true
28
+ @next_cursor = @body[:next_cursor]
29
+ @http_response = http_response
30
+ @next_query = @has_more ? Page.next_query(query || {}, items, @next_cursor, paging) : nil
31
+ @loader = loader
32
+ end
33
+
34
+ # @return [String] "list"
35
+ def object
36
+ @body.fetch(:object, "list")
37
+ end
38
+
39
+ # Whether the API said there are more items after this page.
40
+ def has_more?
41
+ @has_more
42
+ end
43
+ alias has_more has_more?
44
+
45
+ # Whether there is a next page to ask for. Makes no request.
46
+ def next_page?
47
+ !@next_query.nil?
48
+ end
49
+
50
+ # The next page (one request), or nil after the last one.
51
+ #
52
+ # @return [ZapSMTP::Page, nil]
53
+ def next_page
54
+ @next_query && @loader.call(@next_query)
55
+ end
56
+
57
+ # Yields this page's items.
58
+ def each(&block)
59
+ return enum_for(:each) { @data.size } unless block
60
+
61
+ @data.each(&block)
62
+ self
63
+ end
64
+
65
+ # Yields every item of this page and the pages after it, loading each
66
+ # page when the previous one is used up. Without a block, an Enumerator
67
+ # (use .lazy to stop early without loading further pages).
68
+ def auto_paging_each(&block)
69
+ return enum_for(:auto_paging_each) unless block
70
+
71
+ each_page { |page| page.data.each(&block) }
72
+ nil
73
+ end
74
+
75
+ # Yields this page and every page after it.
76
+ def each_page
77
+ return enum_for(:each_page) unless block_given?
78
+
79
+ page = self
80
+ while page
81
+ yield page
82
+ page = page.next_page
83
+ end
84
+ nil
85
+ end
86
+
87
+ # A field of the list object, by name.
88
+ def [](key)
89
+ key.to_sym == :data ? @data : @body[key.to_sym]
90
+ end
91
+
92
+ # The list object as parsed, keys as symbols (a copy).
93
+ def to_h
94
+ Model.deep_dup(@body)
95
+ end
96
+
97
+ def inspect
98
+ "#<#{self.class.name} data=#{@data.size} items has_more=#{@has_more}>"
99
+ end
100
+
101
+ # The query of the page after one (docs/DESIGN-sdks.md, paging): the
102
+ # caller's query plus where the next page starts, or nil when there is no
103
+ # way to ask for it.
104
+ #
105
+ # @api private
106
+ def self.next_query(query, items, cursor, paging)
107
+ first = items.first
108
+ last = items.last
109
+ case paging
110
+ when :cursor then present(cursor) && query.merge("after" => cursor)
111
+ when :before then (id = field(last, :id)) && query.merge("before" => id)
112
+ when :after then (id = field(last, :id)) && query.merge("after" => id)
113
+ when :logs then logs_query(query, first, last)
114
+ when :thread then thread_query(query, last)
115
+ end
116
+ end
117
+
118
+ # With after, logs come oldest-first from the server and newest-first in
119
+ # the page: the next newer page starts after the page's newest entry.
120
+ #
121
+ # @api private
122
+ def self.logs_query(query, first, last)
123
+ if query["after"].nil?
124
+ (id = field(last, :id)) && query.merge("before" => id)
125
+ else
126
+ (id = field(first, :id)) && query.merge("after" => id)
127
+ end
128
+ end
129
+
130
+ # @api private
131
+ def self.thread_query(query, last)
132
+ id = field(last, :id)
133
+ at = field(last, :last_message_at)
134
+ id && at && query.merge("before" => at, "before_id" => id)
135
+ end
136
+
137
+ # @api private
138
+ def self.field(item, key)
139
+ present(item.is_a?(Hash) ? item[key] : nil)
140
+ end
141
+
142
+ # @api private
143
+ def self.present(value)
144
+ value.nil? || value == "" ? nil : value
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module ZapSMTP
6
+ module Rails
7
+ # A Mail / ActionMailer delivery method that sends through the ZapSMTP API.
8
+ #
9
+ # # config/environments/production.rb
10
+ # config.action_mailer.delivery_method = :zapsmtp
11
+ # config.action_mailer.zapsmtp_settings = { api_key: ENV["ZAP_API_KEY"] }
12
+ #
13
+ # Settings: api_key, base_url, timeout, max_retries, default_headers and
14
+ # transport (as for ZapSMTP::Client.new), or client: a ZapSMTP::Client;
15
+ # return_response: true makes Mail::Message#deliver! answer the API's
16
+ # response. After a send the message carries the email's id in its
17
+ # X-Zapsmtp-Email-Id header.
18
+ class DeliveryMethod
19
+ CLIENT_SETTINGS = %i[api_key base_url timeout max_retries default_headers transport].freeze
20
+
21
+ @clients = {}
22
+ @mutex = Mutex.new
23
+
24
+ class << self
25
+ # One client per distinct settings, shared by the deliveries that use
26
+ # them (Mail makes a delivery method per message).
27
+ #
28
+ # @api private
29
+ def client_for(settings)
30
+ config = settings.slice(*CLIENT_SETTINGS)
31
+ @mutex.synchronize { @clients[config] ||= ZapSMTP::Client.new(**config) }
32
+ end
33
+ end
34
+
35
+ # @return [Hash]
36
+ attr_accessor :settings
37
+
38
+ def initialize(settings = {})
39
+ @settings = settings.to_h.transform_keys(&:to_sym)
40
+ end
41
+
42
+ # Sends the message; raises a ZapSMTP::Error when the API refuses it.
43
+ #
44
+ # The message keeps its options until the API has taken it, and its
45
+ # Idempotency-Key (the caller's, or one made on the first attempt and
46
+ # kept with the options): delivering the same message again after a
47
+ # failure, a lost answer included, sends the same request and never
48
+ # sends twice.
49
+ #
50
+ # @param message [Mail::Message]
51
+ # @return [ZapSMTP::Models::SendEmailResponse]
52
+ def deliver!(message)
53
+ options = ZapSMTP::Rails.options(message)
54
+ unless options["idempotency_key"]
55
+ options["idempotency_key"] = SecureRandom.uuid
56
+ ZapSMTP::Rails.set_options(message, options)
57
+ end
58
+ key = options.delete("idempotency_key")
59
+ result = client.emails.send(Message.params(message, options), request_options: { idempotency_key: key })
60
+ message[OPTIONS_HEADER] = nil
61
+ message[EMAIL_ID_HEADER] = result.id
62
+ result
63
+ end
64
+
65
+ # @return [ZapSMTP::Client]
66
+ def client
67
+ settings[:client] || DeliveryMethod.client_for(settings)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZapSMTP
4
+ module Rails
5
+ # Turns a Mail::Message into emails.send parameters.
6
+ module Message
7
+ # Headers the message's own fields already carry, or the API sets.
8
+ SKIPPED_HEADERS = %w[
9
+ from to cc bcc reply-to subject date message-id mime-version content-type content-transfer-encoding
10
+ content-disposition content-id return-path received dkim-signature
11
+ x-zapsmtp-options x-zapsmtp-email-id
12
+ ].freeze
13
+
14
+ module_function
15
+
16
+ # @param message [Mail::Message]
17
+ # @param options [Hash{String => Object}] see ZapSMTP::Rails.set_options
18
+ # @return [Hash{String => Object}]
19
+ def params(message, options = {})
20
+ params = {}
21
+ from = addresses(message, :from)
22
+ params["from"] = from.first if from
23
+ { "to" => :to, "cc" => :cc, "bcc" => :bcc, "reply_to" => :reply_to }.each do |key, field|
24
+ list = addresses(message, field)
25
+ params[key] = list if list
26
+ end
27
+ params["subject"] = message.subject unless message.subject.nil?
28
+ add_bodies(params, message) unless options.key?("template")
29
+ headers = custom_headers(message)
30
+ params["headers"] = headers unless headers.empty?
31
+ attachments = message.attachments.map { |part| attachment(part) }
32
+ params["attachments"] = attachments unless attachments.empty?
33
+ params.merge(options).compact
34
+ end
35
+
36
+ # A field's addresses, formatted with their display names; nil when
37
+ # the field is absent or empty.
38
+ def addresses(message, name)
39
+ field = message[name]
40
+ return nil if field.nil?
41
+
42
+ list = field.field.respond_to?(:addrs) ? field.addrs.map(&:format) : [field.value.to_s]
43
+ list.empty? ? nil : list
44
+ end
45
+
46
+ def add_bodies(params, message)
47
+ if message.multipart?
48
+ params["html"] = message.html_part&.decoded
49
+ params["text"] = message.text_part&.decoded
50
+ elsif message.mime_type == "text/html"
51
+ params["html"] = message.decoded
52
+ else
53
+ params["text"] = message.decoded
54
+ end
55
+ end
56
+
57
+ # The message's other header fields: In-Reply-To, References, List-Unsubscribe, X-...
58
+ def custom_headers(message)
59
+ message.header.fields.each_with_object({}) do |field, out|
60
+ next if SKIPPED_HEADERS.include?(field.name.downcase)
61
+
62
+ out[field.name] = field.value.to_s
63
+ end
64
+ end
65
+
66
+ def attachment(part)
67
+ out = { "filename" => part.filename, "content" => [part.body.decoded].pack("m0"),
68
+ "content_type" => part.mime_type }
69
+ out["content_id"] = part.cid if part.inline?
70
+ out.compact
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZapSMTP
4
+ module Rails
5
+ # A controller concern that verifies ZapSMTP webhook requests. A request
6
+ # whose signature is missing, wrong or too old is answered 400.
7
+ #
8
+ # class ZapsmtpWebhooksController < ActionController::API
9
+ # include ZapSMTP::Rails::WebhookVerification
10
+ #
11
+ # def create
12
+ # event = zapsmtp_webhook_event # zapsmtp_webhook_events for a webhook with batch delivery
13
+ # EmailBounce.record(event.data) if event.type == "email.bounced"
14
+ # head :ok
15
+ # end
16
+ # end
17
+ #
18
+ # The secret is ENV["ZAP_WEBHOOK_SECRET"]; override #zapsmtp_webhook_secret
19
+ # to take it from elsewhere (credentials). In an ActionController::Base
20
+ # controller, skip_forgery_protection for the action.
21
+ module WebhookVerification
22
+ def self.included(controller)
23
+ controller.rescue_from(ZapSMTP::WebhookVerificationError) do |error|
24
+ render json: { error: error.message }, status: :bad_request
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ # The verified event of this request.
31
+ #
32
+ # @return [ZapSMTP::WebhookEvent]
33
+ # @raise [ZapSMTP::WebhookVerificationError]
34
+ def zapsmtp_webhook_event
35
+ @zapsmtp_webhook_event ||= ZapSMTP::Webhook.verify(request.raw_post, request.headers,
36
+ zapsmtp_webhook_secret,
37
+ tolerance: zapsmtp_webhook_tolerance)
38
+ end
39
+
40
+ # The verified events of this request: every event of a batch (a
41
+ # webhook with batch: true), or its one event.
42
+ #
43
+ # @return [Array<ZapSMTP::WebhookEvent>]
44
+ # @raise [ZapSMTP::WebhookVerificationError]
45
+ def zapsmtp_webhook_events
46
+ @zapsmtp_webhook_events ||= ZapSMTP::Webhook.verify_events(request.raw_post, request.headers,
47
+ zapsmtp_webhook_secret,
48
+ tolerance: zapsmtp_webhook_tolerance)
49
+ end
50
+
51
+ # A before_action: refuses a request whose signature is not valid.
52
+ def verify_zapsmtp_webhook!
53
+ zapsmtp_webhook_events
54
+ end
55
+
56
+ # @return [String]
57
+ def zapsmtp_webhook_secret
58
+ ENV.fetch("ZAP_WEBHOOK_SECRET", nil)
59
+ end
60
+
61
+ # @return [Integer] seconds
62
+ def zapsmtp_webhook_tolerance
63
+ ZapSMTP::Webhook::DEFAULT_TOLERANCE
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "core"
5
+
6
+ module ZapSMTP
7
+ # The Rails and Mail integration: an ActionMailer delivery method
8
+ # (ZapSMTP::Rails::DeliveryMethod) and a controller concern that verifies
9
+ # webhooks (ZapSMTP::Rails::WebhookVerification). In a Rails app the
10
+ # Railtie registers the delivery method as :zapsmtp; with the Mail gem
11
+ # alone, require "zapsmtp/rails" and use the class.
12
+ module Rails
13
+ # The private header that carries the zapsmtp: options of a mail()
14
+ # call (tags, metadata, stream, template...) to the delivery method,
15
+ # which removes it before sending.
16
+ OPTIONS_HEADER = "X-Zapsmtp-Options"
17
+ # The header the delivery method sets on a sent message: the email's id.
18
+ EMAIL_ID_HEADER = "X-Zapsmtp-Email-Id"
19
+
20
+ module_function
21
+
22
+ # Registers the delivery method and the zapsmtp: option of mail() on
23
+ # ActionMailer::Base (the Railtie does this).
24
+ def install(mailer_base)
25
+ mailer_base.add_delivery_method(:zapsmtp, DeliveryMethod)
26
+ mailer_base.prepend(MailerOptions)
27
+ end
28
+
29
+ # Sets the ZapSMTP options of a Mail::Message: tags, metadata, stream,
30
+ # template, tracking, scheduled_at, tls, idempotency_key, or any other
31
+ # emails.send parameter. A nil value removes that parameter.
32
+ def set_options(message, options)
33
+ message[OPTIONS_HEADER] = nil
34
+ message[OPTIONS_HEADER] = JSON.generate(options, ascii_only: true)
35
+ message
36
+ end
37
+
38
+ # Reads the options of a Mail::Message (they stay on it until it is sent).
39
+ #
40
+ # @return [Hash{String => Object}]
41
+ def options(message)
42
+ field = message.header.fields.reverse.find { |f| f.name.casecmp?(OPTIONS_HEADER) }
43
+ return {} unless field
44
+
45
+ options = JSON.parse(field.value.to_s)
46
+ raise ArgumentError unless options.is_a?(Hash)
47
+
48
+ options
49
+ rescue JSON::ParserError, ArgumentError
50
+ raise Error, "#{OPTIONS_HEADER} must be a JSON object"
51
+ end
52
+
53
+ # The zapsmtp: option of ActionMailer's mail():
54
+ #
55
+ # mail(to: user.email, subject: "Welcome", zapsmtp: { tags: [{ name: "category", value: "welcome" }],
56
+ # metadata: { user_id: user.id.to_s } })
57
+ module MailerOptions
58
+ def mail(headers = {}, &block)
59
+ return super unless headers.is_a?(Hash) && headers.key?(:zapsmtp)
60
+
61
+ message = super(headers.except(:zapsmtp), &block)
62
+ ZapSMTP::Rails.set_options(message, headers[:zapsmtp])
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ require_relative "rails/message"
69
+ require_relative "rails/delivery_method"
70
+ require_relative "rails/webhook_verification"
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails"
4
+
5
+ module ZapSMTP
6
+ module Rails
7
+ # Registers the :zapsmtp delivery method with ActionMailer:
8
+ #
9
+ # config.action_mailer.delivery_method = :zapsmtp
10
+ # config.action_mailer.zapsmtp_settings = { api_key: ENV["ZAP_API_KEY"] }
11
+ class Railtie < ::Rails::Railtie
12
+ initializer "zapsmtp.action_mailer", before: "action_mailer.set_configs" do
13
+ ActiveSupport.on_load(:action_mailer) { ZapSMTP::Rails.install(self) }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZapSMTP
4
+ # What the API answered besides the body: the status, the headers (rate
5
+ # limits, quotas) and the request id.
6
+ class Response
7
+ # @return [Integer]
8
+ attr_reader :status
9
+ # @return [Hash{String => String}] names in lower case
10
+ attr_reader :headers
11
+ # @return [String, nil] X-Request-Id: quote it to support
12
+ attr_reader :request_id
13
+
14
+ def initialize(status, headers)
15
+ @status = status
16
+ @headers = headers
17
+ @request_id = headers["x-request-id"]
18
+ end
19
+
20
+ # A header's value, its name in any case.
21
+ #
22
+ # @return [String, nil]
23
+ def [](name)
24
+ headers[name.to_s.downcase]
25
+ end
26
+
27
+ def inspect
28
+ "#<#{self.class.name} status=#{status} request_id=#{request_id.inspect}>"
29
+ end
30
+ end
31
+
32
+ # A result with its response, from ZapSMTP::Client#with_response:
33
+ #
34
+ # email, response = zap.with_response.emails.get(id)
35
+ Result = Struct.new(:data, :response) do
36
+ def to_ary
37
+ [data, response]
38
+ end
39
+ end
40
+ end