Class: MailVerp

Inherits:
Object
  • Object
show all
Defined in:
lib/mailverp.rb,
lib/mailverp/version.rb

Overview

Builds and reads VERP (Variable Envelope Return Path) addresses: an outbound message's own envelope sender, tagged with a signed identifier, so a bounce arriving later says which delivery it's about without anyone having to parse its body. Building and reading are the only two things this library does; what an identifier means, and what to do once you have it back, are yours.

Constant Summary collapse

DEFAULT_PREFIX =
"bounces".freeze
DEFAULT_SEPARATOR =
"+".freeze
DEFAULT_TAG_SEPARATOR =
".".freeze
DEFAULT_HMAC_LENGTH =
16
DEFAULT_DIGEST =
"SHA256".freeze
MAX_LOCAL_PART_BYTESIZE =

RFC 5321 §4.5.3.1.1

64
MAX_DOMAIN_BYTESIZE =

RFC 1035 §3.1

253
MAX_ADDRESS_BYTESIZE =

RFC 5321 §4.5.3.1.3, minus the two path brackets

254
IDENTIFIER_PATTERN =
/\A[a-z0-9_-]+\z/.freeze
DOMAIN_LABEL =
/[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/.freeze
DOMAIN_PATTERN =
/\A#{DOMAIN_LABEL}(?:\.#{DOMAIN_LABEL})*\z/.freeze
VERSION =
"0.1.0"

Instance Method Summary collapse

Constructor Details

#initialize(secret:, prefix: DEFAULT_PREFIX, separator: DEFAULT_SEPARATOR, tag_separator: DEFAULT_TAG_SEPARATOR, hmac_length: DEFAULT_HMAC_LENGTH, digest: DEFAULT_DIGEST) ⇒ MailVerp

Returns a new instance of MailVerp.

Raises:

  • (ArgumentError)


25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/mailverp.rb', line 25

def initialize(secret:, prefix: DEFAULT_PREFIX, separator: DEFAULT_SEPARATOR,
  tag_separator: DEFAULT_TAG_SEPARATOR, hmac_length: DEFAULT_HMAC_LENGTH, digest: DEFAULT_DIGEST)
  @secrets = Array(secret)
  raise ArgumentError, "secret must not be empty" if @secrets.empty?

  @prefix = prefix
  @separator = separator
  @tag_separator = tag_separator
  @hmac_length = hmac_length
  @digest = digest
  @pattern = build_pattern
end

Instance Method Details

#address?(address) ⇒ Boolean

Whether address has the shape of one this library issued. Shape only - the HMAC isn't checked, so this says nothing about authenticity.

Returns:

  • (Boolean)


81
82
83
# File 'lib/mailverp.rb', line 81

def address?(address)
  !!@pattern.match(local_part_of(address))
end

#address_for(identifier, domain:) ⇒ Object

The envelope sender for an outbound message, tagged with identifier so a bounce sent back here can be traced to it. The signature covers the domain, so the address verifies there and nowhere else.

Both arguments are validated: this goes straight into a MAIL FROM, and anything that isn't a legal reverse-path raises rather than becoming SMTP command injection.



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/mailverp.rb', line 45

def address_for(identifier, domain:)
  if !identifier.to_s.match?(IDENTIFIER_PATTERN)
    raise ArgumentError, "identifier #{identifier.inspect} is not safe for a local part " \
      "(expected #{IDENTIFIER_PATTERN.inspect})"
  elsif !valid_domain?(domain)
    raise ArgumentError, "domain #{domain.inspect} is not a valid DNS name " \
      "(punycode it yourself if it's internationalized)"
  elsif local_part(identifier, domain).bytesize > MAX_LOCAL_PART_BYTESIZE
    raise ArgumentError, "identifier #{identifier.inspect} is too long " \
      "(max #{max_identifier_length} characters with this configuration)"
  elsif address(identifier, domain).bytesize > MAX_ADDRESS_BYTESIZE
    raise ArgumentError, "address for #{identifier.inspect}@#{domain} is too long " \
      "(max #{MAX_ADDRESS_BYTESIZE} octets)"
  else
    address(identifier, domain)
  end
end

#identifier_for(address) ⇒ Object

The identifier tagged into address, or nil if the shape is wrong, the domain isn't a plausible DNS name, the prefix differs, or the HMAC doesn't verify against any configured secret.

The domain is checked first, before the HMAC is even computed: these addresses arrive off the wire, and a malformed one is rejected on shape alone rather than by relying on the signature to catch it.



70
71
72
73
74
75
76
77
# File 'lib/mailverp.rb', line 70

def identifier_for(address)
  domain = domain_of(address)
  if valid_domain?(domain) &&
      (match = @pattern.match(local_part_of(address))) &&
      valid_hmac?(match[:id], domain, match[:hmac])
    match[:id]
  end
end

#max_identifier_lengthObject

The longest identifier that still fits a 64-octet local part with this prefix, separators, and HMAC length. The local-part budget only: a long enough domain can still push the whole address past 254 octets and make address_for raise for an identifier within this length.



89
90
91
# File 'lib/mailverp.rb', line 89

def max_identifier_length
  MAX_LOCAL_PART_BYTESIZE - @prefix.bytesize - @separator.bytesize - @tag_separator.bytesize - @hmac_length
end