Module: Cloudflare::Email::SecureMessageId
- Defined in:
- lib/cloudflare/email/secure_message_id.rb
Overview
Signed outbound Message-IDs for reply authentication.
Sign the outbound Message-ID with HMAC-SHA256. The recipient’s reply naturally carries the signed id in ‘In-Reply-To:`, which your mailbox verifies and decodes to recover the original thread state.
See README’s “Signed replies” section for a full usage example.
Constant Summary collapse
- InvalidToken =
Class.new(Cloudflare::Email::Error)
- DEFAULT_PREFIX =
"msg".freeze
- DEFAULT_MAX_AGE =
30 days
30 * 24 * 60 * 60
- EPOCH_OFFSET =
Time.utc(2026, 1, 1).to_i.freeze
Class Method Summary collapse
-
.decode(message_id, secret:, max_age: DEFAULT_MAX_AGE, now: Time.now.to_i) ⇒ Object
Decode a Message-ID produced by encode.
-
.encode(payload:, domain:, secret:, prefix: DEFAULT_PREFIX, now: Time.now.to_i) ⇒ Object
Build a signed Message-ID carrying ‘payload`.
-
.match?(message_id, prefix: DEFAULT_PREFIX) ⇒ Boolean
Cheap heuristic — does this look like one of our signed Message-IDs?.
Class Method Details
.decode(message_id, secret:, max_age: DEFAULT_MAX_AGE, now: Time.now.to_i) ⇒ Object
Decode a Message-ID produced by encode. Accepts ‘<bracketed>` form too. Returns parsed payload or raises InvalidToken.
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'lib/cloudflare/email/secure_message_id.rb', line 39 def decode(, secret:, max_age: DEFAULT_MAX_AGE, now: Time.now.to_i) raise InvalidToken, "message-id is empty" if .to_s.empty? id = strip_brackets(.to_s).strip local, domain = id.split("@", 2) raise InvalidToken, "missing @ in message-id" unless local && domain _prefix, b64, mac = local.split(".", 3) raise InvalidToken, "malformed message-id" unless b64 && mac && !b64.empty? && !mac.empty? expected = Signing.hmac_hex(secret, b64) raise InvalidToken, "signature mismatch" unless Signing.secure_compare(expected, mac) packed = begin Signing.base64url_decode(b64) rescue StandardError raise InvalidToken, "base64 decode failed" end raise InvalidToken, "truncated packed bytes" if packed.bytesize < 4 iat_offset = packed.byteslice(0, 4).unpack1("N") iat = iat_offset + EPOCH_OFFSET payload_json = packed.byteslice(4..) raise InvalidToken, "token expired" if now - iat > max_age raise InvalidToken, "token timestamp in the future" if iat - now > 5 * 60 JSON.parse(payload_json.to_s) rescue JSON::ParserError raise InvalidToken, "payload not valid JSON" end |
.encode(payload:, domain:, secret:, prefix: DEFAULT_PREFIX, now: Time.now.to_i) ⇒ Object
Build a signed Message-ID carrying ‘payload`. Returns the bare Message-ID without angle brackets — SMTP/Mail adds them.
23 24 25 26 27 28 29 30 31 32 33 34 35 |
# File 'lib/cloudflare/email/secure_message_id.rb', line 23 def encode(payload:, domain:, secret:, prefix: DEFAULT_PREFIX, now: Time.now.to_i) raise ArgumentError, "secret must not be empty" if secret.to_s.empty? raise ArgumentError, "domain must not be empty" if domain.to_s.empty? iat_offset = now.to_i - EPOCH_OFFSET raise ArgumentError, "timestamp out of 32-bit range" if iat_offset.negative? || iat_offset >= (1 << 32) packed = [iat_offset].pack("N") + JSON.generate(payload) b64 = Signing.base64url_encode(packed) mac = Signing.hmac_hex(secret, b64) "#{prefix}.#{b64}.#{mac}@#{domain}" end |
.match?(message_id, prefix: DEFAULT_PREFIX) ⇒ Boolean
Cheap heuristic — does this look like one of our signed Message-IDs?
72 73 74 75 76 77 78 79 |
# File 'lib/cloudflare/email/secure_message_id.rb', line 72 def match?(, prefix: DEFAULT_PREFIX) id = strip_brackets(.to_s).strip local, domain = id.split("@", 2) return false unless local && domain p, b64, mac = local.split(".", 3) return false unless p && b64 && mac p == prefix && !b64.empty? && mac.match?(/\A[0-9a-f]{64}\z/) end |