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: "
[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"), + }, + }, + "multi-recipient": { + filename: "multi_recipient_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;arathib@vnet.ibm.com"}, + "Final-Recipient": []string{"rfc822;arathib@vnet.ibm.com"}, + "Action": []string{"failed"}, + "Status": []string{"5.0.0 (permanent failure)"}, + "Diagnostic-Code": []string{"smtp; 550 'arathib@vnet.IBM.COM' is not a registered gateway user"}, + "Remote-Mta": []string{"dns; vnet.ibm.com"}, + }, + { + "Original-Recipient": []string{"rfc822;johnh@hpnjld.njd.hp.com"}, + "Final-Recipient": []string{"rfc822;johnh@hpnjld.njd.hp.com"}, + "Action": []string{"delayed"}, + "Status": []string{"4.0.0 (hpnjld.njd.jp.com: host name lookup failure)"}, + }, + { + "Original-Recipient": []string{"rfc822;wsnell@sdcc13.ucsd.edu"}, + "Final-Recipient": []string{"rfc822;wsnell@sdcc13.ucsd.edu"}, + "Action": []string{"failed"}, + "Status": []string{"5.0.0"}, + "Diagnostic-Code": []string{"smtp; 550 user unknown"}, + "Remote-Mta": []string{"dns; sdcc13.ucsd.edu"}, + }, + }, + }, + OriginalMessage: []byte("[original message goes here]\n"), + }, + }, + "delayed": { + filename: "delayed_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; sun2.nsfnet-relay.ac.uk"}}, + }, + RecipientDSNs: []textproto.MIMEHeader{ + { + "Final-Recipient": []string{"rfc822;thomas@de-montfort.ac.uk"}, + "Status": []string{"4.0.0 (unknown temporary failure)"}, + "Action": []string{"delayed"}, + }, + }, + }, + OriginalMessage: nil, + }, + }, + "with multipart/alternative": { + filename: "dsn_with_multipart_alternative.raw", + want: &dsn.Report{ + Explanation: dsn.Explanation{ + Text: "[human-readable explanation goes here]\n", + HTML: "
[human-readable explanation goes here]
\n", + }, + DeliveryStatus: dsn.DeliveryStatus{ + MessageDSNs: []textproto.MIMEHeader{ + { + "Reporting-Mta": []string{"dns;1234.prod.outlook.com"}, + "Received-From-Mta": []string{"dns;some.example.com"}, + "Arrival-Date": []string{"Thu, 27 Jan 2022 08:03:02 +0000"}, + }, + }, + RecipientDSNs: []textproto.MIMEHeader{ + { + "Final-Recipient": []string{"rfc822;non-existing@example.com"}, + "Action": []string{"failed"}, + "Status": []string{"5.1.10"}, + "Diagnostic-Code": []string{"smtp;550 5.1.10 RESOLVER.ADR.RecipientNotFound"}, + }, + }, + }, + OriginalMessage: []byte("[original message goes here]\n"), + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + env := readEnvelope(t, tt.filename) + report, err := dsn.ParseReport(env.Root) + require.NoError(t, err) + assert.Equal(t, tt.want, report) + }) + } +} + +func readEnvelope(tb testing.TB, filename string) *enmime.Envelope { + tb.Helper() + + data := readTestdata(tb, filename) + env, err := enmime.ReadEnvelope(bytes.NewReader(data)) + if err != nil { + tb.Fatalf("read envelope: %s", err) + } + + return env +} + +func readTestdata(tb testing.TB, filename string) []byte { + tb.Helper() + + data, err := os.ReadFile(filepath.Join("testdata", filename)) + if err != nil { + tb.Fatalf("read %s: %s", filename, err) + } + + return data +} diff --git a/dsn/testdata/delayed_dsn.raw b/dsn/testdata/delayed_dsn.raw new file mode 100644 index 00000000..8aaf7cdb --- /dev/null +++ b/dsn/testdata/delayed_dsn.raw @@ -0,0 +1,27 @@ +MIME-Version: 1.0 +From: +Message-Id: <199407092338.TAA23293@CS.UTK.EDU> +Received: from nsfnet-relay.ac.uk by sun2.nsfnet-relay.ac.uk + id ; + Sun, 10 Jul 1994 00:36:51 +0100 +To: owner-info-mime@cs.utk.edu +Date: Sun, 10 Jul 1994 00:36:51 +0100 +Subject: WARNING: message delayed at "nsfnet-relay.ac.uk" +content-type: multipart/report; report-type=delivery-status; + boundary=foobar + +--foobar +content-type: text/plain + +[human-readable explanation goes here] + +--foobar +content-type: message/delivery-status + +Reporting-MTA: dns; sun2.nsfnet-relay.ac.uk + +Final-Recipient: rfc822;thomas@de-montfort.ac.uk +Status: 4.0.0 (unknown temporary failure) +Action: delayed + +--foobar-- diff --git a/dsn/testdata/dsn_with_multipart_alternative.raw b/dsn/testdata/dsn_with_multipart_alternative.raw new file mode 100644 index 00000000..89ecf664 --- /dev/null +++ b/dsn/testdata/dsn_with_multipart_alternative.raw @@ -0,0 +1,48 @@ +From: +To: +Date: Thu, 27 Jan 2022 08:03:03 +0000 +Content-Type: multipart/report; report-type=delivery-status; + boundary="da9dd0b0-6849-46ed-8b0c-ab77592daac2" +Content-Language: en-US +Message-ID: <30ea784a-1d56-4a6f-8f3f-005d4666761c@1234.prod.outlook.com> +Subject: Undeliverable: Some subject +Auto-Submitted: auto-replied +Return-Path: <> +MIME-Version: 1.0 + +--da9dd0b0-6849-46ed-8b0c-ab77592daac2 +Content-Type: multipart/alternative; differences=Content-Type; + boundary="2cfd971c-8d47-4ec1-933b-da4718219a55" + +--2cfd971c-8d47-4ec1-933b-da4718219a55 +Content-Type: text/plain; charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +[human-readable explanation goes here] + +--2cfd971c-8d47-4ec1-933b-da4718219a55 +Content-Type: text/html; charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +
[human-readable explanation goes here]
+ +--2cfd971c-8d47-4ec1-933b-da4718219a55-- + +--da9dd0b0-6849-46ed-8b0c-ab77592daac2 +Content-Type: message/delivery-status + +Reporting-MTA: dns;1234.prod.outlook.com +Received-From-MTA: dns;some.example.com +Arrival-Date: Thu, 27 Jan 2022 08:03:02 +0000 + +Final-Recipient: rfc822;non-existing@example.com +Action: failed +Status: 5.1.10 +Diagnostic-Code: smtp;550 5.1.10 RESOLVER.ADR.RecipientNotFound + +--da9dd0b0-6849-46ed-8b0c-ab77592daac2 +Content-Type: message/rfc822 + +[original message goes here] + +--da9dd0b0-6849-46ed-8b0c-ab77592daac2-- diff --git a/dsn/testdata/multi_recipient_dsn.raw b/dsn/testdata/multi_recipient_dsn.raw new file mode 100644 index 00000000..66427ca8 --- /dev/null +++ b/dsn/testdata/multi_recipient_dsn.raw @@ -0,0 +1,44 @@ +Date: Fri, 8 Jul 1994 09:21:47 -0400 +From: Mail Delivery Subsystem +Subject: Returned mail: User unknown +To: +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=delivery-status; + boundary="JAA13167.773673707/CS.UTK.EDU" + +--JAA13167.773673707/CS.UTK.EDU +content-type: text/plain; charset=us-ascii + +[human-readable explanation goes here] + +--JAA13167.773673707/CS.UTK.EDU +content-type: message/delivery-status + +Reporting-MTA: dns; cs.utk.edu + +Original-Recipient: rfc822;arathib@vnet.ibm.com +Final-Recipient: rfc822;arathib@vnet.ibm.com +Action: failed +Status: 5.0.0 (permanent failure) +Diagnostic-Code: smtp; 550 'arathib@vnet.IBM.COM' is not a + registered gateway user +Remote-MTA: dns; vnet.ibm.com + +Original-Recipient: rfc822;johnh@hpnjld.njd.hp.com +Final-Recipient: rfc822;johnh@hpnjld.njd.hp.com +Action: delayed +Status: 4.0.0 (hpnjld.njd.jp.com: host name lookup failure) + +Original-Recipient: rfc822;wsnell@sdcc13.ucsd.edu +Final-Recipient: rfc822;wsnell@sdcc13.ucsd.edu +Action: failed +Status: 5.0.0 +Diagnostic-Code: smtp; 550 user unknown +Remote-MTA: dns; sdcc13.ucsd.edu + +--JAA13167.773673707/CS.UTK.EDU +content-type: message/rfc822 + +[original message goes here] + +--JAA13167.773673707/CS.UTK.EDU-- diff --git a/dsn/testdata/simple_dsn.raw b/dsn/testdata/simple_dsn.raw new file mode 100644 index 00000000..784b412b --- /dev/null +++ b/dsn/testdata/simple_dsn.raw @@ -0,0 +1,32 @@ +Date: Thu, 7 Jul 1994 17:16:05 -0400 +From: Mail Delivery Subsystem +Message-Id: <199407072116.RAA14128@CS.UTK.EDU> +Subject: Returned mail: Cannot send message for 5 days +To: +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=delivery-status; + boundary="RAA14128.773615765/CS.UTK.EDU" + +--RAA14128.773615765/CS.UTK.EDU +Content-Type: text/plain + +[human-readable explanation goes here] + +--RAA14128.773615765/CS.UTK.EDU +content-type: message/delivery-status + +Reporting-MTA: dns; cs.utk.edu + +Original-Recipient: rfc822;louisl@larry.slip.umd.edu +Final-Recipient: rfc822;louisl@larry.slip.umd.edu +Action: failed +Status: 4.0.0 +Diagnostic-Code: smtp; 426 connection timed out +Last-Attempt-Date: Thu, 7 Jul 1994 17:15:49 -0400 + +--RAA14128.773615765/CS.UTK.EDU +content-type: message/rfc822 + +[original message goes here] + +--RAA14128.773615765/CS.UTK.EDU-- diff --git a/dsn/testdata/simple_dsn_with_html.raw b/dsn/testdata/simple_dsn_with_html.raw new file mode 100644 index 00000000..eb5b8651 --- /dev/null +++ b/dsn/testdata/simple_dsn_with_html.raw @@ -0,0 +1,32 @@ +Date: Thu, 7 Jul 1994 17:16:05 -0400 +From: Mail Delivery Subsystem +Message-Id: <199407072116.RAA14128@CS.UTK.EDU> +Subject: Returned mail: Cannot send message for 5 days +To: +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=delivery-status; + boundary="RAA14128.773615765/CS.UTK.EDU" + +--RAA14128.773615765/CS.UTK.EDU +Content-Type: text/html + +
[human-readable explanation goes here]
+ +--RAA14128.773615765/CS.UTK.EDU +content-type: message/delivery-status + +Reporting-MTA: dns; cs.utk.edu + +Original-Recipient: rfc822;louisl@larry.slip.umd.edu +Final-Recipient: rfc822;louisl@larry.slip.umd.edu +Action: failed +Status: 4.0.0 +Diagnostic-Code: smtp; 426 connection timed out +Last-Attempt-Date: Thu, 7 Jul 1994 17:15:49 -0400 + +--RAA14128.773615765/CS.UTK.EDU +content-type: message/rfc822 + +[original message goes here] + +--RAA14128.773615765/CS.UTK.EDU--