Class: MailVerp
- Inherits:
-
Object
- Object
- MailVerp
- 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
-
#address?(address) ⇒ Boolean
Whether
addresshas the shape of one this library issued. -
#address_for(identifier, domain:) ⇒ Object
The envelope sender for an outbound message, tagged with
identifierso a bounce sent back here can be traced to it. -
#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. -
#initialize(secret:, prefix: DEFAULT_PREFIX, separator: DEFAULT_SEPARATOR, tag_separator: DEFAULT_TAG_SEPARATOR, hmac_length: DEFAULT_HMAC_LENGTH, digest: DEFAULT_DIGEST) ⇒ MailVerp
constructor
A new instance of MailVerp.
-
#max_identifier_length ⇒ Object
The longest identifier that still fits a 64-octet local part with this prefix, separators, and HMAC length.
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.
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.
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_length ⇒ Object
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 |