Move smtp.RelayMessage into a new smtp.MTA interface.
[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) DeliverMessage(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 host, port, _ := net.SplitHostPort(l.Addr().String())
48 mta := mta{
49 server: s,
50 log: zap.NewNop(),
51 }
52 mta.relayMessageToHost(env, zap.NewNop(), env.RcptTo[0].Address, host, port)
53
54 if want, got := 1, len(s.messages); want != got {
55 t.Errorf("Want %d message to be delivered, got %d", want, got)
56 return
57 }
58
59 received := s.messages[0]
60
61 if want, got := env.MailFrom.Address, received.MailFrom.Address; want != got {
62 t.Errorf("Want MailFrom %s, got %s", want, got)
63 }
64 if want, got := 1, len(received.RcptTo); want != got {
65 t.Errorf("Want %d RcptTo, got %d", want, got)
66 return
67 }
68 if want, got := env.RcptTo[0].Address, received.RcptTo[0].Address; want != got {
69 t.Errorf("Want RcptTo %s, got %s", want, got)
70 }
71
72 if !bytes.HasSuffix(received.Data, env.Data) {
73 t.Errorf("Delivered message does not match relayed one. Delivered=%q Relayed=%q", string(env.Data), string(received.Data))
74 }
75 }
76
77 func TestDeliveryFailureMessage(t *testing.T) {
78 s := &deliveryServer{}
79
80 env := Envelope{
81 MailFrom: mail.Address{Address: "from@sender.org"},
82 RcptTo: []mail.Address{{Address: "to@receive.net"}},
83 Data: []byte("Message\n"),
84 ID: "m.willfail",
85 EHLO: "mx.receive.net",
86 RemoteAddr: &net.IPAddr{net.IPv4(127, 0, 0, 1), ""},
87 }
88
89 errorStr1 := "internal message"
90 errorStr2 := "general error 122"
91 mta := mta{
92 server: s,
93 log: zap.NewNop(),
94 }
95 mta.deliverRelayFailure(env, zap.NewNop(), env.RcptTo[0].Address, errorStr1, fmt.Errorf(errorStr2))
96
97 if want, got := 1, len(s.messages); want != got {
98 t.Errorf("Want %d failure notification, got %d", want, got)
99 return
100 }
101
102 failure := s.messages[0]
103
104 if want, got := env.MailFrom.Address, failure.RcptTo[0].Address; want != got {
105 t.Errorf("Failure message should be delivered to sender %s, actually %s", want, got)
106 }
107
108 // Read the failure message.
109 buf := bytes.NewBuffer(failure.Data)
110 msg, err := mail.ReadMessage(buf)
111 if err != nil {
112 t.Errorf("Failed to read message: %v", err)
113 return
114 }
115
116 // Parse out the Content-Type to get the multipart boundary string.
117 mediatype, mtheaders, err := mime.ParseMediaType(msg.Header["Content-Type"][0])
118 if err != nil {
119 t.Errorf("Failed to parse MIME headers: %v", err)
120 return
121 }
122
123 if want, got := "multipart/report", mediatype; want != got {
124 t.Errorf("Want MIME type of %q, got %q", want, got)
125 }
126
127 if want, got := "delivery-status", mtheaders["report-type"]; want != got {
128 t.Errorf("Want report-type of %q, got %q", want, got)
129 }
130
131 boundary := mtheaders["boundary"]
132
133 if want, got := "Delivery Status Notification (Failure)", msg.Header["Subject"][0]; want != got {
134 t.Errorf("Want Subject field %q, got %q", want, got)
135 }
136
137 if want, got := "<"+env.MailFrom.Address+">", msg.Header["To"][0]; want != got {
138 t.Errorf("Want To field %q, got %q", want, got)
139 }
140
141 // Parse the multi-part messsage.
142 mpr := multipart.NewReader(msg.Body, boundary)
143 part, err := mpr.NextPart()
144 if err != nil {
145 t.Errorf("Error reading part 0: %v", err)
146 return
147 }
148
149 // First part is the human-readable error.
150 if want, got := "text/plain; charset=UTF-8", part.Header["Content-Type"][0]; want != got {
151 t.Errorf("Part 0 type want %q, got %q", want, got)
152 }
153
154 content, err := ioutil.ReadAll(part)
155 if err != nil {
156 t.Errorf("Failed to read part 0 content: %v", err)
157 return
158 }
159 contentStr := string(content)
160
161 if !strings.Contains(contentStr, "Delivery Failure") {
162 t.Errorf("Missing Delivery Failure")
163 }
164
165 if want := fmt.Sprintf("%s:\n%s", errorStr1, errorStr2); !strings.Contains(contentStr, want) {
166 t.Errorf("Missing error string %q", want)
167 }
168
169 // Second part is the status information.
170 part, err = mpr.NextPart()
171 if err != nil {
172 t.Errorf("Error reading part 1: %v", err)
173 return
174 }
175
176 if want, got := "message/delivery-status", part.Header["Content-Type"][0]; want != got {
177 t.Errorf("Part 1 type want %q, got %q", want, got)
178 }
179
180 content, err = ioutil.ReadAll(part)
181 if err != nil {
182 t.Errorf("Failed to read part 1 content: %v", err)
183 return
184 }
185 contentStr = string(content)
186
187 if want := "Original-Envelope-ID: " + env.ID + "\n"; !strings.Contains(contentStr, want) {
188 t.Errorf("Missing %q in %q", want, contentStr)
189 }
190
191 if want := "Reporting-UA: " + env.EHLO + "\n"; !strings.Contains(contentStr, want) {
192 t.Errorf("Missing %q in %q", want, contentStr)
193 }
194
195 if want := "Reporting-MTA: dns; localhost [127.0.0.1]\n"; !strings.Contains(contentStr, want) {
196 t.Errorf("Missing %q in %q", want, contentStr)
197 }
198
199 // Third part is the original message.
200 part, err = mpr.NextPart()
201 if err != nil {
202 t.Errorf("Error reading part 2: %v", err)
203 return
204 }
205
206 if want, got := "message/rfc822", part.Header["Content-Type"][0]; want != got {
207 t.Errorf("Part 2 type want %q, got %q", want, got)
208 }
209
210 content, err = ioutil.ReadAll(part)
211 if err != nil {
212 t.Errorf("Failed to read part 2 content: %v", err)
213 return
214 }
215
216 if !bytes.Equal(content, env.Data) {
217 t.Errorf("Byte content of original message does not match")
218 }
219 }