mailbounce 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/lib/mailbounce/recipient.rb +102 -0
- data/lib/mailbounce/rejection.rb +115 -0
- data/lib/mailbounce/report.rb +122 -0
- data/lib/mailbounce/status.rb +58 -0
- data/lib/mailbounce/version.rb +3 -0
- data/lib/mailbounce.rb +17 -0
- data/mailbounce.gemspec +34 -0
- metadata +68 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e07f3db5d808f4a1d3d9f4b53fe83f3f78f619008e14de533d4101cfe1f99a6e
|
|
4
|
+
data.tar.gz: b2a4341195772ea43fe13a282a27dbcff914da3dbd232f7f0ad7ecbd144a1a59
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f404a22a31ce871ab3e6fb948b0dc8ed5e1dafe9a85b74822fd5e30230f7d2b8ecfedea124370a349f4a139bbc7c136ea519f5ac46ad4f6f8c84518d74436abc
|
|
7
|
+
data.tar.gz: fd63774ff78d90ef852e94a1994e1282beef51b23daffae422041c0a70a3d02a4e0beb9330639d821093fdcc391afc22855f0efcf5d8a743ddb8a526765c94f1
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- Delivery status report parsing (RFC 3464), every per-recipient block of a `multipart/report`
|
|
8
|
+
- Enhanced status codes (RFC 3463), with the class read apart from the condition
|
|
9
|
+
- Rejection classification into `:invalid`, `:full`, `:oversized`, `:blocked`, and `:unknown`
|
|
10
|
+
- The sending address as a classification input, so a rejection quoting it reads as being about the sender
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PostRider
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# mailbounce
|
|
2
|
+
|
|
3
|
+
📠Email delivery failures for Ruby
|
|
4
|
+
|
|
5
|
+
Answers one question about a rejected message: whose fault was it?
|
|
6
|
+
|
|
7
|
+
**mailbounce** reads the two ways mail comes back: a rejection during the SMTP session, and a delivery status notification (RFC 3464) arriving afterwards. Pure Ruby, no dependencies beyond `mail`.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Report parsing (RFC 3464), every per-recipient block of a `multipart/report`
|
|
12
|
+
- Enhanced status codes (RFC 3463), read as class and condition apart
|
|
13
|
+
- Classification - was the rejection about the recipient, or about you
|
|
14
|
+
- No network, no state
|
|
15
|
+
|
|
16
|
+
> It reports what a failure *is*, never what it *costs*. Retiring an address, retrying, and how long to remember either are yours to decide.
|
|
17
|
+
|
|
18
|
+
## Contents
|
|
19
|
+
|
|
20
|
+
- [Getting Started](#getting-started)
|
|
21
|
+
- [Classifying a Rejection](#classifying-a-rejection)
|
|
22
|
+
- [Categories](#categories)
|
|
23
|
+
- [Permanent Is Not the Same as Final](#permanent-is-not-the-same-as-final)
|
|
24
|
+
- [Reading a Report](#reading-a-report)
|
|
25
|
+
- [Testing](#testing)
|
|
26
|
+
- [History](#history)
|
|
27
|
+
|
|
28
|
+
## Getting Started
|
|
29
|
+
|
|
30
|
+
Add this line to your application's Gemfile:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
gem "mailbounce"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires Ruby >= 3.3.4
|
|
37
|
+
|
|
38
|
+
## Classifying a Rejection
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
rejection = MailBounce.classify(response: "550 5.1.1 <someone@example.com>: User unknown",
|
|
42
|
+
sending_ip: "203.0.113.9")
|
|
43
|
+
|
|
44
|
+
rejection.category # => :invalid
|
|
45
|
+
rejection.status.to_s # => "5.1.1"
|
|
46
|
+
rejection.permanent? # => true
|
|
47
|
+
rejection.about_us? # => false
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- **response** - the SMTP reply, or a diagnostic taken from a report
|
|
51
|
+
- **sending_ip** - optional, the address the mail went out from
|
|
52
|
+
|
|
53
|
+
## Categories
|
|
54
|
+
|
|
55
|
+
| Category | What it means |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `:invalid` | The mailbox doesn't exist - the one category that says something lasting about the address |
|
|
58
|
+
| `:full` | The mailbox exists and is over quota |
|
|
59
|
+
| `:oversized` | This message was too large; another might not be |
|
|
60
|
+
| `:blocked` | Policy, reputation, or the network - about the exchange, not the address |
|
|
61
|
+
| `:unknown` | Nothing matched |
|
|
62
|
+
|
|
63
|
+
Classification reads the enhanced status code first, then falls back to wording, since plenty of servers pair a bare `5.0.0` with a diagnostic that names the cause. The wording it knows is what Postfix, Exim, and Sendmail say by default - there's no per-provider table, since those go stale silently.
|
|
64
|
+
|
|
65
|
+
Unmatched rejections come back `:unknown` rather than as a guess.
|
|
66
|
+
|
|
67
|
+
Giving `sending_ip` is what makes the interesting case work. A server quoting the address the mail came from is describing the sender, so it classifies `:blocked` however firmly it talks about the recipient:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
MailBounce.classify(response: "550 5.1.1 rejected because 203.0.113.9 is on a blocklist",
|
|
71
|
+
sending_ip: "203.0.113.9").category
|
|
72
|
+
# => :blocked, not :invalid
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Without it, that response reads as a recipient failure - and retiring an address over someone else's opinion of your IP is the mistake this library exists to prevent.
|
|
76
|
+
|
|
77
|
+
## Permanent Is Not the Same as Final
|
|
78
|
+
|
|
79
|
+
`permanent?` reports what the server claimed: a 5xx is permanent, per RFC 3463. `category` reports what the rejection was about. They disagree exactly where it matters:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
rejection = MailBounce.classify(response: "550 5.7.1 Service unavailable; client host blocked")
|
|
83
|
+
|
|
84
|
+
rejection.permanent? # => true - the server said so
|
|
85
|
+
rejection.category # => :blocked - but a listing lapses
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Both are reported so a caller can decide which to believe. This library doesn't decide for you.
|
|
89
|
+
|
|
90
|
+
## Reading a Report
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
report = MailBounce.parse(raw_bounce)
|
|
94
|
+
|
|
95
|
+
report.any? # => true, when there's a machine-readable part to read
|
|
96
|
+
report.recipients # => [#<MailBounce::Recipient ...>]
|
|
97
|
+
|
|
98
|
+
recipient = report.for("someone@example.com")
|
|
99
|
+
recipient.action # => "failed"
|
|
100
|
+
recipient.status.to_s # => "5.1.1"
|
|
101
|
+
recipient.permanent? # => true
|
|
102
|
+
recipient.permanent_failure? # => true - failed, and permanently
|
|
103
|
+
recipient.diagnostic_code # => "smtp; 550 5.1.1 ... User unknown"
|
|
104
|
+
recipient.remote_mta # => "dns; mx.example.com"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Reports announce delays and successes by the same route and in the same shape, so `permanent_failure?` is usually the question worth asking - `Action: delayed` is not a bounce however permanent the covering message reads.
|
|
108
|
+
|
|
109
|
+
A report naming several recipients speaks for a given address only when it names it; one covering a single recipient needn't name anyone. Nothing raises: a report with no machine-readable part, or bytes that aren't a message at all, reports nothing.
|
|
110
|
+
|
|
111
|
+
## Testing
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
bundle install
|
|
115
|
+
rake
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Nothing touches the network. Fixtures in `test/fixtures/` are complete bounce messages covering permanent failure, delay, transient failure, several recipients, an unnamed recipient, and a report with no machine-readable part at all.
|
|
119
|
+
|
|
120
|
+
## History
|
|
121
|
+
|
|
122
|
+
View the changelog
|
|
123
|
+
|
|
124
|
+
## Contributing
|
|
125
|
+
|
|
126
|
+
Everyone is encouraged to help improve this project:
|
|
127
|
+
|
|
128
|
+
- [Report bugs](https://github.com/example/mailbounce/issues)
|
|
129
|
+
- Fix bugs and submit pull requests
|
|
130
|
+
- Write, clarify, or fix documentation
|
|
131
|
+
- Suggest or add new features
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require "mailbounce/status"
|
|
2
|
+
|
|
3
|
+
module MailBounce
|
|
4
|
+
# One per-recipient block of a delivery status report (RFC 3464 §2.3).
|
|
5
|
+
class Recipient
|
|
6
|
+
FAILED_ACTION = "failed".freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :final_recipient, :original_recipient, :action, :diagnostic_code, :remote_mta
|
|
9
|
+
|
|
10
|
+
def initialize(final_recipient: nil, original_recipient: nil, failed_recipient: nil,
|
|
11
|
+
action: nil, status: nil, diagnostic_code: nil, remote_mta: nil)
|
|
12
|
+
@final_recipient = final_recipient
|
|
13
|
+
@original_recipient = original_recipient
|
|
14
|
+
@failed_recipient = failed_recipient
|
|
15
|
+
@action = action
|
|
16
|
+
@status = status
|
|
17
|
+
@diagnostic_code = diagnostic_code
|
|
18
|
+
@remote_mta = remote_mta
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
WRAPPED = /\A["'<]|[">']\z/
|
|
22
|
+
|
|
23
|
+
# RFC 3464 §2.3.2 wants Final-Recipient; also try Original-Recipient and
|
|
24
|
+
# X-Failed-Recipients so an address-less block is not treated as anonymous
|
|
25
|
+
# (anonymous answers every name).
|
|
26
|
+
def address
|
|
27
|
+
@address ||= sources.filter_map { |source| addressed(source) }.first.to_s
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def anonymous?
|
|
31
|
+
address.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def failed?
|
|
35
|
+
action.to_s.strip.casecmp?(FAILED_ACTION)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def permanent?
|
|
39
|
+
status&.permanent? || false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def permanent_failure?
|
|
43
|
+
failed? && permanent?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def status
|
|
47
|
+
@parsed_status ||= Status.parse(@status)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def addressed_to?(recipient)
|
|
51
|
+
named = recipient.to_s.strip
|
|
52
|
+
|
|
53
|
+
!named.empty? && named?(named)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
def sources
|
|
58
|
+
[ @final_recipient, @original_recipient, @failed_recipient ]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Type-tagged as `rfc822; someone@example.com`.
|
|
62
|
+
def addressed(source)
|
|
63
|
+
address = unwrapped(source.to_s.split(";", 2).last.to_s)
|
|
64
|
+
|
|
65
|
+
address unless address.empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Strip outer brackets/quotes only. Quoted local-parts lose their quotes
|
|
69
|
+
# (safe miss — DSNs rarely carry that form).
|
|
70
|
+
def unwrapped(value)
|
|
71
|
+
address = value.strip
|
|
72
|
+
|
|
73
|
+
while address.match?(WRAPPED)
|
|
74
|
+
address = address.sub(/\A["'<]/, "").sub(/[">']\z/, "").strip
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
address
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# An RFC 6533 address carries raw UTF-8, but the message it arrived in
|
|
81
|
+
# reads as bytes — the two only compare once their encodings agree.
|
|
82
|
+
def named?(named)
|
|
83
|
+
mine, theirs = comparable(address), comparable(named)
|
|
84
|
+
|
|
85
|
+
if mine.encoding == theirs.encoding
|
|
86
|
+
mine.casecmp?(theirs)
|
|
87
|
+
else
|
|
88
|
+
mine.b.casecmp?(theirs.b)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def comparable(value)
|
|
93
|
+
utf8 = value.dup.force_encoding(Encoding::UTF_8)
|
|
94
|
+
|
|
95
|
+
if utf8.valid_encoding?
|
|
96
|
+
utf8
|
|
97
|
+
else
|
|
98
|
+
value.b
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
require "ipaddr"
|
|
2
|
+
|
|
3
|
+
require "mailbounce/status"
|
|
4
|
+
|
|
5
|
+
module MailBounce
|
|
6
|
+
# Classifies an SMTP rejection: recipient fault vs sender/exchange.
|
|
7
|
+
class Rejection
|
|
8
|
+
REPLY_CODE = /\A\s*(?<code>[245]\d\d)\b/
|
|
9
|
+
|
|
10
|
+
# RFC 3463 §3.2, class dropped. 1.2 is the system; permanently reported it
|
|
11
|
+
# is as dead as a mailbox (transient 4.1.2 is merely unreachable).
|
|
12
|
+
INVALID_CONDITIONS = %w[ 1.1 1.2 1.3 1.6 2.1 ].freeze
|
|
13
|
+
|
|
14
|
+
FULL_CONDITIONS = %w[ 2.2 ].freeze
|
|
15
|
+
|
|
16
|
+
OVERSIZED_CONDITIONS = %w[ 2.3 3.4 ].freeze
|
|
17
|
+
|
|
18
|
+
# RFC 3463 §3.2: sender's address, not the recipient's.
|
|
19
|
+
SENDER_CONDITIONS = %w[ 1.7 1.8 ].freeze
|
|
20
|
+
|
|
21
|
+
# Network, system, protocol, policy — the exchange, not the mailbox.
|
|
22
|
+
BLOCKED_SUBJECTS = %w[ 3 4 5 7 ].freeze
|
|
23
|
+
|
|
24
|
+
# Common MTA wording when no enhanced status is present. No bare
|
|
25
|
+
# "does not exist": that predicate takes any noun, so "sender address does
|
|
26
|
+
# not exist" would read as a dead recipient.
|
|
27
|
+
# No "mailbox unavailable": RFC 5321 §4.2.3 uses that phrase for both 450
|
|
28
|
+
# (busy / temp policy) and 550 (not found / no access / policy).
|
|
29
|
+
INVALID_TEXT = /user unknown|unknown user|no such (?:user|recipient)|recipient not found|invalid recipient/i
|
|
30
|
+
|
|
31
|
+
FULL_TEXT = /mailbox full|over quota/i
|
|
32
|
+
|
|
33
|
+
BLOCKED_TEXT = /blocked|blacklist|spam|denied/i
|
|
34
|
+
|
|
35
|
+
QUAD = /(?:\d{1,3}\.){3}\d{1,3}/
|
|
36
|
+
|
|
37
|
+
# Mapped form first: otherwise `\h*(?::\h*){2,}` stops at `::ffff:203`.
|
|
38
|
+
ADDRESS = /\h*(?::\h*)+:#{QUAD}|#{QUAD}|\h*(?::\h*){2,}/
|
|
39
|
+
|
|
40
|
+
attr_reader :response
|
|
41
|
+
|
|
42
|
+
def initialize(response:, sending_ip: nil)
|
|
43
|
+
@response = response.to_s
|
|
44
|
+
@sending_ips = Array(sending_ip).compact
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Prefer :unknown over a guess — a wrongful retire is worse than extra retries.
|
|
48
|
+
def category
|
|
49
|
+
@category ||=
|
|
50
|
+
if about_us?
|
|
51
|
+
:blocked
|
|
52
|
+
else
|
|
53
|
+
categorized_by_status || categorized_by_text || :unknown
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# What the server claimed (5xx / class 5), not what we made of it.
|
|
58
|
+
def permanent?
|
|
59
|
+
if status
|
|
60
|
+
status.permanent?
|
|
61
|
+
else
|
|
62
|
+
reply_code.to_s.start_with?(Status::PERMANENT_CLASS)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def about_us?
|
|
67
|
+
ours.any? { |ip| quoted_addresses.include?(ip) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def status
|
|
71
|
+
@status ||= Status.parse(@response)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
# Unrecognized conditions fall through so bare 5.0.0 can use the diagnostic text.
|
|
76
|
+
def categorized_by_status
|
|
77
|
+
if status
|
|
78
|
+
case status.condition
|
|
79
|
+
when *INVALID_CONDITIONS then :invalid
|
|
80
|
+
when *FULL_CONDITIONS then :full
|
|
81
|
+
when *OVERSIZED_CONDITIONS then :oversized
|
|
82
|
+
when *SENDER_CONDITIONS then :blocked
|
|
83
|
+
else :blocked if BLOCKED_SUBJECTS.include?(status.subject)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def categorized_by_text
|
|
89
|
+
case @response
|
|
90
|
+
when INVALID_TEXT then :invalid
|
|
91
|
+
when FULL_TEXT then :full
|
|
92
|
+
when BLOCKED_TEXT then :blocked
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def reply_code
|
|
97
|
+
@response[REPLY_CODE, :code]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def ours
|
|
101
|
+
@ours ||= @sending_ips.filter_map { |ip| address_for(ip) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def quoted_addresses
|
|
105
|
+
@quoted_addresses ||= @response.scan(ADDRESS).filter_map { |token| address_for(token) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# `.native` so ::ffff:203.0.113.9 and 203.0.113.9 compare equal.
|
|
109
|
+
def address_for(value)
|
|
110
|
+
IPAddr.new(value.to_s).native
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require "mail"
|
|
2
|
+
|
|
3
|
+
require "mailbounce/recipient"
|
|
4
|
+
|
|
5
|
+
module MailBounce
|
|
6
|
+
# Machine-readable half of a bounce (RFC 3464). Unreadable input reports nothing.
|
|
7
|
+
class Report
|
|
8
|
+
# RFC 6533 adds the global type for internationalized addresses.
|
|
9
|
+
DELIVERY_STATUS_TYPES = %w[ message/delivery-status message/global-delivery-status ].freeze
|
|
10
|
+
|
|
11
|
+
# RFC 3464 §2.1.
|
|
12
|
+
BLANK_LINE = /\r?\n[ \t]*\r?\n/
|
|
13
|
+
|
|
14
|
+
FIELD_NAME = /\A(?<name>[A-Za-z][A-Za-z0-9\-]*)[ \t]*:/
|
|
15
|
+
|
|
16
|
+
# Value stays on its line so an empty field cannot swallow the next.
|
|
17
|
+
FIELDS = {
|
|
18
|
+
action: /^Action[ \t]*:[ \t]*(?<value>.+)$/i,
|
|
19
|
+
status: /^Status[ \t]*:[ \t]*(?<value>.+)$/i,
|
|
20
|
+
final_recipient: /^Final-Recipient[ \t]*:[ \t]*(?<value>.+)$/i,
|
|
21
|
+
original_recipient: /^Original-Recipient[ \t]*:[ \t]*(?<value>.+)$/i,
|
|
22
|
+
diagnostic_code: /^Diagnostic-Code[ \t]*:[ \t]*(?<value>.+)$/i,
|
|
23
|
+
remote_mta: /^Remote-MTA[ \t]*:[ \t]*(?<value>.+)$/i
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def self.parse(raw)
|
|
27
|
+
new(raw)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(raw)
|
|
31
|
+
@mail = Mail.new(raw.to_s)
|
|
32
|
+
rescue StandardError
|
|
33
|
+
@mail = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def recipients
|
|
37
|
+
@recipients ||= reported_blocks.map { |block| recipient_from(block) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# One unnamed recipient may stand in for any address; a named report only
|
|
41
|
+
# for the address it names.
|
|
42
|
+
def for(address = nil)
|
|
43
|
+
if named = recipients.find { |recipient| recipient.addressed_to?(address) }
|
|
44
|
+
named
|
|
45
|
+
else
|
|
46
|
+
sole_recipient_for(address)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def any?
|
|
51
|
+
recipients.any?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
def sole_recipient_for(address)
|
|
56
|
+
if recipients.one? && (recipients.first.anonymous? || address.to_s.strip.empty?)
|
|
57
|
+
recipients.first
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def blocks
|
|
62
|
+
delivery_status.to_s.split(BLANK_LINE).flat_map { |chunk| per_recipient(chunk) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Per-recipient blocks carry Action; the report preamble does not.
|
|
66
|
+
def reported_blocks
|
|
67
|
+
@reported_blocks ||= blocks.select { |block| unfolded(block).match?(FIELDS[:action]) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# X-Failed-Recipients only when one block and one address — no positional guess.
|
|
71
|
+
def attributable_address
|
|
72
|
+
failed_recipients.first if reported_blocks.one? && failed_recipients.one?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def failed_recipients
|
|
76
|
+
@failed_recipients ||= failed_recipient_headers.flat_map { |header| header.split(",") }.map(&:strip).reject(&:empty?)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def failed_recipient_headers
|
|
80
|
+
Array(@mail&.[]("X-Failed-Recipients")).map(&:to_s)
|
|
81
|
+
rescue StandardError
|
|
82
|
+
[]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A block ends when a field it already has reappears (order is not fixed).
|
|
86
|
+
def per_recipient(chunk)
|
|
87
|
+
chunk.lines.each_with_object([]) do |line, blocks|
|
|
88
|
+
if blocks.last && continues?(blocks.last, line)
|
|
89
|
+
blocks.last << line
|
|
90
|
+
else
|
|
91
|
+
blocks << +line
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def continues?(block, line)
|
|
97
|
+
if name = line[FIELD_NAME, :name]
|
|
98
|
+
!block.match?(/^#{Regexp.escape(name)}[ \t]*:/i)
|
|
99
|
+
else
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def recipient_from(block)
|
|
105
|
+
fields = FIELDS.transform_values { |pattern| unfolded(block)[pattern, :value]&.strip }
|
|
106
|
+
|
|
107
|
+
Recipient.new(**fields, failed_recipient: attributable_address)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def unfolded(block)
|
|
111
|
+
block.gsub(/\r?\n[ \t]+/, " ")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The mail gem parses and decodes lazily, so unreadable input raises here
|
|
115
|
+
# rather than at construction.
|
|
116
|
+
def delivery_status
|
|
117
|
+
@mail&.all_parts&.find { |part| DELIVERY_STATUS_TYPES.include?(part.mime_type) }&.body&.decoded
|
|
118
|
+
rescue StandardError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module MailBounce
|
|
2
|
+
# Enhanced status code (RFC 3463): class.subject.detail.
|
|
3
|
+
class Status
|
|
4
|
+
# Only where a status belongs — not dotted quads in diagnostics (5.1.1.10
|
|
5
|
+
# is an address). Misses a bare `smtp; 5.1.1 …`; that deferral is cheaper
|
|
6
|
+
# than retiring a mailbox over a host IP read as a status.
|
|
7
|
+
PATTERN = /
|
|
8
|
+
(?: \A[ \t]* | [245]\d\d[ \t\-]+ | ^Status[ \t]*:[ \t]* )
|
|
9
|
+
(?<code>[245]\.\d{1,3}\.\d{1,3}) (?!\.\d)
|
|
10
|
+
/xi
|
|
11
|
+
|
|
12
|
+
PERMANENT_CLASS = "5".freeze
|
|
13
|
+
|
|
14
|
+
TRANSIENT_CLASS = "4".freeze
|
|
15
|
+
|
|
16
|
+
def self.parse(text)
|
|
17
|
+
if code = text.to_s[PATTERN, :code]
|
|
18
|
+
new(code)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :code
|
|
23
|
+
|
|
24
|
+
def initialize(code)
|
|
25
|
+
@code = code.to_s
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def permanent?
|
|
29
|
+
status_class == PERMANENT_CLASS
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def transient?
|
|
33
|
+
status_class == TRANSIENT_CLASS
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Class dropped: 5.1.1 and 4.1.1 share condition "1.1".
|
|
37
|
+
def condition
|
|
38
|
+
parts.drop(1).join(".")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def subject
|
|
42
|
+
parts[1].to_s
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_s
|
|
46
|
+
code
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
def status_class
|
|
51
|
+
parts.first.to_s
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parts
|
|
55
|
+
@parts ||= code.split(".")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/mailbounce.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "mailbounce/version"
|
|
2
|
+
require "mailbounce/status"
|
|
3
|
+
require "mailbounce/rejection"
|
|
4
|
+
require "mailbounce/recipient"
|
|
5
|
+
require "mailbounce/report"
|
|
6
|
+
|
|
7
|
+
# Reads delivery failures: bounce reports (RFC 3464) and SMTP rejections.
|
|
8
|
+
# Reports what a failure is; retiring or retrying is the caller's decision.
|
|
9
|
+
module MailBounce
|
|
10
|
+
def self.classify(response:, sending_ip: nil)
|
|
11
|
+
Rejection.new(response: response, sending_ip: sending_ip)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.parse(raw)
|
|
15
|
+
Report.parse(raw)
|
|
16
|
+
end
|
|
17
|
+
end
|
data/mailbounce.gemspec
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
$LOAD_PATH.push File.expand_path('lib', __dir__)
|
|
2
|
+
require_relative "lib/mailbounce/version"
|
|
3
|
+
|
|
4
|
+
Gem::Specification.new do |spec|
|
|
5
|
+
spec.name = "mailbounce"
|
|
6
|
+
spec.version = MailBounce::VERSION
|
|
7
|
+
spec.platform = Gem::Platform::RUBY
|
|
8
|
+
spec.required_ruby_version = ">= 3.3.4"
|
|
9
|
+
spec.authors = [ "Simon Lev" ]
|
|
10
|
+
spec.email = [ "support@postrider.dev" ]
|
|
11
|
+
|
|
12
|
+
spec.summary = "RFC 3464 delivery status notifications, parsed and classified, in pure Ruby."
|
|
13
|
+
spec.description = "Answers one question about a rejected message: whose fault was it?"
|
|
14
|
+
|
|
15
|
+
spec.homepage = "https://github.com/mailpiece/mailbounce"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
|
|
18
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
19
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
20
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
21
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
22
|
+
|
|
23
|
+
# Explicit list: `git ls-files` is empty before the first commit.
|
|
24
|
+
spec.files = Dir[
|
|
25
|
+
"lib/**/*.rb",
|
|
26
|
+
"CHANGELOG.md",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"mailbounce.gemspec"
|
|
30
|
+
]
|
|
31
|
+
spec.require_paths = [ "lib" ]
|
|
32
|
+
|
|
33
|
+
spec.add_dependency "mail", ">= 2.7"
|
|
34
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mailbounce
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Simon Lev
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: mail
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.7'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.7'
|
|
26
|
+
description: 'Answers one question about a rejected message: whose fault was it?'
|
|
27
|
+
email:
|
|
28
|
+
- support@postrider.dev
|
|
29
|
+
executables: []
|
|
30
|
+
extensions: []
|
|
31
|
+
extra_rdoc_files: []
|
|
32
|
+
files:
|
|
33
|
+
- CHANGELOG.md
|
|
34
|
+
- LICENSE
|
|
35
|
+
- README.md
|
|
36
|
+
- lib/mailbounce.rb
|
|
37
|
+
- lib/mailbounce/recipient.rb
|
|
38
|
+
- lib/mailbounce/rejection.rb
|
|
39
|
+
- lib/mailbounce/report.rb
|
|
40
|
+
- lib/mailbounce/status.rb
|
|
41
|
+
- lib/mailbounce/version.rb
|
|
42
|
+
- mailbounce.gemspec
|
|
43
|
+
homepage: https://github.com/mailpiece/mailbounce
|
|
44
|
+
licenses:
|
|
45
|
+
- MIT
|
|
46
|
+
metadata:
|
|
47
|
+
source_code_uri: https://github.com/mailpiece/mailbounce
|
|
48
|
+
changelog_uri: https://github.com/mailpiece/mailbounce/blob/main/CHANGELOG.md
|
|
49
|
+
bug_tracker_uri: https://github.com/mailpiece/mailbounce/issues
|
|
50
|
+
rubygems_mfa_required: 'true'
|
|
51
|
+
rdoc_options: []
|
|
52
|
+
require_paths:
|
|
53
|
+
- lib
|
|
54
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: 3.3.4
|
|
59
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '0'
|
|
64
|
+
requirements: []
|
|
65
|
+
rubygems_version: 4.0.3
|
|
66
|
+
specification_version: 4
|
|
67
|
+
summary: RFC 3464 delivery status notifications, parsed and classified, in pure Ruby.
|
|
68
|
+
test_files: []
|