outbound_test.go raw
1 package bridge
2
3 import (
4 "context"
5 "fmt"
6 "net"
7 "strings"
8 "testing"
9 "time"
10
11 gosmtp "github.com/emersion/go-smtp"
12 bridgesmtp "next.orly.dev/pkg/bridge/smtp"
13 )
14
15 func TestOutboundProcessor_NotSubscribed(t *testing.T) {
16 var replies []string
17 sendDM := func(pubkey, content string) error {
18 replies = append(replies, content)
19 return nil
20 }
21
22 store := NewMemorySubscriptionStore()
23 handler := NewSubscriptionHandler(store, nil, sendDM, 2100, nil, 0)
24 op := NewOutboundProcessor(nil, nil, handler, "bridge.example.com", sendDM, nil)
25
26 err := op.ProcessOutbound("user1", "To: alice@example.com\n\nHello")
27 if err != nil {
28 t.Fatalf("unexpected error: %v", err)
29 }
30 if len(replies) == 0 {
31 t.Fatal("expected subscription required reply")
32 }
33 if !strings.Contains(replies[0], "subscription") {
34 t.Errorf("reply = %q, want subscription message", replies[0])
35 }
36 }
37
38 func TestOutboundProcessor_RateLimited(t *testing.T) {
39 var replies []string
40 sendDM := func(pubkey, content string) error {
41 replies = append(replies, content)
42 return nil
43 }
44
45 rl := NewRateLimiter(RateLimitConfig{
46 PerUserPerHour: 1,
47 MinInterval: 0,
48 })
49 rl.Record("user1")
50
51 op := NewOutboundProcessor(nil, rl, nil, "bridge.example.com", sendDM, nil)
52
53 err := op.ProcessOutbound("user1", "To: alice@example.com\n\nHello")
54 if err != nil {
55 t.Fatalf("unexpected error: %v", err)
56 }
57 if len(replies) == 0 {
58 t.Fatal("expected rate limit reply")
59 }
60 if !strings.Contains(replies[0], "Rate limit") {
61 t.Errorf("reply = %q, want rate limit message", replies[0])
62 }
63 }
64
65 func TestOutboundProcessor_NoRecipients(t *testing.T) {
66 var replies []string
67 sendDM := func(pubkey, content string) error {
68 replies = append(replies, content)
69 return nil
70 }
71
72 op := NewOutboundProcessor(nil, nil, nil, "bridge.example.com", sendDM, nil)
73
74 err := op.ProcessOutbound("user1", "Subject: No recipient\n\nBody")
75 if err != nil {
76 t.Fatalf("unexpected error: %v", err)
77 }
78 if len(replies) == 0 {
79 t.Fatal("expected no-recipient reply")
80 }
81 if !strings.Contains(replies[0], "No recipients") {
82 t.Errorf("reply = %q, want no-recipients message", replies[0])
83 }
84 }
85
86 func TestOutboundProcessor_EmptyContent(t *testing.T) {
87 var replies []string
88 sendDM := func(pubkey, content string) error {
89 replies = append(replies, content)
90 return nil
91 }
92
93 op := NewOutboundProcessor(nil, nil, nil, "bridge.example.com", sendDM, nil)
94
95 err := op.ProcessOutbound("user1", "")
96 if err != nil {
97 t.Fatalf("unexpected error: %v", err)
98 }
99 if len(replies) == 0 {
100 t.Fatal("expected reply")
101 }
102 }
103
104 func TestOutboundProcessor_WithSubscription_SMTPNil(t *testing.T) {
105 var replies []string
106 sendDM := func(pubkey, content string) error {
107 replies = append(replies, content)
108 return nil
109 }
110
111 store := NewMemorySubscriptionStore()
112 store.Save(&Subscription{
113 PubkeyHex: "user1",
114 ExpiresAt: time.Now().Add(24 * time.Hour),
115 })
116 handler := NewSubscriptionHandler(store, nil, sendDM, 2100, nil, 0)
117
118 op := NewOutboundProcessor(nil, nil, handler, "bridge.example.com", sendDM, nil)
119
120 // Will panic because smtpClient is nil when calling Send
121 func() {
122 defer func() {
123 r := recover()
124 if r == nil {
125 t.Fatal("expected panic from nil smtpClient")
126 }
127 }()
128 op.ProcessOutbound("user1", "To: alice@example.com\n\nHello")
129 }()
130 }
131
132 func TestOutboundProcessor_Reply_NilSendDM(t *testing.T) {
133 op := &OutboundProcessor{sendDM: nil}
134 op.reply("user1", "test") // should not panic
135 }
136
137 func TestOutboundProcessor_Reply_Error(t *testing.T) {
138 op := &OutboundProcessor{
139 sendDM: func(pubkey, content string) error {
140 return fmt.Errorf("send failed")
141 },
142 }
143 op.reply("user1", "test") // should not panic, just log
144 }
145
146 func TestOutboundProcessor_Reply_Success(t *testing.T) {
147 var sent string
148 op := &OutboundProcessor{
149 sendDM: func(pubkey, content string) error {
150 sent = content
151 return nil
152 },
153 }
154 op.reply("user1", "hello")
155 if sent != "hello" {
156 t.Errorf("sent = %q, want hello", sent)
157 }
158 }
159
160 func TestOutboundProcessor_FullFlow(t *testing.T) {
161 var received *bridgesmtp.InboundEmail
162 server := bridgesmtp.NewServer(bridgesmtp.ServerConfig{
163 Domain: "test.example.com",
164 ListenAddr: "127.0.0.1:0",
165 }, func(email *bridgesmtp.InboundEmail) error {
166 received = email
167 return nil
168 })
169
170 if err := server.Start(); err != nil {
171 t.Fatalf("start server: %v", err)
172 }
173 defer server.Stop(context.Background())
174
175 addr := server.Addr().String()
176 _, port, _ := net.SplitHostPort(addr)
177
178 smtpClient := bridgesmtp.NewClient(bridgesmtp.ClientConfig{
179 FromDomain: "bridge.example.com",
180 })
181 smtpClient.SetResolver(func(domain string) ([]*net.MX, error) {
182 return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
183 })
184 smtpClient.SetDialer(func(a string) (*gosmtp.Client, error) {
185 return gosmtp.Dial("127.0.0.1:" + port)
186 })
187
188 var replies []string
189 sendDM := func(pubkey, content string) error {
190 replies = append(replies, content)
191 return nil
192 }
193
194 store := NewMemorySubscriptionStore()
195 store.Save(&Subscription{
196 PubkeyHex: "user1pubkeyhex0123456789abcdef0123456789abcdef0123456789abcdef01",
197 ExpiresAt: time.Now().Add(24 * time.Hour),
198 })
199 handler := NewSubscriptionHandler(store, nil, sendDM, 2100, nil, 0)
200
201 op := NewOutboundProcessor(smtpClient, nil, handler, "bridge.example.com", sendDM, nil)
202
203 err := op.ProcessOutbound(
204 "user1pubkeyhex0123456789abcdef0123456789abcdef0123456789abcdef01",
205 "To: alice@test.example.com\nSubject: Hello\n\nThis is a test.",
206 )
207 if err != nil {
208 t.Fatalf("ProcessOutbound failed: %v", err)
209 }
210
211 time.Sleep(100 * time.Millisecond)
212
213 if received == nil {
214 t.Fatal("expected server to receive email")
215 }
216 if !strings.Contains(received.From, "bridge.example.com") {
217 t.Errorf("From = %q, want bridge.example.com domain", received.From)
218 }
219 if len(replies) == 0 {
220 t.Fatal("expected confirmation reply")
221 }
222 if !strings.Contains(replies[len(replies)-1], "Email sent to") {
223 t.Errorf("expected confirmation, got: %s", replies[len(replies)-1])
224 }
225 }
226
227 func TestOutboundProcessor_SendFails(t *testing.T) {
228 smtpClient := bridgesmtp.NewClient(bridgesmtp.ClientConfig{
229 FromDomain: "bridge.example.com",
230 })
231 smtpClient.SetResolver(func(domain string) ([]*net.MX, error) {
232 return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
233 })
234 smtpClient.SetDialer(func(a string) (*gosmtp.Client, error) {
235 return nil, net.ErrClosed
236 })
237
238 var replies []string
239 sendDM := func(pubkey, content string) error {
240 replies = append(replies, content)
241 return nil
242 }
243
244 op := NewOutboundProcessor(smtpClient, nil, nil, "bridge.example.com", sendDM, nil)
245
246 err := op.ProcessOutbound("user1", "To: alice@example.com\n\nHello")
247 if err == nil {
248 t.Fatal("expected error from SMTP failure")
249 }
250 if len(replies) == 0 {
251 t.Fatal("expected failure reply")
252 }
253 if !strings.Contains(replies[0], "delivery failed") {
254 t.Errorf("expected delivery failure message, got: %s", replies[0])
255 }
256 }
257
258 func TestOutboundProcessor_ParseError(t *testing.T) {
259 var replies []string
260 sendDM := func(pubkey, content string) error {
261 replies = append(replies, content)
262 return nil
263 }
264
265 op := NewOutboundProcessor(nil, nil, nil, "bridge.example.com", sendDM, nil)
266
267 // Content without "To:" header but starting with something that looks like a header
268 err := op.ProcessOutbound("user1", "Subject: Test\n\nNo recipient")
269 if err != nil {
270 t.Fatalf("unexpected error: %v", err)
271 }
272 if len(replies) == 0 {
273 t.Fatal("expected reply about no recipients")
274 }
275 if !strings.Contains(replies[0], "No recipients") {
276 t.Errorf("expected no-recipients message, got: %s", replies[0])
277 }
278 }
279
280 func TestOutboundProcessor_WithCcRecipients(t *testing.T) {
281 var received *bridgesmtp.InboundEmail
282 server := bridgesmtp.NewServer(bridgesmtp.ServerConfig{
283 Domain: "test.example.com",
284 ListenAddr: "127.0.0.1:0",
285 }, func(email *bridgesmtp.InboundEmail) error {
286 received = email
287 return nil
288 })
289
290 if err := server.Start(); err != nil {
291 t.Fatalf("start server: %v", err)
292 }
293 defer server.Stop(context.Background())
294
295 addr := server.Addr().String()
296 _, port, _ := net.SplitHostPort(addr)
297
298 smtpClient := bridgesmtp.NewClient(bridgesmtp.ClientConfig{
299 FromDomain: "bridge.example.com",
300 })
301 smtpClient.SetResolver(func(domain string) ([]*net.MX, error) {
302 return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
303 })
304 smtpClient.SetDialer(func(a string) (*gosmtp.Client, error) {
305 return gosmtp.Dial("127.0.0.1:" + port)
306 })
307
308 var replies []string
309 sendDM := func(pubkey, content string) error {
310 replies = append(replies, content)
311 return nil
312 }
313
314 op := NewOutboundProcessor(smtpClient, nil, nil, "bridge.example.com", sendDM, nil)
315
316 err := op.ProcessOutbound("user1", "To: alice@test.example.com\nCc: bob@test.example.com\nSubject: CC Test\n\nHello with CC")
317 if err != nil {
318 t.Fatalf("ProcessOutbound failed: %v", err)
319 }
320
321 time.Sleep(100 * time.Millisecond)
322
323 if received == nil {
324 t.Fatal("expected server to receive email")
325 }
326 // Confirmation should include both To and Cc
327 if len(replies) == 0 {
328 t.Fatal("expected confirmation reply")
329 }
330 last := replies[len(replies)-1]
331 if !strings.Contains(last, "alice@test.example.com") {
332 t.Errorf("confirmation missing To recipient: %s", last)
333 }
334 if !strings.Contains(last, "bob@test.example.com") {
335 t.Errorf("confirmation missing Cc recipient: %s", last)
336 }
337 }
338
339 func TestOutboundProcessor_ShortPubkey(t *testing.T) {
340 // Test with pubkey shorter than 16 chars
341 var received *bridgesmtp.InboundEmail
342 server := bridgesmtp.NewServer(bridgesmtp.ServerConfig{
343 Domain: "test.example.com",
344 ListenAddr: "127.0.0.1:0",
345 }, func(email *bridgesmtp.InboundEmail) error {
346 received = email
347 return nil
348 })
349
350 if err := server.Start(); err != nil {
351 t.Fatalf("start server: %v", err)
352 }
353 defer server.Stop(context.Background())
354
355 addr := server.Addr().String()
356 _, port, _ := net.SplitHostPort(addr)
357
358 smtpClient := bridgesmtp.NewClient(bridgesmtp.ClientConfig{
359 FromDomain: "bridge.example.com",
360 })
361 smtpClient.SetResolver(func(domain string) ([]*net.MX, error) {
362 return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
363 })
364 smtpClient.SetDialer(func(a string) (*gosmtp.Client, error) {
365 return gosmtp.Dial("127.0.0.1:" + port)
366 })
367
368 var replies []string
369 sendDM := func(pubkey, content string) error {
370 replies = append(replies, content)
371 return nil
372 }
373
374 op := NewOutboundProcessor(smtpClient, nil, nil, "bridge.example.com", sendDM, nil)
375
376 // Use a short pubkey (< 16 chars)
377 err := op.ProcessOutbound("abc", "To: alice@test.example.com\n\nHello")
378 if err != nil {
379 t.Fatalf("ProcessOutbound failed: %v", err)
380 }
381
382 time.Sleep(100 * time.Millisecond)
383
384 if received == nil {
385 t.Fatal("expected server to receive email")
386 }
387 // From address should use the full short pubkey
388 if !strings.Contains(received.From, "abc@bridge.example.com") {
389 t.Errorf("From = %q, expected abc@bridge.example.com", received.From)
390 }
391 }
392
393 func TestOutboundProcessor_WithRateLimiter(t *testing.T) {
394 var received *bridgesmtp.InboundEmail
395 server := bridgesmtp.NewServer(bridgesmtp.ServerConfig{
396 Domain: "test.example.com",
397 ListenAddr: "127.0.0.1:0",
398 }, func(email *bridgesmtp.InboundEmail) error {
399 received = email
400 return nil
401 })
402
403 if err := server.Start(); err != nil {
404 t.Fatalf("start server: %v", err)
405 }
406 defer server.Stop(context.Background())
407
408 addr := server.Addr().String()
409 _, port, _ := net.SplitHostPort(addr)
410
411 smtpClient := bridgesmtp.NewClient(bridgesmtp.ClientConfig{
412 FromDomain: "bridge.example.com",
413 })
414 smtpClient.SetResolver(func(domain string) ([]*net.MX, error) {
415 return []*net.MX{{Host: "127.0.0.1", Pref: 10}}, nil
416 })
417 smtpClient.SetDialer(func(a string) (*gosmtp.Client, error) {
418 return gosmtp.Dial("127.0.0.1:" + port)
419 })
420
421 var replies []string
422 sendDM := func(pubkey, content string) error {
423 replies = append(replies, content)
424 return nil
425 }
426
427 rl := NewRateLimiter(RateLimitConfig{
428 PerUserPerHour: 100,
429 MinInterval: 0,
430 })
431
432 op := NewOutboundProcessor(smtpClient, rl, nil, "bridge.example.com", sendDM, nil)
433
434 err := op.ProcessOutbound("user1", "To: alice@test.example.com\n\nHello")
435 if err != nil {
436 t.Fatalf("ProcessOutbound failed: %v", err)
437 }
438
439 time.Sleep(100 * time.Millisecond)
440
441 if received == nil {
442 t.Fatal("expected server to receive email")
443 }
444
445 _ = received
446 }
447