Repository: jhillyerd/enmime
Pull request #268 (2022-11-16 15:12:33)

feat: parse DSN
This PR adds parsing of Delivery Status Notification (DSN) per [rfc3464](https://datatracker.ietf.org/doc/html/rfc3464) and [rfc6522](https://datatracker.ietf.org/doc/html/rfc6522) (also known as [bounce message](https://en.wikipedia.org/wiki/Bounce_message)). 

Closes https://github.com/jhillyerd/enmime/issues/267


Patch:
diff --git a/dsn/dsn.go b/dsn/dsn.go
new file mode 100644
index 0000000..4a96fdf
--- /dev/null
+++ b/dsn/dsn.go
@@ -0,0 +1,41 @@
+// 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 is a text/plain part of the explanation.
+	Text string
+	// HTML is a text/html part of the explanation.
+	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 0000000..d3a7c08
--- /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(data []byte) (DeliveryStatus, error) {
+	fields, err := parseDeliveryStatusFields(data)
+	if err != nil {
+		return DeliveryStatus{}, fmt.Errorf("parse delivery status: %w", err)
+	}
+
+	perMessage, perRecipient := splitDSNFields(fields)
+
+	return DeliveryStatus{
+		MessageDSNs:   perMessage,
+		RecipientDSNs: perRecipient,
+	}, nil
+}
+
+// parseDeliveryStatusFields 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 parseDeliveryStatusFields(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") != ""
+}


Test patch:
diff --git a/dsn/example_test.go b/dsn/example_test.go
new file mode 100644
index 0000000..386941c
--- /dev/null
+++ b/dsn/example_test.go
@@ -0,0 +1,39 @@
+package dsn_test
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/jhillyerd/enmime"
+	"github.com/jhillyerd/enmime/dsn"
+)
+
+// ExampleParseReport shows how to parse message as Delivery Status Notification (DSN).
+func ExampleParseReport() {
+	f, err := os.Open("testdata/simple_dsn.raw")
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+	defer f.Close()
+
+	env, err := enmime.ReadEnvelope(f)
+	if err != nil {
+		fmt.Print(err)
+		return
+	}
+
+	rep, err := dsn.ParseReport(env.Root)
+	if err != nil {
+		fmt.Print(err)
+		return
+	}
+
+	fmt.Printf("Original message: %s", rep.OriginalMessage)
+	fmt.Printf("Failed?: %t\n", dsn.IsFailed(rep.DeliveryStatus.RecipientDSNs[0]))
+	fmt.Printf("Why?: %s", rep.Explanation.Text)
+	// Output:
+	// Original message: [original message goes here]
+	// Failed?: true
+	// Why?: [human-readable explanation goes here]
+}
diff --git a/dsn/parse_internal_test.go b/dsn/parse_internal_test.go
new file mode 100644
index 0000000..e0ac659
--- /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 TestParseDeliveryStatusFields(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 := parseDeliveryStatusFields([]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 0000000..ad8e1ae
--- /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: "<div>[human-readable explanation goes here]</div>\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: "<div>[human-readable explanation goes here]</div>\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 0000000..8aaf7cd
--- /dev/null
+++ b/dsn/testdata/delayed_dsn.raw
@@ -0,0 +1,27 @@
+MIME-Version: 1.0
+From: <postmaster@nsfnet-relay.ac.uk>
+Message-Id: <199407092338.TAA23293@CS.UTK.EDU>
+Received: from nsfnet-relay.ac.uk by sun2.nsfnet-relay.ac.uk
+       id <g.12954-0@sun2.nsfnet-relay.ac.uk>;
+       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 0000000..89ecf66
--- /dev/null
+++ b/dsn/testdata/dsn_with_multipart_alternative.raw
@@ -0,0 +1,48 @@
+From: <postmaster@example.com>
+To: <user@example.com>
+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
+
+<div>[human-readable explanation goes here]</div>
+
+--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 0000000..66427ca
--- /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 <MAILER-DAEMON@CS.UTK.EDU>
+Subject: Returned mail: User unknown
+To: <owner-ups-mib@CS.UTK.EDU>
+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 0000000..784b412
--- /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 <MAILER-DAEMON@CS.UTK.EDU>
+Message-Id: <199407072116.RAA14128@CS.UTK.EDU>
+Subject: Returned mail: Cannot send message for 5 days
+To: <owner-info-mime@cs.utk.edu>
+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 0000000..eb5b865
--- /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 <MAILER-DAEMON@CS.UTK.EDU>
+Message-Id: <199407072116.RAA14128@CS.UTK.EDU>
+Subject: Returned mail: Cannot send message for 5 days
+To: <owner-info-mime@cs.utk.edu>
+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
+
+<div>[human-readable explanation goes here]</div>
+
+--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--