Produce delivery-status failure notifications when failing to relay a message.
[mailpopbox.git] / smtp / relay_test.go
1 // mailpopbox
2 // Copyright 2020 Blue Static <https://www.bluestatic.org>
3 // This program is free software licensed under the GNU General Public License,
4 // version 3.0. The full text of the license can be found in LICENSE.txt.
5 // SPDX-License-Identifier: GPL-3.0-only
6
7 package smtp
8
9 import (
10 "bytes"
11 "fmt"
12 "io/ioutil"
13 "mime"
14 "mime/multipart"
15 "net"
16 "net/mail"
17 "strings"
18 "testing"
19
20 "go.uber.org/zap"
21 )
22
23 type deliveryServer struct {
24 testServer
25 messages []Envelope
26 }
27
28 func (s *deliveryServer) OnMessageDelivered(env Envelope) *ReplyLine {
29 s.messages = append(s.messages, env)
30 return nil
31 }
32
33 func TestRelayRoundTrip(t *testing.T) {
34 s := &deliveryServer{
35 testServer: testServer{domain: "receive.net"},
36 }
37 l := runServer(t, s)
38 defer l.Close()
39
40 env := Envelope{
41 MailFrom: mail.Address{Address: "from@sender.org"},
42 RcptTo: []mail.Address{{Address: "to@receive.net"}},
43 Data: []byte("~~~Message~~~\n"),
44 ID: "ididid",
45 }
46
47 relayMessageToHost(s, env, zap.NewNop(), env.RcptTo[0].Address, l.Addr().String())
48
49 if len(s.messages) != 1 {
50 t.Errorf("Expected 1 message to be delivered, got %d", len(s.messages))
51 return
52 }
53
54 received := s.messages[0]
55
56 if env.MailFrom.Address != received.MailFrom.Address {
57 t.Errorf("Expected MailFrom %s, got %s", env.MailFrom.Address, received.MailFrom.Address)
58 }
59 if len(received.RcptTo) != 1 {
60 t.Errorf("Expected 1 RcptTo, got %d", len(received.RcptTo))
61 return
62 }
63 if env.RcptTo[0].Address != received.RcptTo[0].Address {
64 t.Errorf("Expected RcptTo %s, got %s", env.RcptTo[0].Address, received.RcptTo[0].Address)
65 }
66
67 if !bytes.HasSuffix(received.Data, env.Data) {
68 t.Errorf("Delivered message does not match relayed one. Delivered=%q Relayed=%q", string(env.Data), string(received.Data))
69 }
70 }
71
72 func TestDeliveryFailureMessage(t *testing.T) {
73 s := &deliveryServer{}
74
75 env := Envelope{
76 MailFrom: mail.Address{Address: "from@sender.org"},
77 RcptTo: []mail.Address{{Address: "to@receive.net"}},
78 Data: []byte("Message\n"),
79 ID: "m.willfail",
80 EHLO: "mx.receive.net",
81 RemoteAddr: &net.IPAddr{net.IPv4(127, 0, 0, 1), ""},
82 }
83
84 errorStr1 := "internal message"
85 errorStr2 := "general error 122"
86 deliverRelayFailure(s, env, zap.NewNop(), env.RcptTo[0].Address, errorStr1, fmt.Errorf(errorStr2))
87
88 if len(s.messages) != 1 {
89 t.Errorf("Expected 1 failure notification, got %d", len(s.messages))
90 return
91 }
92
93 failure := s.messages[0]
94
95 if failure.RcptTo[0].Address != env.MailFrom.Address {
96 t.Errorf("Failure message should be delivered to sender %s, actually %s", env.MailFrom.Address, failure.RcptTo[0].Address)
97 }
98
99 // Read the failure message.
100 buf := bytes.NewBuffer(failure.Data)
101 msg, err := mail.ReadMessage(buf)
102 if err != nil {
103 t.Errorf("Failed to read message: %v", err)
104 return
105 }
106
107 // Parse out the Content-Type to get the multipart boundary string.
108 mediatype, mtheaders, err := mime.ParseMediaType(msg.Header["Content-Type"][0])
109 if err != nil {
110 t.Errorf("Failed to parse MIME headers: %v", err)
111 return
112 }
113
114 expected := "multipart/report"
115 if mediatype != expected {
116 t.Errorf("Expected MIME type of %q, got %q", expected, mediatype)
117 }
118
119 expected = "delivery-status"
120 if mtheaders["report-type"] != expected {
121 t.Errorf("Expected report-type of %q, got %q", expected, mtheaders["report-type"])
122 }
123
124 boundary := mtheaders["boundary"]
125
126 expected = "Delivery Status Notification (Failure)"
127 if msg.Header["Subject"][0] != expected {
128 t.Errorf("Subject did not match %q, got %q", expected, mtheaders["Subject"])
129 }
130
131 if msg.Header["To"][0] != "<"+env.MailFrom.Address+">" {
132 t.Errorf("To field does not match %q, got %q", env.MailFrom.Address, msg.Header["To"][0])
133 }
134
135 // Parse the multi-part messsage.
136 mpr := multipart.NewReader(msg.Body, boundary)
137 part, err := mpr.NextPart()
138 if err != nil {
139 t.Errorf("Error reading part 0: %v", err)
140 return
141 }
142
143 // First part is the human-readable error.
144 expected = "text/plain; charset=UTF-8"
145 if part.Header["Content-Type"][0] != expected {
146 t.Errorf("Part 0 type expected %q, got %q", expected, part.Header["Content-Type"][0])
147 }
148
149 content, err := ioutil.ReadAll(part)
150 if err != nil {
151 t.Errorf("Failed to read part 0 content: %v", err)
152 return
153 }
154 contentStr := string(content)
155
156 if !strings.Contains(contentStr, "Delivery Failure") {
157 t.Errorf("Missing Delivery Failure")
158 }
159
160 expected = fmt.Sprintf("%s:\n%s", errorStr1, errorStr2)
161 if !strings.Contains(contentStr, expected) {
162 t.Errorf("Missing error string %q", expected)
163 }
164
165 // Second part is the status information.
166 part, err = mpr.NextPart()
167 if err != nil {
168 t.Errorf("Error reading part 1: %v", err)
169 return
170 }
171
172 expected = "delivery-status"
173 if part.Header["Content-Type"][0] != expected {
174 t.Errorf("Part 1 type expected %q, got %q", expected, part.Header["Content-Type"][0])
175 }
176
177 content, err = ioutil.ReadAll(part)
178 if err != nil {
179 t.Errorf("Failed to read part 1 content: %v", err)
180 return
181 }
182 contentStr = string(content)
183
184 expected = "Original-Envelope-ID: " + env.ID + "\n"
185 if !strings.Contains(contentStr, expected) {
186 t.Errorf("Missing %q in %q", expected, contentStr)
187 }
188
189 expected = "Reporting-UA: " + env.EHLO + "\n"
190 if !strings.Contains(contentStr, expected) {
191 t.Errorf("Missing %q in %q", expected, contentStr)
192 }
193
194 expected = "Reporting-MTA: localhost\n"
195 if !strings.Contains(contentStr, expected) {
196 t.Errorf("Missing %q in %q", expected, contentStr)
197 }
198
199 expected = "X-Remote-Address: 127.0.0.1\n"
200 if !strings.Contains(contentStr, expected) {
201 t.Errorf("Missing %q in %q", expected, contentStr)
202 }
203
204 // Third part is the original message.
205 part, err = mpr.NextPart()
206 if err != nil {
207 t.Errorf("Error reading part 2: %v", err)
208 return
209 }
210
211 expected = "message/rfc822"
212 if part.Header["Content-Type"][0] != expected {
213 t.Errorf("Part 2 type expected %q, got %q", expected, part.Header["Content-Type"][0])
214 }
215
216 content, err = ioutil.ReadAll(part)
217 if err != nil {
218 t.Errorf("Failed to read part 2 content: %v", err)
219 return
220 }
221
222 if !bytes.Equal(content, env.Data) {
223 t.Errorf("Byte content of original message does not match")
224 }
225 }