Skip to content

Commit

Permalink
feat: parse DSN
Browse files Browse the repository at this point in the history
  • Loading branch information
Dmytro Kasianenko committed Nov 16, 2022
1 parent ebd4cb3 commit 695d9d9
Show file tree
Hide file tree
Showing 9 changed files with 692 additions and 0 deletions.
41 changes: 41 additions & 0 deletions dsn/dsn.go
Original file line number Diff line number Diff line change
@@ -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")
}
156 changes: 156 additions & 0 deletions dsn/parse.go
Original file line number Diff line number Diff line change
@@ -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") != ""
}
132 changes: 132 additions & 0 deletions dsn/parse_internal_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading

0 comments on commit 695d9d9

Please sign in to comment.