Files
warmbly/internal/pkg/dsn/dsn.go
T

177 lines
6.1 KiB
Go

// Package dsn parses delivery-status notifications (bounce messages, RFC 3464)
// well enough to decide whether a permanent failure occurred, who it was for,
// and which original message it concerns. It is deliberately conservative:
// callers act (suppress a recipient) only on a confirmed permanent failure, so
// a soft/transient bounce is never treated as hard.
package dsn
import (
"regexp"
"strings"
)
// Report is the extracted result of parsing a bounce message.
type Report struct {
// IsBounce is true when the message looks like a delivery-status report at
// all (worth acting on); false for ordinary mail.
IsBounce bool
// Permanent is true only when a 5.x.x status or an explicit permanent
// "Action: failed" was found. Transient (4.x.x / delayed) stays false.
Permanent bool
// FailedRecipient is the address that bounced (Final/Original-Recipient).
FailedRecipient string
// OriginalMessageID is the Message-ID of the original outbound message the
// bounce concerns, recovered from the returned headers section.
OriginalMessageID string
}
var (
reStatus = regexp.MustCompile(`(?im)^\s*Status:\s*([245])\.\d{1,3}\.\d{1,3}`)
reAction = regexp.MustCompile(`(?im)^\s*Action:\s*(failed|delayed|delivered|relayed|expanded)`)
reFinalRcpt = regexp.MustCompile(`(?im)^\s*(?:Final|Original)-Recipient:\s*[^;\r\n]*;\s*<?([^\s<>]+@[^\s<>]+?)>?\s*$`)
reMessageID = regexp.MustCompile(`(?im)^\s*Message-ID:\s*<([^>\s]+)>`)
reDiagnostic = regexp.MustCompile(`(?im)^\s*Diagnostic-Code:\s*[^;\r\n]*;\s*([245])\d\d`)
)
// senderMarkers identify a machine bounce source in the From line.
var senderMarkers = []string{"mailer-daemon", "postmaster@", "mail-daemon"}
// subjectMarkers are the subjects mail servers write on a failure notice.
var subjectMarkers = []string{
"undeliverable", "undelivered mail", "delivery status notification",
"returned mail", "delivery failure", "mail delivery failed", "mail delivery failure",
"delivery has failed", "failure notice", "message not delivered", "delivery incomplete",
"non remis", "unzustellbar",
}
// IsBounceSender reports a mail-system sender in a From line.
func IsBounceSender(from string) bool {
f := strings.ToLower(from)
for _, m := range senderMarkers {
if strings.Contains(f, m) {
return true
}
}
return false
}
// HasBounceSubject reports a subject that starts as a mail server's failure
// notice does. A prefix, so a person's "Re: <subject>" never matches.
func HasBounceSubject(subject string) bool {
s := strings.ToLower(strings.TrimSpace(subject))
for _, m := range subjectMarkers {
if strings.HasPrefix(s, m) {
return true
}
}
return false
}
// Detect reports whether an inbound message looks like a bounce, using only the
// cheap envelope signals (no body parse). Callers gate the full Parse on this.
func Detect(from, subject, contentType string) bool {
if IsBounceSender(from) {
return true
}
if strings.Contains(strings.ToLower(contentType), "multipart/report") {
return true
}
s := strings.ToLower(subject)
for _, m := range subjectMarkers {
if strings.Contains(s, m) {
return true
}
}
return false
}
// Parse extracts bounce details from the message body (the DSN parts). It is
// safe on non-DSN bodies: fields simply stay empty. Permanence is decided from
// the machine-readable Status / Diagnostic-Code / Action fields, never from the
// human-readable prose, so a "delayed" notice is never a permanent bounce.
func Parse(body string) Report {
var r Report
if m := reStatus.FindStringSubmatch(body); m != nil {
r.IsBounce = true
r.Permanent = m[1] == "5"
}
if !r.Permanent {
if m := reDiagnostic.FindStringSubmatch(body); m != nil {
r.IsBounce = true
r.Permanent = m[1] == "5"
}
}
if a := reAction.FindStringSubmatch(body); a != nil {
r.IsBounce = true
if strings.EqualFold(a[1], "failed") && r.hasNo4xx(body) {
// "failed" is permanent per RFC 3464, but a co-present 4.x.x status
// (retry-then-fail) means transient — defer to the status code.
r.Permanent = true
}
}
if m := reFinalRcpt.FindStringSubmatch(body); m != nil {
r.FailedRecipient = strings.TrimSpace(m[1])
}
// The last Message-ID in the body belongs to the returned original message
// (the DSN's own id, if present, is a header, not in the body parts).
if ids := reMessageID.FindAllStringSubmatch(body, -1); len(ids) > 0 {
r.OriginalMessageID = strings.TrimSpace(ids[len(ids)-1][1])
}
return r
}
func (r Report) hasNo4xx(body string) bool {
if m := reStatus.FindStringSubmatch(body); m != nil {
return m[1] != "4"
}
return true
}
// reReturned starts the copy of the original message a notice returns, whose
// words are the sender's and say nothing about the delivery.
var reReturned = regexp.MustCompile(`(?i)(-{2,}\s*original message|original message headers|message/rfc822)`)
// noticeText is the mail system's own part of a notice.
func noticeText(body string) string {
if loc := reReturned.FindStringIndex(body); loc != nil {
return body[:loc[0]]
}
return body
}
var reTransient = regexp.MustCompile(`(?i)\b(delay|delayed|will retry|will be retried|temporar(y|ily)|still trying)\b`)
// IsTransientNotice reports a notice that says delivery is still being
// retried: a 4.x.x status, or the mail system's own words about a delay.
func IsTransientNotice(subject, body string) bool {
if m := reStatus.FindStringSubmatch(body); m != nil && m[1] == "4" {
return true
}
return reTransient.MatchString(subject) || reTransient.MatchString(noticeText(body))
}
// WithFailedRecipients reads a failure notice that carries no RFC 3464 status
// part, only the X-Failed-Recipients header Gmail and Exim add (a Google Group
// that refuses the post, say). It is permanent unless something in the notice
// says delivery is still being retried; a report that already decided keeps
// its own answer.
func (r Report) WithFailedRecipients(header, subject, body string) Report {
header = strings.TrimSpace(header)
if header == "" || r.Permanent {
return r
}
if IsTransientNotice(subject, body) {
return r
}
r.IsBounce = true
r.Permanent = true
if r.FailedRecipient == "" {
first := strings.TrimSpace(strings.Split(header, ",")[0])
r.FailedRecipient = strings.Trim(first, "<>")
}
return r
}