inbound_test.go raw
1 package bridge
2
3 import (
4 crand "crypto/rand"
5 "fmt"
6 "strings"
7 "testing"
8
9 bridgesmtp "next.orly.dev/pkg/bridge/smtp"
10 )
11
12 type mockBlossom struct {
13 uploadedData []byte
14 returnURL string
15 returnErr error
16 }
17
18 func (m *mockBlossom) Upload(data []byte, contentType string) (string, error) {
19 m.uploadedData = data
20 return m.returnURL, m.returnErr
21 }
22
23 func TestNewInboundProcessor(t *testing.T) {
24 ip := NewInboundProcessor(nil, "https://example.com/compose", func(string, string) error { return nil })
25 if ip == nil {
26 t.Fatal("expected non-nil InboundProcessor")
27 }
28 }
29
30 func TestProcessInbound_PlainText(t *testing.T) {
31 var sentDM string
32 var sentTo string
33 sendDM := func(pubkey, content string) error {
34 sentTo = pubkey
35 sentDM = content
36 return nil
37 }
38
39 ip := NewInboundProcessor(nil, "https://relay.example.com/compose", sendDM)
40
41 raw := "From: alice@example.com\r\nTo: npub1test@bridge.example.com\r\n" +
42 "Subject: Hello World\r\n" +
43 "Content-Type: text/plain\r\n\r\n" +
44 "This is the body."
45
46 email := &bridgesmtp.InboundEmail{
47 From: "alice@example.com",
48 To: []string{"npub1test@bridge.example.com"},
49 RawMessage: []byte(raw),
50 }
51
52 err := ip.ProcessInbound(email, "abcd1234")
53 if err != nil {
54 t.Fatalf("unexpected error: %v", err)
55 }
56 if sentTo != "abcd1234" {
57 t.Errorf("sentTo = %q, want abcd1234", sentTo)
58 }
59 if !strings.Contains(sentDM, "From: alice@example.com") {
60 t.Errorf("DM missing From header: %q", sentDM)
61 }
62 if !strings.Contains(sentDM, "Subject: Hello World") {
63 t.Errorf("DM missing Subject header: %q", sentDM)
64 }
65 if !strings.Contains(sentDM, "This is the body.") {
66 t.Errorf("DM missing body: %q", sentDM)
67 }
68 if !strings.Contains(sentDM, "Reply: https://relay.example.com/compose#to=alice%40example.com") {
69 t.Errorf("DM missing reply link: %q", sentDM)
70 }
71 }
72
73 func TestProcessInbound_WithAttachments(t *testing.T) {
74 blossom := &mockBlossom{returnURL: "https://blossom.example.com/abc123"}
75 var sentDM string
76 sendDM := func(pubkey, content string) error {
77 sentDM = content
78 return nil
79 }
80
81 ip := NewInboundProcessor(blossom, "", sendDM)
82
83 raw := "From: alice@example.com\r\nTo: npub1test@bridge.example.com\r\n" +
84 "Subject: With Attachment\r\n" +
85 "Content-Type: multipart/mixed; boundary=boundary123\r\n\r\n" +
86 "--boundary123\r\n" +
87 "Content-Type: text/plain\r\n\r\n" +
88 "Body text here.\r\n" +
89 "--boundary123\r\n" +
90 "Content-Type: application/pdf\r\n" +
91 "Content-Disposition: attachment; filename=\"doc.pdf\"\r\n\r\n" +
92 "PDFCONTENT\r\n" +
93 "--boundary123--\r\n"
94
95 email := &bridgesmtp.InboundEmail{
96 From: "alice@example.com",
97 To: []string{"npub1test@bridge.example.com"},
98 RawMessage: []byte(raw),
99 }
100
101 err := ip.ProcessInbound(email, "abcd1234")
102 if err != nil {
103 t.Fatalf("unexpected error: %v", err)
104 }
105 if !strings.Contains(sentDM, "Attachment: https://blossom.example.com/abc123#") {
106 t.Errorf("DM missing attachment URL with fragment key: %q", sentDM)
107 }
108 if blossom.uploadedData == nil {
109 t.Error("expected blossom upload to be called")
110 }
111 }
112
113 func TestProcessInbound_NoBlossom(t *testing.T) {
114 var sentDM string
115 sendDM := func(pubkey, content string) error {
116 sentDM = content
117 return nil
118 }
119
120 ip := NewInboundProcessor(nil, "", sendDM)
121
122 raw := "From: alice@example.com\r\nTo: npub1test@bridge.example.com\r\n" +
123 "Subject: With HTML\r\n" +
124 "Content-Type: multipart/alternative; boundary=b1\r\n\r\n" +
125 "--b1\r\n" +
126 "Content-Type: text/plain\r\n\r\n" +
127 "Plain text.\r\n" +
128 "--b1\r\n" +
129 "Content-Type: text/html\r\n\r\n" +
130 "<h1>HTML</h1>\r\n" +
131 "--b1--\r\n"
132
133 email := &bridgesmtp.InboundEmail{
134 From: "alice@example.com",
135 To: []string{"npub1test@bridge.example.com"},
136 RawMessage: []byte(raw),
137 }
138
139 err := ip.ProcessInbound(email, "abcd1234")
140 if err != nil {
141 t.Fatalf("unexpected error: %v", err)
142 }
143 // No blossom = no attachment in DM
144 if strings.Contains(sentDM, "Attachment:") {
145 t.Errorf("should not have attachment when no Blossom: %q", sentDM)
146 }
147 }
148
149 func TestProcessInbound_HTMLOnly(t *testing.T) {
150 blossom := &mockBlossom{returnURL: "https://blossom.example.com/xyz"}
151 var sentDM string
152 sendDM := func(pubkey, content string) error {
153 sentDM = content
154 return nil
155 }
156
157 ip := NewInboundProcessor(blossom, "", sendDM)
158
159 raw := "From: alice@example.com\r\nTo: npub1test@bridge.example.com\r\n" +
160 "Subject: HTML Only\r\n" +
161 "Content-Type: text/html\r\n\r\n" +
162 "<h1>Hello</h1>"
163
164 email := &bridgesmtp.InboundEmail{
165 From: "alice@example.com",
166 To: []string{"npub1test@bridge.example.com"},
167 RawMessage: []byte(raw),
168 }
169
170 err := ip.ProcessInbound(email, "abcd1234")
171 if err != nil {
172 t.Fatalf("unexpected error: %v", err)
173 }
174 if !strings.Contains(sentDM, "[HTML-only email") {
175 t.Errorf("expected HTML-only fallback text: %q", sentDM)
176 }
177 }
178
179 func TestProcessInbound_BlossomUploadError(t *testing.T) {
180 blossom := &mockBlossom{returnErr: fmt.Errorf("upload failed")}
181 var sentDM string
182 sendDM := func(pubkey, content string) error {
183 sentDM = content
184 return nil
185 }
186
187 ip := NewInboundProcessor(blossom, "", sendDM)
188
189 raw := "From: alice@example.com\r\nTo: npub1test@bridge.example.com\r\n" +
190 "Subject: Error\r\n" +
191 "Content-Type: multipart/mixed; boundary=err1\r\n\r\n" +
192 "--err1\r\n" +
193 "Content-Type: text/plain\r\n\r\n" +
194 "Body.\r\n" +
195 "--err1\r\n" +
196 "Content-Type: application/pdf\r\n" +
197 "Content-Disposition: attachment; filename=\"doc.pdf\"\r\n\r\n" +
198 "PDF\r\n" +
199 "--err1--\r\n"
200
201 email := &bridgesmtp.InboundEmail{
202 From: "alice@example.com",
203 To: []string{"npub1test@bridge.example.com"},
204 RawMessage: []byte(raw),
205 }
206
207 err := ip.ProcessInbound(email, "abcd1234")
208 if err != nil {
209 t.Fatalf("unexpected error: %v", err)
210 }
211 if !strings.Contains(sentDM, "[processing failed]") {
212 t.Errorf("expected processing failed message: %q", sentDM)
213 }
214 }
215
216 func TestProcessInbound_SendDMError(t *testing.T) {
217 sendDM := func(pubkey, content string) error {
218 return fmt.Errorf("DM send failed")
219 }
220
221 ip := NewInboundProcessor(nil, "", sendDM)
222
223 raw := "From: alice@example.com\r\nTo: npub1test@bridge.example.com\r\n" +
224 "Subject: Test\r\n" +
225 "Content-Type: text/plain\r\n\r\n" +
226 "Body."
227
228 email := &bridgesmtp.InboundEmail{
229 From: "alice@example.com",
230 To: []string{"npub1test@bridge.example.com"},
231 RawMessage: []byte(raw),
232 }
233
234 err := ip.ProcessInbound(email, "abcd1234")
235 if err == nil {
236 t.Fatal("expected error")
237 }
238 }
239
240 func TestProcessInbound_InvalidMIME(t *testing.T) {
241 sendDM := func(pubkey, content string) error { return nil }
242 ip := NewInboundProcessor(nil, "", sendDM)
243
244 // Truly malformed input that go-message cannot parse at all
245 email := &bridgesmtp.InboundEmail{
246 From: "alice@example.com",
247 To: []string{"npub1test@bridge.example.com"},
248 RawMessage: []byte("Content-Type: multipart/mixed; boundary=xyz\r\n\r\n--abc\r\n"),
249 }
250
251 err := ip.ProcessInbound(email, "abcd1234")
252 if err == nil {
253 t.Fatal("expected error from invalid MIME")
254 }
255 }
256
257 func TestProcessInbound_NoComposeURL(t *testing.T) {
258 var sentDM string
259 sendDM := func(pubkey, content string) error {
260 sentDM = content
261 return nil
262 }
263
264 ip := NewInboundProcessor(nil, "", sendDM)
265
266 raw := "From: alice@example.com\r\nTo: npub1test@bridge.example.com\r\n" +
267 "Subject: No Reply Link\r\n" +
268 "Content-Type: text/plain\r\n\r\n" +
269 "Body."
270
271 email := &bridgesmtp.InboundEmail{
272 From: "alice@example.com",
273 To: []string{"npub1test@bridge.example.com"},
274 RawMessage: []byte(raw),
275 }
276
277 err := ip.ProcessInbound(email, "abcd1234")
278 if err != nil {
279 t.Fatalf("unexpected error: %v", err)
280 }
281 if strings.Contains(sentDM, "Reply:") {
282 t.Errorf("should not have Reply link without composeURL: %q", sentDM)
283 }
284 }
285
286 func TestProcessAttachments_ZipError(t *testing.T) {
287 // Feed incompressible data exceeding the 25MB zip limit
288 blossom := &mockBlossom{returnURL: "https://blossom.example.com/test"}
289 ip := &InboundProcessor{blossom: blossom}
290
291 bigData := make([]byte, 26*1024*1024)
292 // crypto/rand data is incompressible — zip output will be >= input
293 crand.Read(bigData)
294
295 url, err := ip.processAttachments("", []bridgesmtp.Attachment{
296 {Filename: "huge.bin", ContentType: "application/octet-stream", Data: bigData},
297 })
298 if err == nil {
299 t.Fatal("expected error from zip size exceeding limit")
300 }
301 if url != "" {
302 t.Errorf("expected empty URL on error, got %q", url)
303 }
304 }
305
306 func TestProcessAttachments_Empty(t *testing.T) {
307 blossom := &mockBlossom{returnURL: "https://blossom.example.com/test"}
308 ip := &InboundProcessor{blossom: blossom}
309
310 url, err := ip.processAttachments("", nil)
311 if err != nil {
312 t.Fatalf("unexpected error: %v", err)
313 }
314 if url != "" {
315 t.Errorf("expected empty URL for no content, got %q", url)
316 }
317 }
318