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