diff --git a/dsn/dsn.go b/dsn/dsn.go new file mode 100644 index 00000000..d9ce5cb3 --- /dev/null +++ b/dsn/dsn.go @@ -0,0 +1,39 @@ +// Package dsn meant to work with Delivery Status Notification (DSN) per rfc3464: +// https://datatracker.ietf.org/doc/html/rfc3464 +package dsn + +import ( + "net/textproto" + "strings" +) + +// Report represents delivery status report as per https://datatracker.ietf.org/doc/html/rfc6522. +// It is container of the MIME type multipart/report and contains 3 parts. +type Report struct { + // Explanation contains a human-readable description of the condition(s) that caused the report to be generated. + Explanation Explanation + // DeliveryStatus provides a machine-readable description of the condition(s) that caused the report to be generated. + DeliveryStatus DeliveryStatus + // OriginalMessage is optional original message or its portion. + OriginalMessage []byte +} + +// Explanation contains a human-readable description of the condition(s) that caused the report to be generated. +// Where a description of the error is desired in several languages or several media, a multipart/alternative construct MAY be used. +type Explanation struct { + Text string + HTML string +} + +// DeliveryStatus provides a machine-readable description of the condition(s) that caused the report to be generated. +type DeliveryStatus struct { + // RecipientDSN is Delivery Status Notification per message. + MessageDSNs []textproto.MIMEHeader + // RecipientDSN is Delivery Status Notification per recipient. + RecipientDSNs []textproto.MIMEHeader +} + +// IsFailed returns true if Action field is "failed", meaning message could not be delivered to the recipient. +func IsFailed(n textproto.MIMEHeader) bool { + return strings.EqualFold(n.Get("Action"), "failed") +} diff --git a/dsn/parse.go b/dsn/parse.go new file mode 100644 index 00000000..e4c0c744 --- /dev/null +++ b/dsn/parse.go @@ -0,0 +1,156 @@ +package dsn + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "net/textproto" + + "github.com/jhillyerd/enmime" +) + +// ParseReport parses p as a "container" for delivery status report (per rfc6522) if p is "multipart/report". +// Otherwise returns nil. +func ParseReport(p *enmime.Part) (*Report, error) { + if !isMultipartReport(p.ContentType) { + return nil, nil + } + + var report Report + // first part is explanation + explanation := p.FirstChild + if explanation == nil || !setExplanation(&report.Explanation, explanation) { + return &report, nil + } + + // second part is delivery-status + deliveryStatus := explanation.NextSibling + if deliveryStatus == nil || !isDeliveryStatus(deliveryStatus.ContentType) { + return &report, nil + } + + ds, err := parseDeliveryStatus(deliveryStatus.Content) + if err != nil { + return nil, err + } + report.DeliveryStatus = ds + + // third part is original email + originalEmail := deliveryStatus.NextSibling + if originalEmail == nil || !isEmail(originalEmail.ContentType) { + return &report, nil + } + + report.OriginalMessage = originalEmail.Content + + return &report, nil +} + +func isMultipartReport(ct string) bool { + return ct == "multipart/report" +} + +func isDeliveryStatus(ct string) bool { + return ct == "message/delivery-status" || ct == "message/global-delivery-status" +} + +func isEmail(ct string) bool { + return ct == "message/rfc822" +} + +func setExplanation(e *Explanation, p *enmime.Part) bool { + if p == nil { + return false + } + + switch p.ContentType { + case "text/plain", "": // treat no content-type as text + e.Text = string(p.Content) + return true + case "text/html": + e.HTML = string(p.Content) + return true + case "multipart/alternative": + // the structure is next: + // multipart/alternative + // - text/plain (FirstChild) + // - text/html (FirstChild.NextSibling) + if setExplanation(e, p.FirstChild) { + return setExplanation(e, p.FirstChild.NextSibling) + } + default: + return false + } + + return false +} + +func parseDeliveryStatus(date []byte) (DeliveryStatus, error) { + fields, err := parseDeliveryStatusMessage(date) + if err != nil { + return DeliveryStatus{}, fmt.Errorf("parse delivey status: %w", err) + } + + perMessage, perRecipient := splitDSNFields(fields) + + return DeliveryStatus{ + MessageDSNs: perMessage, + RecipientDSNs: perRecipient, + }, nil +} + +// parseDeliveryStatusMessage parses delivery-status message per https://www.rfc-editor.org/rfc/rfc3464#section-2.1 +// The body of a message/delivery-status consists of one or more "fields" formatted according to the ABNF of +// RFC 822 header "fields". In other words, body of delivery status is multiple headers separated by blank line. +// First part is per-message fields, following by per-recipient fields. +func parseDeliveryStatusMessage(data []byte) ([]textproto.MIMEHeader, error) { + if len(data) > 0 && data[len(data)-1] != '\n' { // additional new line if missing + data = append(data, byte('\n')) + } + data = append(data, byte('\n')) // fix for https://github.com/golang/go/issues/47897 - can't read messages with headers only + + r := textproto.NewReader(bufio.NewReader(bytes.NewReader(data))) + var ( + fields []textproto.MIMEHeader + err error + ) + // parse body as multiple header fields separated by blank lines + for err == nil { + h, err := r.ReadMIMEHeader() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + + return nil, fmt.Errorf("read MIME header fields: %w", err) + } + + fields = append(fields, h) + } + + return fields, nil +} + +// splitDSNFields splits into per-message and per-recipient fields. +// First part is per-message fields, following by per-recipient fields. +func splitDSNFields(fields []textproto.MIMEHeader) (perMessage, perRecipient []textproto.MIMEHeader) { + for i, f := range fields { + if isPerMessageDSN(f) { + perMessage = append(perMessage, f) + continue + } + + perRecipient = fields[i:] + break + } + + return +} + +// isPerMessageDSN returns true if field is per-message DSN field. +// According to https://datatracker.ietf.org/doc/html/rfc3464#section-3, minimal per-message DSN must have Reporting-MTA field. +func isPerMessageDSN(header textproto.MIMEHeader) bool { + return header.Get("Reporting-MTA") != "" +} diff --git a/dsn/parse_internal_test.go b/dsn/parse_internal_test.go new file mode 100644 index 00000000..c6d490ec --- /dev/null +++ b/dsn/parse_internal_test.go @@ -0,0 +1,132 @@ +package dsn + +import ( + "net/textproto" + "testing" + + "github.com/jhillyerd/enmime" + "github.com/stretchr/testify/assert" +) + +func TestParseDeliveryStatusMessage(t *testing.T) { + tests := map[string]struct { + status string + want []textproto.MIMEHeader + }{ + "regular": { + status: `Reporting-MTA: dns; cs.utk.edu + +Action: failed +Status: 4.0.0 +Diagnostic-Code: smtp; 426 connection timed out +`, + want: []textproto.MIMEHeader{ + { + "Reporting-Mta": []string{"dns; cs.utk.edu"}, + }, + { + "Action": []string{"failed"}, + "Status": []string{"4.0.0"}, + "Diagnostic-Code": []string{"smtp; 426 connection timed out"}, + }, + }, + }, + "without per-message DSN": { + status: `Action: failed +Status: 4.0.0 +Diagnostic-Code: smtp; 426 connection timed out +`, + want: []textproto.MIMEHeader{ + { + "Action": []string{"failed"}, + "Status": []string{"4.0.0"}, + "Diagnostic-Code": []string{"smtp; 426 connection timed out"}, + }, + }, + }, + "without new line": { + status: `Action: failed +Status: 4.0.0 +Diagnostic-Code: smtp; 426 connection timed out`, + want: []textproto.MIMEHeader{ + { + "Action": []string{"failed"}, + "Status": []string{"4.0.0"}, + "Diagnostic-Code": []string{"smtp; 426 connection timed out"}, + }, + }, + }, + "empty": { + status: "", + want: []textproto.MIMEHeader{{}}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := parseDeliveryStatusMessage([]byte(tt.status)) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, tt.want, got, "should correctly parse delivery/status message") + }) + } +} + +func TestSetExplanation(t *testing.T) { + tests := map[string]struct { + part *enmime.Part + want Explanation + }{ + "text/plain": { + part: &enmime.Part{ + ContentType: "text/plain", + Content: []byte("text content"), + }, + want: Explanation{Text: "text content"}, + }, + "text/html": { + part: &enmime.Part{ + ContentType: "text/html", + Content: []byte("HTML content"), + }, + want: Explanation{HTML: "HTML content"}, + }, + "multipart/alternative": { + part: &enmime.Part{ + ContentType: "multipart/alternative", + FirstChild: &enmime.Part{ + ContentType: "text/plain", + Content: []byte("text content"), + NextSibling: &enmime.Part{ + ContentType: "text/html", + Content: []byte("HTML content"), + }, + }, + }, + want: Explanation{Text: "text content", HTML: "HTML content"}, + }, + "no content-type": { + part: &enmime.Part{ + ContentType: "", + Content: []byte("text content"), + }, + want: Explanation{Text: "text content"}, + }, + "other": { + part: &enmime.Part{ + ContentType: "other", + Content: []byte("some content"), + }, + want: Explanation{}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var e Explanation + setExplanation(&e, tt.part) + assert.Equal(t, tt.want, e) + }) + } +} diff --git a/dsn/parse_test.go b/dsn/parse_test.go new file mode 100644 index 00000000..ad8e1ae9 --- /dev/null +++ b/dsn/parse_test.go @@ -0,0 +1,180 @@ +package dsn_test + +import ( + "bytes" + "net/textproto" + "os" + "path/filepath" + "testing" + + "github.com/jhillyerd/enmime" + "github.com/jhillyerd/enmime/dsn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseReport(t *testing.T) { + tests := map[string]struct { + filename string + want *dsn.Report + }{ + "simple": { + filename: "simple_dsn.raw", + want: &dsn.Report{ + Explanation: dsn.Explanation{Text: "[human-readable explanation goes here]\n"}, + DeliveryStatus: dsn.DeliveryStatus{ + MessageDSNs: []textproto.MIMEHeader{ + {"Reporting-Mta": []string{"dns; cs.utk.edu"}}, + }, + RecipientDSNs: []textproto.MIMEHeader{ + { + "Original-Recipient": []string{"rfc822;louisl@larry.slip.umd.edu"}, + "Final-Recipient": []string{"rfc822;louisl@larry.slip.umd.edu"}, + "Action": []string{"failed"}, + "Status": []string{"4.0.0"}, + "Diagnostic-Code": []string{"smtp; 426 connection timed out"}, + "Last-Attempt-Date": []string{"Thu, 7 Jul 1994 17:15:49 -0400"}, + }, + }, + }, + OriginalMessage: []byte("[original message goes here]\n"), + }, + }, + "simple with HTML": { + filename: "simple_dsn_with_html.raw", + want: &dsn.Report{ + Explanation: dsn.Explanation{HTML: "