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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +267 -0
  5. data/lib/generators/haskimail/install/install_generator.rb +32 -0
  6. data/lib/generators/haskimail/install/templates/haskimail.rb +27 -0
  7. data/lib/haskimail/attachment.rb +84 -0
  8. data/lib/haskimail/clients.rb +78 -0
  9. data/lib/haskimail/configuration.rb +89 -0
  10. data/lib/haskimail/connection.rb +268 -0
  11. data/lib/haskimail/errors.rb +130 -0
  12. data/lib/haskimail/paginator.rb +48 -0
  13. data/lib/haskimail/rails/delivery_method.rb +56 -0
  14. data/lib/haskimail/rails/mail_converter.rb +130 -0
  15. data/lib/haskimail/rails/mailer_helpers.rb +42 -0
  16. data/lib/haskimail/rails/message_options.rb +88 -0
  17. data/lib/haskimail/rails/railtie.rb +28 -0
  18. data/lib/haskimail/resources/account_templates.rb +53 -0
  19. data/lib/haskimail/resources/base.rb +75 -0
  20. data/lib/haskimail/resources/bounces.rb +43 -0
  21. data/lib/haskimail/resources/domains.rb +42 -0
  22. data/lib/haskimail/resources/emails.rb +149 -0
  23. data/lib/haskimail/resources/inbound_rules.rb +33 -0
  24. data/lib/haskimail/resources/message_streams.rb +46 -0
  25. data/lib/haskimail/resources/messages.rb +135 -0
  26. data/lib/haskimail/resources/servers.rb +89 -0
  27. data/lib/haskimail/resources/stats.rb +89 -0
  28. data/lib/haskimail/resources/suppressions.rb +39 -0
  29. data/lib/haskimail/resources/templates.rb +68 -0
  30. data/lib/haskimail/resources/webhooks.rb +80 -0
  31. data/lib/haskimail/response_handler.rb +68 -0
  32. data/lib/haskimail/secret.rb +40 -0
  33. data/lib/haskimail/serializer.rb +75 -0
  34. data/lib/haskimail/util.rb +110 -0
  35. data/lib/haskimail/version.rb +5 -0
  36. data/lib/haskimail/webhooks/endpoint.rb +146 -0
  37. data/lib/haskimail/webhooks/event.rb +103 -0
  38. data/lib/haskimail/webhooks/inbound_message.rb +100 -0
  39. data/lib/haskimail/webhooks.rb +87 -0
  40. data/lib/haskimail-rails.rb +19 -0
  41. data/lib/haskimail.rb +58 -0
  42. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Haskimail
4
+ VERSION = "1.0.0"
5
+ 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