Module: Mailkite

Defined in:
lib/mailkite.rb

Defined Under Namespace

Classes: Client, Error

Constant Summary collapse

VERSION =
"0.6.0"
DEFAULT_BASE_URL =
"https://api.mailkite.dev"
DEFAULT_TOLERANCE_MS =

Reject webhook events older than this (ms) to block replays. Pass 0 to disable.

5 * 60 * 1000

Class Method Summary collapse

Class Method Details

.decrypt(envelope, private_key) ⇒ Object

Decrypt a MailKite at-rest envelope (JSON string) with the matching RSA private key (PEM), returning the original UTF-8 plaintext. Local only.



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/mailkite.rb', line 115

def self.decrypt(envelope, private_key)
  env = envelope.is_a?(String) ? JSON.parse(envelope) : envelope
  priv = OpenSSL::PKey.read(private_key)

  iv = Base64.strict_decode64(env["iv"])
  wrapped = Base64.strict_decode64(env["wrappedKey"])
  ct = Base64.strict_decode64(env["ciphertext"])
  body = ct[0...-16]
  tag = ct[-16..]

  raw_key = priv.decrypt(wrapped, rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256")

  cipher = OpenSSL::Cipher.new("aes-256-gcm")
  cipher.decrypt
  cipher.key = raw_key
  cipher.iv = iv
  cipher.auth_tag = tag
  (cipher.update(body) + cipher.final).force_encoding("UTF-8")
end

.encrypt(plaintext, public_key) ⇒ Object

Encrypt a UTF-8 string to a customer RSA public key (SPKI/PEM), producing the MailKite at-rest envelope as a compact JSON string. Hybrid scheme: a fresh AES-256-GCM content key encrypts the data, then the raw key is wrapped with RSA-OAEP (SHA-256). Local only — no network.



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/mailkite.rb', line 86

def self.encrypt(plaintext, public_key)
  pub = OpenSSL::PKey.read(public_key)
  fp = OpenSSL::Digest::SHA256.hexdigest(pub.to_der)

  raw_key = OpenSSL::Random.random_bytes(32)
  iv = OpenSSL::Random.random_bytes(12)

  cipher = OpenSSL::Cipher.new("aes-256-gcm")
  cipher.encrypt
  cipher.key = raw_key
  cipher.iv = iv
  body = cipher.update(plaintext.to_s.dup.force_encoding("UTF-8")) + cipher.final
  ciphertext = body + cipher.auth_tag # GCM ct with 16-byte tag appended

  wrapped = pub.encrypt(raw_key, rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256")

  JSON.generate({
    "v" => 1,
    "keyAlg" => "RSA-OAEP-256",
    "fp" => fp,
    "enc" => "A256GCM",
    "iv" => Base64.strict_encode64(iv),
    "wrappedKey" => Base64.strict_encode64(wrapped),
    "ciphertext" => Base64.strict_encode64(ciphertext),
  })
end

.reply_block_senderObject

Control-mode reply telling MailKite to block the sender. Returns the exact string ‘href="{"type":"block-sender"">status”:“ok”,“actions”:}`.



78
79
80
# File 'lib/mailkite.rb', line 78

def self.reply_block_sender
  '{"status":"ok","actions":[{"type":"block-sender"}]}'
end

.reply_dropObject

Control-mode reply telling MailKite to drop (discard) the message. Returns the exact string ‘“status”:“drop”`.



72
73
74
# File 'lib/mailkite.rb', line 72

def self.reply_drop
  '{"status":"drop"}'
end

.reply_okObject

The canonical body to return from a webhook handler so MailKite marks the delivery acknowledged. Returns the exact string ‘“status”:“ok”`.



60
61
62
# File 'lib/mailkite.rb', line 60

def self.reply_ok
  '{"status":"ok"}'
end

.reply_spamObject

Control-mode reply telling MailKite to mark the message as spam. Returns the exact string ‘“status”:“spam”`.



66
67
68
# File 'lib/mailkite.rb', line 66

def self.reply_spam
  '{"status":"spam"}'
end

.secure_compare(a, b) ⇒ Object

Length-independent constant-time string compare (no openssl >= 2.2 needed).



50
51
52
53
54
55
56
# File 'lib/mailkite.rb', line 50

def self.secure_compare(a, b)
  return false unless a.bytesize == b.bytesize

  diff = 0
  a.bytes.each_with_index { |byte, i| diff |= byte ^ b.getbyte(i) }
  diff.zero?
end

.verify_webhook(signature, payload, secret, tolerance_ms = DEFAULT_TOLERANCE_MS) ⇒ Object

Verify an ‘x-mailkite-signature` header on an inbound webhook delivery. Local HMAC-SHA256 check — no network call. Pass the raw, unparsed body.



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/mailkite.rb', line 25

def self.verify_webhook(signature, payload, secret, tolerance_ms = DEFAULT_TOLERANCE_MS)
  return false unless signature.is_a?(String) && !signature.empty?

  parts = {}
  signature.split(",").each do |seg|
    i = seg.index("=")
    next unless i
    parts[seg[0...i].strip] = seg[(i + 1)..].strip
  end
  t = parts["t"]
  v1 = parts["v1"]
  return false if t.nil? || v1.nil? || t.empty? || v1.empty? || !t.match?(/\A-?\d+\z/)

  # The t in the header is milliseconds since the epoch.
  if tolerance_ms && tolerance_ms > 0
    return false if ((Time.now.to_f * 1000) - t.to_i).abs > tolerance_ms
  end

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{payload}")
  secure_compare(expected, v1)
rescue StandardError
  false
end