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

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(message_id, secret:, max_age: DEFAULT_MAX_AGE, now: Time.now.to_i)
  raise InvalidToken, "message-id is empty" if message_id.to_s.empty?

  id = strip_brackets(message_id.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.

Raises:

  • (ArgumentError)


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?

Returns:

  • (Boolean)


72
73
74
75
76
77
78
79
# File 'lib/cloudflare/email/secure_message_id.rb', line 72

def match?(message_id, prefix: DEFAULT_PREFIX)
  id = strip_brackets(message_id.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