Module: Mailkite
- Defined in:
- lib/mailkite.rb
Defined Under Namespace
Constant Summary collapse
- VERSION =
"0.5.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
-
.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.
-
.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.
-
.reply_block_sender ⇒ Object
Control-mode reply telling MailKite to block the sender.
-
.reply_drop ⇒ Object
Control-mode reply telling MailKite to drop (discard) the message.
-
.reply_ok ⇒ Object
The canonical body to return from a webhook handler so MailKite marks the delivery acknowledged.
-
.reply_spam ⇒ Object
Control-mode reply telling MailKite to mark the message as spam.
-
.secure_compare(a, b) ⇒ Object
Length-independent constant-time string compare (no openssl >= 2.2 needed).
-
.verify_webhook(signature, payload, secret, tolerance_ms = DEFAULT_TOLERANCE_MS) ⇒ Object
Verify an ‘x-mailkite-signature` header on an inbound webhook delivery.
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_sender ⇒ Object
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_drop ⇒ Object
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_ok ⇒ Object
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_spam ⇒ Object
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 |