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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +326 -0
- data/lib/zapsmtp/attachment.rb +55 -0
- data/lib/zapsmtp/base_client.rb +225 -0
- data/lib/zapsmtp/core.rb +18 -0
- data/lib/zapsmtp/errors.rb +88 -0
- data/lib/zapsmtp/generated/client.rb +92 -0
- data/lib/zapsmtp/generated/models.rb +2618 -0
- data/lib/zapsmtp/generated/resources.rb +2818 -0
- data/lib/zapsmtp/model.rb +165 -0
- data/lib/zapsmtp/page.rb +147 -0
- data/lib/zapsmtp/rails/delivery_method.rb +71 -0
- data/lib/zapsmtp/rails/message.rb +74 -0
- data/lib/zapsmtp/rails/webhook_verification.rb +67 -0
- data/lib/zapsmtp/rails.rb +70 -0
- data/lib/zapsmtp/railtie.rb +17 -0
- data/lib/zapsmtp/response.rb +40 -0
- data/lib/zapsmtp/retry.rb +48 -0
- data/lib/zapsmtp/transport.rb +130 -0
- data/lib/zapsmtp/util.rb +119 -0
- data/lib/zapsmtp/version.rb +6 -0
- data/lib/zapsmtp/webhook.rb +130 -0
- data/lib/zapsmtp/webhook_middleware.rb +55 -0
- data/lib/zapsmtp.rb +17 -0
- metadata +75 -0
|
@@ -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
|
data/lib/zapsmtp/page.rb
ADDED
|
@@ -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
|