haskimail-rails 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +267 -0
- data/lib/generators/haskimail/install/install_generator.rb +32 -0
- data/lib/generators/haskimail/install/templates/haskimail.rb +27 -0
- data/lib/haskimail/attachment.rb +84 -0
- data/lib/haskimail/clients.rb +78 -0
- data/lib/haskimail/configuration.rb +89 -0
- data/lib/haskimail/connection.rb +268 -0
- data/lib/haskimail/errors.rb +130 -0
- data/lib/haskimail/paginator.rb +48 -0
- data/lib/haskimail/rails/delivery_method.rb +56 -0
- data/lib/haskimail/rails/mail_converter.rb +130 -0
- data/lib/haskimail/rails/mailer_helpers.rb +42 -0
- data/lib/haskimail/rails/message_options.rb +88 -0
- data/lib/haskimail/rails/railtie.rb +28 -0
- data/lib/haskimail/resources/account_templates.rb +53 -0
- data/lib/haskimail/resources/base.rb +75 -0
- data/lib/haskimail/resources/bounces.rb +43 -0
- data/lib/haskimail/resources/domains.rb +42 -0
- data/lib/haskimail/resources/emails.rb +149 -0
- data/lib/haskimail/resources/inbound_rules.rb +33 -0
- data/lib/haskimail/resources/message_streams.rb +46 -0
- data/lib/haskimail/resources/messages.rb +135 -0
- data/lib/haskimail/resources/servers.rb +89 -0
- data/lib/haskimail/resources/stats.rb +89 -0
- data/lib/haskimail/resources/suppressions.rb +39 -0
- data/lib/haskimail/resources/templates.rb +68 -0
- data/lib/haskimail/resources/webhooks.rb +80 -0
- data/lib/haskimail/response_handler.rb +68 -0
- data/lib/haskimail/secret.rb +40 -0
- data/lib/haskimail/serializer.rb +75 -0
- data/lib/haskimail/util.rb +110 -0
- data/lib/haskimail/version.rb +5 -0
- data/lib/haskimail/webhooks/endpoint.rb +146 -0
- data/lib/haskimail/webhooks/event.rb +103 -0
- data/lib/haskimail/webhooks/inbound_message.rb +100 -0
- data/lib/haskimail/webhooks.rb +87 -0
- data/lib/haskimail-rails.rb +19 -0
- data/lib/haskimail.rb +58 -0
- metadata +132 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
require "erb"
|
|
6
|
+
|
|
7
|
+
module Haskimail
|
|
8
|
+
# Small, dependency-free helpers shared across the library.
|
|
9
|
+
module Util
|
|
10
|
+
MOSCOW_OFFSET = "+03:00" # Moscow has no DST since 2014; the API works in MSK.
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# "8f3e0c7a-5b2d-..." -> "8f3e…2a94". Never prints a full secret.
|
|
15
|
+
def redact(secret)
|
|
16
|
+
return nil if secret.nil?
|
|
17
|
+
|
|
18
|
+
str = secret.to_s
|
|
19
|
+
return "[REDACTED]" if str.length < 12
|
|
20
|
+
|
|
21
|
+
"#{str[0, 4]}…#{str[-4, 4]}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Encodes a single path segment so that IDs and template aliases cannot
|
|
25
|
+
# change the request path ("/", "?", "#", "..", spaces, Cyrillic, ...).
|
|
26
|
+
def escape_path_segment(value)
|
|
27
|
+
segment = value.to_s
|
|
28
|
+
raise ArgumentError, "Path parameter must not be blank" if segment.strip.empty?
|
|
29
|
+
raise ArgumentError, "Invalid path parameter: #{segment.inspect}" if %w[. ..].include?(segment)
|
|
30
|
+
|
|
31
|
+
ERB::Util.url_encode(segment)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Header values must not contain CR/LF (header injection) or be blank.
|
|
35
|
+
def validate_token!(token, name)
|
|
36
|
+
str = token.to_s
|
|
37
|
+
raise ConfigurationError, "#{name} is not set" if str.strip.empty?
|
|
38
|
+
raise ConfigurationError, "#{name} contains illegal characters" if str.match?(/[\r\n\0]/)
|
|
39
|
+
|
|
40
|
+
str
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Date-only API filter: "2026-10-01" (Moscow date).
|
|
44
|
+
def format_date(value)
|
|
45
|
+
case value
|
|
46
|
+
when nil then nil
|
|
47
|
+
when String then validate_date_string!(value, date_only: true)
|
|
48
|
+
when DateTime, Time then to_moscow(value).strftime("%Y-%m-%d")
|
|
49
|
+
when Date then value.strftime("%Y-%m-%d")
|
|
50
|
+
else
|
|
51
|
+
return format_date(value.to_time) if value.respond_to?(:to_time)
|
|
52
|
+
|
|
53
|
+
raise ArgumentError, "Expected a Date, Time or String, got #{value.class}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Date-time API filter: "2026-10-01 12:00:00" (or with "T" for inbound search).
|
|
58
|
+
def format_datetime(value, separator: " ")
|
|
59
|
+
case value
|
|
60
|
+
when nil then nil
|
|
61
|
+
when String then validate_date_string!(value)
|
|
62
|
+
when DateTime, Time then to_moscow(value).strftime("%Y-%m-%d#{separator}%H:%M:%S")
|
|
63
|
+
when Date then value.strftime("%Y-%m-%d")
|
|
64
|
+
else
|
|
65
|
+
return format_datetime(value.to_time, separator: separator) if value.respond_to?(:to_time)
|
|
66
|
+
|
|
67
|
+
raise ArgumentError, "Expected a Date, Time or String, got #{value.class}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_moscow(value)
|
|
72
|
+
value.to_time.getlocal(MOSCOW_OFFSET)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
DATE_STRING = /\A(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?\z/
|
|
76
|
+
|
|
77
|
+
# Accepts "YYYY-MM-DD" and, unless date_only, "YYYY-MM-DD HH:MM[:SS]" (or with "T").
|
|
78
|
+
def validate_date_string!(value, date_only: false)
|
|
79
|
+
match = DATE_STRING.match(value)
|
|
80
|
+
valid = match && Date.valid_date?(match[1].to_i, match[2].to_i, match[3].to_i) &&
|
|
81
|
+
(match[4].nil? || (!date_only && match[4].to_i < 24 && match[5].to_i < 60 && match[6].to_i < 60))
|
|
82
|
+
return value if valid
|
|
83
|
+
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"Invalid date #{value.inspect}, expected #{date_only ? 'YYYY-MM-DD' : 'YYYY-MM-DD[ HH:MM:SS]'}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def blank?(value)
|
|
89
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?) || (value.is_a?(String) && value.strip.empty?)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Drops nil values from a flat hash.
|
|
93
|
+
def compact(hash)
|
|
94
|
+
hash.compact
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def assert_known_keys!(given, allowed, context)
|
|
98
|
+
unknown = given.map(&:to_sym) - allowed
|
|
99
|
+
return if unknown.empty?
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, "Unknown #{context} option(s): #{unknown.join(', ')}. Allowed: #{allowed.join(', ')}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def assert_range!(name, value, range)
|
|
105
|
+
return if value.nil?
|
|
106
|
+
raise ArgumentError, "#{name} must be an Integer" unless value.is_a?(Integer)
|
|
107
|
+
raise ArgumentError, "#{name} must be within #{range}, got #{value}" unless range.cover?(value)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Haskimail
|
|
6
|
+
module Webhooks
|
|
7
|
+
# A Rack app that receives Haskimail webhooks, authenticates them and hands
|
|
8
|
+
# parsed events to your code. Mount it in config/routes.rb:
|
|
9
|
+
#
|
|
10
|
+
# mount Haskimail::Webhooks::Endpoint.new(
|
|
11
|
+
# signature_key: Rails.application.credentials.dig(:haskimail, :webhook_key),
|
|
12
|
+
# username: "haskimail", password: Rails.application.credentials.dig(:haskimail, :webhook_password)
|
|
13
|
+
# ) { |event| HaskimailWebhookJob.perform_later(event.to_h) }, at: "/haskimail/webhooks"
|
|
14
|
+
#
|
|
15
|
+
# Configure the webhook URL in Haskimail as https://haskimail:PASSWORD@your.app/haskimail/webhooks.
|
|
16
|
+
#
|
|
17
|
+
# Every payload is also published via ActiveSupport::Notifications as
|
|
18
|
+
# "webhook.haskimail" with payload { type:, event: }.
|
|
19
|
+
#
|
|
20
|
+
# Security:
|
|
21
|
+
# * Basic auth is required (it is checked before the body is read).
|
|
22
|
+
# * signature_key adds a per-event check. Haskimail signs only recipient and
|
|
23
|
+
# message_id, so a captured signature can be replayed with another event
|
|
24
|
+
# type; that is why signature-only mode needs allow_signature_only: true.
|
|
25
|
+
# * Inbound emails are not signed, so they are accepted only with Basic auth.
|
|
26
|
+
#
|
|
27
|
+
# Responses: 200 handled; 401 bad credentials; 403 bad or missing signature;
|
|
28
|
+
# 500 when your handler raises. Haskimail retries 5xx for every webhook; for
|
|
29
|
+
# inbound emails it also retries other non-2xx responses except 403.
|
|
30
|
+
class Endpoint
|
|
31
|
+
DEFAULT_MAX_BODY_BYTES = 32 * 1024 * 1024 # inbound email up to 10 MB, Base64-encoded
|
|
32
|
+
JSON_HEADERS = { "content-type" => "application/json" }.freeze
|
|
33
|
+
|
|
34
|
+
# @param signature_key [String, nil] account webhook key; when set, events without a
|
|
35
|
+
# valid `secret` are rejected
|
|
36
|
+
# @param username [String, nil] Basic auth user; when set, Basic auth is required
|
|
37
|
+
# @param password [String, nil] Basic auth password
|
|
38
|
+
# @param logger [#error, nil]
|
|
39
|
+
# @param allow_signature_only [Boolean] accept events authenticated only by signature_key
|
|
40
|
+
# (inbound emails are then rejected)
|
|
41
|
+
# @param allow_unauthenticated [Boolean] explicitly accept requests without any
|
|
42
|
+
# authentication (only for local development)
|
|
43
|
+
def initialize(signature_key: nil, username: nil, password: nil, max_body_bytes: DEFAULT_MAX_BODY_BYTES,
|
|
44
|
+
logger: nil, allow_signature_only: false, allow_unauthenticated: false, &handler)
|
|
45
|
+
@signature_key = signature_key.to_s.empty? ? nil : Secret.new(signature_key)
|
|
46
|
+
@username = username.to_s.empty? ? nil : Secret.new(username)
|
|
47
|
+
@password = password.to_s.empty? ? nil : Secret.new(password)
|
|
48
|
+
@max_body_bytes = max_body_bytes
|
|
49
|
+
@logger = logger
|
|
50
|
+
@allow_signature_only = allow_signature_only
|
|
51
|
+
@allow_unauthenticated = allow_unauthenticated
|
|
52
|
+
@handler = handler
|
|
53
|
+
validate_settings!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def call(env)
|
|
57
|
+
return respond(405, "Method Not Allowed", "allow" => "POST") unless env["REQUEST_METHOD"] == "POST"
|
|
58
|
+
return unauthorized unless authenticated?(env)
|
|
59
|
+
|
|
60
|
+
body = read_body(env)
|
|
61
|
+
return respond(413, "Payload Too Large") unless body
|
|
62
|
+
|
|
63
|
+
payload = Webhooks.parse(body)
|
|
64
|
+
return respond(403, "Forbidden") unless trusted?(payload)
|
|
65
|
+
|
|
66
|
+
dispatch(payload)
|
|
67
|
+
respond(200, "OK")
|
|
68
|
+
rescue ParseError
|
|
69
|
+
respond(400, "Bad Request")
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
@logger&.error("[Haskimail] webhook handler failed: #{e.class}: #{e.message}")
|
|
72
|
+
respond(500, "Internal Server Error")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def inspect
|
|
76
|
+
"#<#{self.class.name} signed=#{!@signature_key.nil?} basic_auth=#{basic_auth?}>"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def validate_settings!
|
|
82
|
+
if @username.nil? != @password.nil?
|
|
83
|
+
raise ConfigurationError, "Set both username and password for webhook Basic auth"
|
|
84
|
+
end
|
|
85
|
+
unless @max_body_bytes.is_a?(Integer) && @max_body_bytes.positive?
|
|
86
|
+
raise ConfigurationError, "max_body_bytes must be a positive Integer"
|
|
87
|
+
end
|
|
88
|
+
return if basic_auth? || @allow_unauthenticated
|
|
89
|
+
return if @signature_key && @allow_signature_only
|
|
90
|
+
|
|
91
|
+
raise ConfigurationError, "Configure Basic auth (username/password) for the webhook endpoint. " \
|
|
92
|
+
"Signature-only mode needs allow_signature_only: true; " \
|
|
93
|
+
"allow_unauthenticated: true is for local development only"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def basic_auth?
|
|
97
|
+
!@username.nil?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def authenticated?(env)
|
|
101
|
+
return true unless basic_auth?
|
|
102
|
+
|
|
103
|
+
Webhooks.valid_basic_auth?(env["HTTP_AUTHORIZATION"], username: @username.reveal, password: @password.reveal)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Events must carry a valid signature when a key is configured. Inbound
|
|
107
|
+
# emails are unsigned, so they require Basic auth.
|
|
108
|
+
# A configured signature key is always enforced, even with allow_unauthenticated.
|
|
109
|
+
def trusted?(payload)
|
|
110
|
+
if payload.is_a?(Event)
|
|
111
|
+
@signature_key.nil? || Webhooks.valid_signature?(payload, key: @signature_key.reveal)
|
|
112
|
+
else
|
|
113
|
+
basic_auth? || @allow_unauthenticated
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def read_body(env)
|
|
118
|
+
input = env["rack.input"]
|
|
119
|
+
return "" unless input
|
|
120
|
+
|
|
121
|
+
input.rewind if input.respond_to?(:rewind)
|
|
122
|
+
body = String.new(input.read(@max_body_bytes + 1).to_s)
|
|
123
|
+
input.rewind if input.respond_to?(:rewind)
|
|
124
|
+
body.bytesize > @max_body_bytes ? nil : body.force_encoding(Encoding::UTF_8)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def dispatch(payload)
|
|
128
|
+
if defined?(ActiveSupport::Notifications)
|
|
129
|
+
ActiveSupport::Notifications.instrument("webhook.haskimail", type: payload.type, event: payload) do
|
|
130
|
+
@handler&.call(payload)
|
|
131
|
+
end
|
|
132
|
+
else
|
|
133
|
+
@handler&.call(payload)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def unauthorized
|
|
138
|
+
respond(401, "Unauthorized", "www-authenticate" => 'Basic realm="Haskimail webhooks"')
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def respond(status, message, extra_headers = {})
|
|
142
|
+
[status, JSON_HEADERS.merge(extra_headers), [JSON.generate(status: message)]]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Haskimail
|
|
7
|
+
module Webhooks
|
|
8
|
+
# A delivery, bounce, spam complaint, open, click or subscription change event.
|
|
9
|
+
class Event
|
|
10
|
+
TYPES = %i[delivered bounced complained opened clicked subscription_change].freeze
|
|
11
|
+
BOUNCE_CATEGORIES = { "hrd" => :hard, "sft" => :soft, "blk" => :blocked, "spm" => :spam }.freeze
|
|
12
|
+
MOSCOW_OFFSET = "+03:00"
|
|
13
|
+
|
|
14
|
+
attr_reader :raw
|
|
15
|
+
|
|
16
|
+
def initialize(data)
|
|
17
|
+
@raw = data.transform_keys(&:to_s).freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Symbol] :delivered, :bounced, :complained, :opened, :clicked,
|
|
21
|
+
# :subscription_change (or the raw value, downcased, for unknown events)
|
|
22
|
+
def type
|
|
23
|
+
@type ||= begin
|
|
24
|
+
value = raw["event"].to_s.downcase
|
|
25
|
+
value == "subscriptionchange" ? :subscription_change : value.to_sym
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
TYPES.each do |event_type|
|
|
30
|
+
define_method(:"#{event_type}?") { type == event_type }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def recipient = string("recipient") || string("email")
|
|
34
|
+
def message_id = raw["message_id"].to_s
|
|
35
|
+
def tag = string("campaign_id")
|
|
36
|
+
def server_id = raw["server_id"]&.to_i
|
|
37
|
+
def stream_id = raw["stream_id"]&.to_s
|
|
38
|
+
def secret = string("secret")
|
|
39
|
+
|
|
40
|
+
# Metadata sent with the message. Haskimail delivers it as a JSON string
|
|
41
|
+
# (or an object in some events); always returns a Hash.
|
|
42
|
+
def metadata
|
|
43
|
+
@metadata ||= begin
|
|
44
|
+
value = raw["custom_vars"]
|
|
45
|
+
parsed = value.is_a?(String) && !value.strip.empty? ? JSON.parse(value) : value
|
|
46
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
47
|
+
rescue JSON::ParserError
|
|
48
|
+
{}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Time, nil] event time in UTC
|
|
53
|
+
def occurred_at
|
|
54
|
+
@occurred_at ||= if string("event_time_utc")
|
|
55
|
+
parse_time(raw["event_time_utc"], "+00:00")
|
|
56
|
+
elsif string("event_time")
|
|
57
|
+
parse_time(raw["event_time"], MOSCOW_OFFSET)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ---- bounced ----
|
|
62
|
+
def bounce_category = BOUNCE_CATEGORIES[raw["bounce_category"].to_s]
|
|
63
|
+
def hard_bounce? = bounced? && bounce_category == :hard
|
|
64
|
+
def soft_bounce? = bounced? && bounce_category == :soft
|
|
65
|
+
def bounce_code = string("code")
|
|
66
|
+
def bounce_description = string("description") || string("error")
|
|
67
|
+
|
|
68
|
+
# ---- clicked ----
|
|
69
|
+
def original_url = string("url_original")
|
|
70
|
+
def clicked_url = string("url_pers")
|
|
71
|
+
|
|
72
|
+
# ---- subscription_change ----
|
|
73
|
+
def suppress_sending? = raw["SuppressSending"].to_s == "true"
|
|
74
|
+
def suppression_reason = string("SuppressionReason")
|
|
75
|
+
|
|
76
|
+
# Haskimail may deliver an event more than once; use this to deduplicate.
|
|
77
|
+
def idempotency_key
|
|
78
|
+
[type, message_id, recipient, raw["event_time_utc"] || raw["event_time"], clicked_url || original_url].join(":")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def [](key) = raw[key.to_s]
|
|
82
|
+
|
|
83
|
+
def to_h = raw.dup
|
|
84
|
+
|
|
85
|
+
def inspect
|
|
86
|
+
"#<#{self.class.name} type=#{type.inspect} message_id=#{message_id.inspect}>"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def string(key)
|
|
92
|
+
value = raw[key]
|
|
93
|
+
value.nil? || value.to_s.empty? ? nil : value.to_s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_time(value, offset)
|
|
97
|
+
Time.strptime("#{value} #{offset}", "%Y-%m-%d %H:%M:%S %z").utc
|
|
98
|
+
rescue ArgumentError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Haskimail
|
|
6
|
+
module Webhooks
|
|
7
|
+
# An inbound email posted to the server's InboundHookUrl
|
|
8
|
+
# (https://haskimail.ru/developer/webhooks/inbound-webhook).
|
|
9
|
+
class InboundMessage
|
|
10
|
+
Address = Struct.new(:email, :name, :mailbox_hash, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# Inbound attachment; content is decoded lazily.
|
|
13
|
+
class Attachment
|
|
14
|
+
attr_reader :name, :content_type, :content_length, :content_id
|
|
15
|
+
|
|
16
|
+
def initialize(data)
|
|
17
|
+
@name = data["Name"].to_s
|
|
18
|
+
@content_type = data["ContentType"]
|
|
19
|
+
@content_length = data["ContentLength"]
|
|
20
|
+
@content_id = data["ContentID"].to_s
|
|
21
|
+
@encoded = data["Content"].to_s
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def inline? = !content_id.empty?
|
|
25
|
+
|
|
26
|
+
# @return [String] raw bytes (binary)
|
|
27
|
+
def read
|
|
28
|
+
Base64.decode64(@encoded)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def inspect
|
|
32
|
+
"#<#{self.class.name} name=#{name.inspect} content_type=#{content_type.inspect}>"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :raw
|
|
37
|
+
|
|
38
|
+
def initialize(data)
|
|
39
|
+
@raw = data.transform_keys(&:to_s).freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def type = :inbound
|
|
43
|
+
def message_id = raw["MessageID"]
|
|
44
|
+
def message_stream = raw["MessageStream"]&.to_s
|
|
45
|
+
def subject = raw["Subject"]
|
|
46
|
+
def date = raw["Date"]
|
|
47
|
+
def tag = raw["Tag"]
|
|
48
|
+
def mailbox_hash = raw["MailboxHash"]
|
|
49
|
+
def original_recipient = raw["OriginalRecipient"]
|
|
50
|
+
def reply_to = raw["ReplyTo"]
|
|
51
|
+
def text_body = raw["TextBody"]
|
|
52
|
+
def html_body = raw["HtmlBody"]
|
|
53
|
+
def stripped_text_reply = raw["StrippedTextReply"]
|
|
54
|
+
def raw_email = raw["RawEmail"]
|
|
55
|
+
|
|
56
|
+
def from
|
|
57
|
+
full = raw["FromFull"]
|
|
58
|
+
full.is_a?(Hash) ? address(full) : Address.new(email: raw["From"], name: raw["FromName"], mailbox_hash: nil)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to = addresses("ToFull")
|
|
62
|
+
def cc = addresses("CcFull")
|
|
63
|
+
def bcc = addresses("BccFull")
|
|
64
|
+
|
|
65
|
+
# @return [Array<Hash>] [{ "Name" => ..., "Value" => ... }]
|
|
66
|
+
def headers = Array(raw["Headers"])
|
|
67
|
+
|
|
68
|
+
# Case-insensitive lookup of the first header with this name.
|
|
69
|
+
def header(name)
|
|
70
|
+
headers.find { |header| header["Name"].to_s.casecmp?(name.to_s) }&.fetch("Value", nil)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def spam_score
|
|
74
|
+
value = header("X-Spam-Score")
|
|
75
|
+
value && Float(value, exception: false)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def attachments
|
|
79
|
+
@attachments ||= Array(raw["Attachments"]).map { |data| Attachment.new(data) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def [](key) = raw[key.to_s]
|
|
83
|
+
def to_h = raw.dup
|
|
84
|
+
|
|
85
|
+
def inspect
|
|
86
|
+
"#<#{self.class.name} message_id=#{message_id.inspect} attachments=#{attachments.size}>"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def addresses(key)
|
|
92
|
+
Array(raw[key]).map { |data| address(data) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def address(data)
|
|
96
|
+
Address.new(email: data["Email"], name: data["Name"], mailbox_hash: data["MailboxHash"])
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
require_relative "webhooks/event"
|
|
8
|
+
require_relative "webhooks/inbound_message"
|
|
9
|
+
require_relative "webhooks/endpoint"
|
|
10
|
+
|
|
11
|
+
module Haskimail
|
|
12
|
+
# Parsing and verification of incoming webhook requests
|
|
13
|
+
# (https://haskimail.ru/developer/webhooks/webhooks-overview).
|
|
14
|
+
#
|
|
15
|
+
# event = Haskimail::Webhooks.parse(request.raw_post)
|
|
16
|
+
# Haskimail::Webhooks.verify_signature!(event, key: ENV["HASKIMAIL_WEBHOOK_KEY"])
|
|
17
|
+
# case event
|
|
18
|
+
# when Haskimail::Webhooks::Event then event.bounced? ...
|
|
19
|
+
# when Haskimail::Webhooks::InboundMessage then event.stripped_text_reply ...
|
|
20
|
+
# end
|
|
21
|
+
module Webhooks
|
|
22
|
+
class ParseError < Error; end
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @param payload [String, Hash] raw JSON body or an already parsed Hash
|
|
27
|
+
# @return [Event, InboundMessage]
|
|
28
|
+
def parse(payload)
|
|
29
|
+
data = payload.is_a?(String) ? JSON.parse(payload) : payload
|
|
30
|
+
raise ParseError, "Webhook payload must be a JSON object" unless data.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
data = data.transform_keys(&:to_s)
|
|
33
|
+
return Event.new(data) if data.key?("event")
|
|
34
|
+
return InboundMessage.new(data) if data.key?("MessageID") || data.key?("FromFull")
|
|
35
|
+
|
|
36
|
+
raise ParseError, "Unrecognized Haskimail webhook payload"
|
|
37
|
+
rescue JSON::ParserError => e
|
|
38
|
+
raise ParseError, "Invalid JSON in webhook payload: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# MD5(recipient + message_id + key), as documented by Haskimail. Compared in
|
|
42
|
+
# constant time. Inbound webhooks are not signed — protect them with Basic auth.
|
|
43
|
+
#
|
|
44
|
+
# Note: the upstream scheme signs only recipient and message_id, so a captured
|
|
45
|
+
# secret can be replayed with another event type. Use Basic auth as well.
|
|
46
|
+
def valid_signature?(event, key:)
|
|
47
|
+
event = parse(event) unless event.is_a?(Event) || event.is_a?(InboundMessage)
|
|
48
|
+
return false unless event.is_a?(Event)
|
|
49
|
+
|
|
50
|
+
secret = event.secret.to_s.downcase
|
|
51
|
+
return false if key.to_s.empty? || secret.empty?
|
|
52
|
+
|
|
53
|
+
expected = Digest::MD5.hexdigest("#{event.recipient}#{event.message_id}#{key}")
|
|
54
|
+
secure_compare(expected, secret)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Event] the parsed, verified event
|
|
58
|
+
# @raise [WebhookVerificationError]
|
|
59
|
+
def verify_signature!(event, key:)
|
|
60
|
+
event = parse(event) unless event.is_a?(Event) || event.is_a?(InboundMessage)
|
|
61
|
+
raise WebhookVerificationError, "Invalid Haskimail webhook signature" unless valid_signature?(event, key: key)
|
|
62
|
+
|
|
63
|
+
event
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Checks an "Authorization: Basic ..." header against expected credentials.
|
|
67
|
+
def valid_basic_auth?(authorization, username:, password:)
|
|
68
|
+
scheme, encoded = authorization.to_s.split(" ", 2)
|
|
69
|
+
return false unless scheme&.casecmp?("basic") && encoded
|
|
70
|
+
|
|
71
|
+
given_user, given_password = encoded.unpack1("m").split(":", 2)
|
|
72
|
+
# Evaluate both comparisons so timing does not reveal which part was wrong.
|
|
73
|
+
user_ok = secure_compare(Digest::SHA256.hexdigest(given_user.to_s), Digest::SHA256.hexdigest(username.to_s))
|
|
74
|
+
password_ok = secure_compare(Digest::SHA256.hexdigest(given_password.to_s),
|
|
75
|
+
Digest::SHA256.hexdigest(password.to_s))
|
|
76
|
+
user_ok & password_ok
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def secure_compare(left, right)
|
|
80
|
+
left = left.to_s
|
|
81
|
+
right = right.to_s
|
|
82
|
+
return false unless left.bytesize == right.bytesize
|
|
83
|
+
|
|
84
|
+
OpenSSL.fixed_length_secure_compare(left, right)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "haskimail"
|
|
4
|
+
require "mail"
|
|
5
|
+
require "active_support"
|
|
6
|
+
require "active_support/lazy_load_hooks"
|
|
7
|
+
require_relative "haskimail/rails/message_options"
|
|
8
|
+
require_relative "haskimail/rails/mail_converter"
|
|
9
|
+
require_relative "haskimail/rails/delivery_method"
|
|
10
|
+
require_relative "haskimail/rails/mailer_helpers"
|
|
11
|
+
|
|
12
|
+
Mail::Message.include(Haskimail::Rails::MailExtension)
|
|
13
|
+
|
|
14
|
+
ActiveSupport.on_load(:action_mailer) do
|
|
15
|
+
add_delivery_method :haskimail, Haskimail::Rails::DeliveryMethod
|
|
16
|
+
include Haskimail::Rails::MailerHelpers
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require_relative "haskimail/rails/railtie" if defined?(Rails::Railtie)
|
data/lib/haskimail.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "haskimail/version"
|
|
4
|
+
require_relative "haskimail/errors"
|
|
5
|
+
require_relative "haskimail/util"
|
|
6
|
+
require_relative "haskimail/secret"
|
|
7
|
+
require_relative "haskimail/configuration"
|
|
8
|
+
require_relative "haskimail/serializer"
|
|
9
|
+
require_relative "haskimail/response_handler"
|
|
10
|
+
require_relative "haskimail/connection"
|
|
11
|
+
require_relative "haskimail/paginator"
|
|
12
|
+
require_relative "haskimail/attachment"
|
|
13
|
+
require_relative "haskimail/resources/base"
|
|
14
|
+
require_relative "haskimail/resources/emails"
|
|
15
|
+
require_relative "haskimail/resources/templates"
|
|
16
|
+
require_relative "haskimail/resources/messages"
|
|
17
|
+
require_relative "haskimail/resources/bounces"
|
|
18
|
+
require_relative "haskimail/resources/message_streams"
|
|
19
|
+
require_relative "haskimail/resources/stats"
|
|
20
|
+
require_relative "haskimail/resources/webhooks"
|
|
21
|
+
require_relative "haskimail/resources/inbound_rules"
|
|
22
|
+
require_relative "haskimail/resources/suppressions"
|
|
23
|
+
require_relative "haskimail/resources/servers"
|
|
24
|
+
require_relative "haskimail/resources/domains"
|
|
25
|
+
require_relative "haskimail/resources/account_templates"
|
|
26
|
+
require_relative "haskimail/clients"
|
|
27
|
+
require_relative "haskimail/webhooks"
|
|
28
|
+
|
|
29
|
+
# Ruby SDK for the Haskimail transactional email API (https://haskimail.ru/developer).
|
|
30
|
+
module Haskimail
|
|
31
|
+
# Send-only test token: the API validates the request but sends nothing.
|
|
32
|
+
TEST_TOKEN = "HASKIMAIL_API_TEST"
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
def config
|
|
36
|
+
@config ||= Configuration.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def configure
|
|
40
|
+
yield config
|
|
41
|
+
config
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset_config!
|
|
45
|
+
@config = Configuration.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Haskimail::ServerClient] built from the global configuration
|
|
49
|
+
def server_client(token = nil, **options)
|
|
50
|
+
ServerClient.new(token, **options)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Haskimail::AccountClient] built from the global configuration
|
|
54
|
+
def account_client(token = nil, **options)
|
|
55
|
+
AccountClient.new(token, **options)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|