package email import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" channelpkg "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" ) // ============================================================ // Service tests // ============================================================ func TestNewService(t *testing.T) { svc := NewService() assert.NotNil(t, svc) assert.NotNil(t, svc.imapListener) assert.NotNil(t, svc.smtpSender) } func TestService_ValidateIMAPConnection(t *testing.T) { svc := NewService() // Incomplete config — should error because IMAP address/login/password missing err := svc.ValidateIMAPConnection(channelpkg.ChannelConfig{}) assert.Error(t, err) assert.Contains(t, err.Error(), "required") } func TestService_ValidateSMTPConnection(t *testing.T) { svc := NewService() err := svc.ValidateSMTPConnection(channelpkg.ChannelConfig{}) assert.Error(t, err) assert.Contains(t, err.Error(), "required") } func TestService_FetchIMAPMessages_IncompleteConfig(t *testing.T) { svc := NewService() inbox := &model.Inbox{Base: model.Base{ID: 1}} _, err := svc.FetchIMAPMessages(context.Background(), inbox, channelpkg.ChannelConfig{}) assert.Error(t, err) assert.Contains(t, err.Error(), "incomplete") } func TestService_SendViaSMTP_IncompleteConfig(t *testing.T) { svc := NewService() _, err := svc.SendViaSMTP(channelpkg.ChannelConfig{}, "from@test.com", "to@test.com", "subject", "body") assert.Error(t, err) assert.Contains(t, err.Error(), "incomplete") } // ============================================================ // Address processing helpers // ============================================================ func TestExtractEmailAddress(t *testing.T) { tests := []struct { input string want string }{ {"", ""}, {"john@example.com", "john@example.com"}, {"John Doe ", "john@example.com"}, {`"John Doe" `, "john@example.com"}, {"", "john@example.com"}, {" john@example.com ", "john@example.com"}, } for _, tt := range tests { got := extractEmailAddress(tt.input) assert.Equal(t, tt.want, got, "input=%q", tt.input) } } func TestExtractDisplayName(t *testing.T) { tests := []struct { input string want string }{ {"", ""}, {"john@example.com", ""}, {"John Doe ", "John Doe"}, {`"John Doe" `, "John Doe"}, {"", ""}, } for _, tt := range tests { got := extractDisplayName(tt.input) assert.Equal(t, tt.want, got, "input=%q", tt.input) } } // ============================================================ // Types tests // ============================================================ func TestConfigInt(t *testing.T) { tests := []struct { name string config channelpkg.ChannelConfig key string def int want int }{ {"missing key", channelpkg.ChannelConfig{}, "port", 993, 993}, {"int value", channelpkg.ChannelConfig{"port": 587}, "port", 993, 587}, {"int64 value", channelpkg.ChannelConfig{"port": int64(587)}, "port", 993, 587}, {"float64 value", channelpkg.ChannelConfig{"port": float64(587)}, "port", 993, 587}, {"string value valid", channelpkg.ChannelConfig{"port": "587"}, "port", 993, 587}, {"string value invalid", channelpkg.ChannelConfig{"port": "abc"}, "port", 993, 993}, {"nil value", channelpkg.ChannelConfig{"port": nil}, "port", 993, 993}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := configInt(tt.config, tt.key, tt.def) assert.Equal(t, tt.want, got) }) } } func TestHTMLToPlainText(t *testing.T) { // htmlToPlainText first replaces entities, then strips tags (adding spaces), // then collapses whitespace. // Note: < > become < > which stripHTMLTags interprets as tag delimiters. tests := []struct { name string html string want string }{ {"empty", "", ""}, {"plain text", "Hello world", "Hello world"}, {"basic HTML", "

Hello

", "Hello"}, {"nested HTML", "

Hello

", "Hello"}, {"amp entity only", "&", "&"}, {"with nbsp", " hello", "hello"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := htmlToPlainText(tt.html) assert.Equal(t, tt.want, got) }) } } func TestStripHTMLTags(t *testing.T) { // stripHTMLTags replaces tags with spaces assert.Equal(t, " hello ", stripHTMLTags("hello")) assert.Equal(t, " a b ", stripHTMLTags("
a
b")) assert.Equal(t, "text", stripHTMLTags("text")) } func TestCollapseWhitespace(t *testing.T) { assert.Equal(t, "a b c", collapseWhitespace("a b\n\nc")) assert.Equal(t, " a b c ", collapseWhitespace(" a b\r\nc ")) assert.Equal(t, " ", collapseWhitespace(" ")) assert.Equal(t, "x", collapseWhitespace("x")) } func TestLooksLikeHTML(t *testing.T) { assert.True(t, looksLikeHTML("hi")) assert.True(t, looksLikeHTML("

hi

")) assert.True(t, looksLikeHTML("
hi
")) assert.True(t, looksLikeHTML("bold")) assert.True(t, looksLikeHTML("
")) assert.False(t, looksLikeHTML("just text")) assert.False(t, looksLikeHTML("")) } func TestStripContentIDBrackets(t *testing.T) { assert.Equal(t, "abc", stripContentIDBrackets("")) assert.Equal(t, "abc", stripContentIDBrackets("abc")) assert.Equal(t, "abc", stripContentIDBrackets(" ")) assert.Equal(t, "")) assert.Equal(t, "abc@domain", extractThreadIDFromReferences(" ")) } func TestParseMessageIDList(t *testing.T) { assert.Nil(t, parseMessageIDList("")) assert.Equal(t, []string{"abc"}, parseMessageIDList("")) assert.Equal(t, []string{"a", "b"}, parseMessageIDList(" ")) } func TestGenerateMessageID(t *testing.T) { id := generateMessageID("example.com") assert.True(t, strings.HasPrefix(id, "")) idDefault := generateMessageID("") assert.True(t, strings.HasSuffix(idDefault, "@gochat.local>")) } func TestFormatDateNow(t *testing.T) { d := formatDateNow() assert.NotEmpty(t, d) // Format: "02 Jan 06 15:04 -0700" assert.Regexp(t, `^\d{2} \w{3} \d{2} \d{2}:\d{2} [+-]\d{4}$`, d) } func TestConvertToIncomingMessage(t *testing.T) { inbox := &model.Inbox{Base: model.Base{ID: 10}, AccountID: 5} emailMsg := &EmailMessage{ MessageID: "msg-123", FromAddress: "sender@example.com", FromName: "Sender Name", ToAddresses: []string{"support@gochat.com"}, CcAddresses: []string{"cc@example.com"}, Subject: "Test Subject", TextContent: "Hello world", InReplyTo: "parent-msg", References: " ", Attachments: []EmailAttachment{ {Filename: "file.pdf", ContentType: "application/pdf", FileSize: 1024}, }, } msg, err := convertToIncomingMessage(emailMsg, inbox) require.NoError(t, err) require.NotNil(t, msg) assert.Equal(t, channelpkg.ChannelEmail, msg.ChannelType) assert.Equal(t, "msg-123", msg.SourceID) assert.Equal(t, "parent-msg", msg.ConversationID) assert.Equal(t, "sender@example.com", msg.SenderID) assert.Equal(t, "Sender Name", msg.SenderName) assert.Equal(t, "Hello world", msg.Content) assert.Equal(t, channelpkg.ContentEmail, msg.ContentType) assert.Equal(t, uint(10), msg.InboxID) assert.Equal(t, uint(5), msg.AccountID) assert.Equal(t, "parent-msg", msg.ReplyToID) assert.Len(t, msg.Attachments, 1) assert.Equal(t, "file.pdf", msg.Attachments[0].Filename) } func TestConvertToIncomingMessage_ReplyTo(t *testing.T) { inbox := &model.Inbox{Base: model.Base{ID: 1}} emailMsg := &EmailMessage{ MessageID: "msg-456", FromAddress: "from@example.com", ReplyToAddress: "reply@example.com", TextContent: "body", } msg, err := convertToIncomingMessage(emailMsg, inbox) require.NoError(t, err) assert.Equal(t, "reply@example.com", msg.SenderID) } func TestConvertToIncomingMessage_HTMLFallback(t *testing.T) { inbox := &model.Inbox{Base: model.Base{ID: 1}} emailMsg := &EmailMessage{ MessageID: "msg-789", FromAddress: "from@example.com", HTMLContent: "

HTML body

", } msg, err := convertToIncomingMessage(emailMsg, inbox) require.NoError(t, err) assert.Equal(t, "HTML body", msg.Content) } func TestConvertToIncomingMessage_EmptyContent(t *testing.T) { inbox := &model.Inbox{Base: model.Base{ID: 1}} emailMsg := &EmailMessage{ MessageID: "msg-empty", FromAddress: "from@example.com", } msg, err := convertToIncomingMessage(emailMsg, inbox) require.NoError(t, err) assert.Equal(t, "(no content)", msg.Content) } // ============================================================ // Provider tests // ============================================================ func TestNewEmailProvider(t *testing.T) { svc := NewService() repo := &Repository{} pipe := NewIncomingProcessor(nil) provider := NewEmailProvider(svc, repo, pipe) assert.NotNil(t, provider) } func TestEmailProvider_Identity(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) assert.Equal(t, channelpkg.ChannelEmail, provider.Type()) assert.Equal(t, "Email", provider.Name()) assert.NotEmpty(t, provider.Description()) } func TestEmailProvider_ConfigSchema(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) schema := provider.ConfigSchema() require.NotNil(t, schema) assert.Equal(t, "object", schema.Type) assert.NotEmpty(t, schema.Properties) assert.NotEmpty(t, schema.Required) } func TestEmailProvider_ValidateConfig(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) ctx := context.Background() t.Run("empty config fails", func(t *testing.T) { err := provider.ValidateConfig(ctx, channelpkg.ChannelConfig{}) assert.Error(t, err) }) t.Run("missing imap fields", func(t *testing.T) { config := channelpkg.ChannelConfig{ "imap_address": "imap.example.com", } err := provider.ValidateConfig(ctx, config) assert.Error(t, err) assert.Contains(t, err.Error(), "imap_port") }) t.Run("missing smtp fields", func(t *testing.T) { config := channelpkg.ChannelConfig{ "imap_address": "imap.example.com", "imap_port": 993, "imap_email": "user@example.com", "imap_password": "pass", "smtp_address": "smtp.example.com", } err := provider.ValidateConfig(ctx, config) assert.Error(t, err) assert.Contains(t, err.Error(), "smtp_port") }) t.Run("complete config passes", func(t *testing.T) { config := channelpkg.ChannelConfig{ "imap_address": "imap.example.com", "imap_port": 993, "imap_email": "user@example.com", "imap_password": "pass", "smtp_address": "smtp.example.com", "smtp_port": 587, "smtp_email": "user@example.com", "smtp_password": "pass", } err := provider.ValidateConfig(ctx, config) assert.NoError(t, err) }) } func TestEmailProvider_DefaultConfig(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) config := provider.DefaultConfig() assert.NotNil(t, config) assert.Equal(t, 993, config["imap_port"]) assert.Equal(t, 587, config["smtp_port"]) assert.Equal(t, true, config["imap_ssl"]) assert.Equal(t, true, config["smtp_ssl"]) } func TestEmailProvider_OnCreate(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) ctx := context.Background() inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1} // OnCreate will try to validate IMAP/SMTP which will fail — expect error config := channelpkg.ChannelConfig{ "smtp_email": "support@example.com", } _, err := provider.OnCreate(ctx, inbox, config) assert.Error(t, err) // IMAP connection validation fails } func TestEmailProvider_OnDestroy(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) err := provider.OnDestroy(context.Background(), &model.Inbox{Base: model.Base{ID: 1}}, channelpkg.ChannelConfig{}) assert.NoError(t, err) } func TestEmailProvider_ProcessIncoming(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) ctx := context.Background() inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1} t.Run("valid payload", func(t *testing.T) { emailMsg := EmailMessage{ MessageID: "test-msg-1", FromAddress: "sender@example.com", FromName: "Sender", Subject: "Test", TextContent: "Hello", } payload, _ := json.Marshal(emailMsg) msg, err := provider.ProcessIncoming(ctx, inbox, payload) require.NoError(t, err) require.NotNil(t, msg) assert.Equal(t, "test-msg-1", msg.SourceID) assert.Equal(t, "sender@example.com", msg.SenderID) assert.Equal(t, "Hello", msg.Content) assert.Equal(t, channelpkg.ChannelEmail, msg.ChannelType) }) t.Run("invalid JSON", func(t *testing.T) { _, err := provider.ProcessIncoming(ctx, inbox, []byte("invalid json")) assert.Error(t, err) }) t.Run("with attachments", func(t *testing.T) { emailMsg := EmailMessage{ MessageID: "test-msg-2", FromAddress: "sender@example.com", TextContent: "With attachment", Attachments: []EmailAttachment{ {Filename: "doc.pdf", ContentType: "application/pdf"}, }, } payload, _ := json.Marshal(emailMsg) msg, err := provider.ProcessIncoming(ctx, inbox, payload) require.NoError(t, err) assert.Len(t, msg.Attachments, 1) assert.Equal(t, "doc.pdf", msg.Attachments[0].Filename) }) t.Run("with reply-to", func(t *testing.T) { emailMsg := EmailMessage{ MessageID: "test-msg-3", FromAddress: "sender@example.com", TextContent: "Reply", InReplyTo: "original-msg", } payload, _ := json.Marshal(emailMsg) msg, err := provider.ProcessIncoming(ctx, inbox, payload) require.NoError(t, err) assert.Equal(t, "original-msg", msg.ReplyToID) }) } func TestEmailProvider_ValidateWebhookRequest(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) err := provider.ValidateWebhookRequest(context.Background(), &model.Inbox{}, &channelpkg.WebhookRequest{}) assert.NoError(t, err) } func TestEmailProvider_SendMessage(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) ctx := context.Background() inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1} t.Run("no contact source_id", func(t *testing.T) { contact := &model.Contact{} msg := &model.Message{Content: "test"} _, err := provider.SendMessage(ctx, inbox, msg, contact) assert.Error(t, err) }) t.Run("no smtp_email config", func(t *testing.T) { contact := &model.Contact{SourceID: "recipient@example.com"} msg := &model.Message{Content: "test"} _, err := provider.SendMessage(ctx, inbox, msg, contact) assert.Error(t, err) assert.Contains(t, err.Error(), "smtp_email") }) } func TestEmailProvider_GetContactProfile(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) profile, err := provider.GetContactProfile(context.Background(), &model.Inbox{}, "user@example.com") require.NoError(t, err) assert.Equal(t, "user@example.com", profile.Name) assert.Equal(t, "user@example.com", profile.Extra["email"]) } func TestEmailProvider_Capabilities(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) caps := provider.Capabilities() assert.True(t, caps.SupportsAttachments) assert.True(t, caps.SupportsDeliveryStatus) assert.True(t, caps.SupportsReplies) assert.True(t, caps.SupportsEmailHeaders) assert.False(t, caps.SupportsTypingIndicator) assert.Equal(t, int64(25*1024*1024), caps.MaxAttachmentSize) } func TestEmailProvider_PollInterval(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) assert.Equal(t, 5*time.Minute, provider.PollInterval()) } func TestEmailProvider_PollMessages_EmptyConfig(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) inbox := &model.Inbox{Base: model.Base{ID: 1}} msgs, err := provider.PollMessages(context.Background(), inbox) // IMAP fetch will fail due to incomplete config assert.Error(t, err) assert.Nil(t, msgs) } func TestEmailProvider_SetPipeline(t *testing.T) { provider := NewEmailProvider(NewService(), &Repository{}, NewIncomingProcessor(nil)) newPipe := NewIncomingProcessor(nil) provider.SetPipeline(newPipe) assert.NotNil(t, provider.pipeline) } // ============================================================ // Helper functions tests // ============================================================ func TestParseInboxConfig(t *testing.T) { t.Run("empty string", func(t *testing.T) { config := parseInboxConfig("") assert.NotNil(t, config) assert.Empty(t, config) }) t.Run("valid JSON", func(t *testing.T) { config := parseInboxConfig(`{"key":"value"}`) assert.Equal(t, "value", config["key"]) }) t.Run("invalid JSON", func(t *testing.T) { config := parseInboxConfig("invalid") assert.NotNil(t, config) assert.Empty(t, config) }) } func TestConfigStr(t *testing.T) { assert.Equal(t, "default", configStr(channelpkg.ChannelConfig{}, "key", "default")) assert.Equal(t, "value", configStr(channelpkg.ChannelConfig{"key": "value"}, "key", "default")) assert.Equal(t, "default", configStr(channelpkg.ChannelConfig{"key": ""}, "key", "default")) assert.Equal(t, "default", configStr(channelpkg.ChannelConfig{"key": 123}, "key", "default")) } // ============================================================ // IMAPListener tests // ============================================================ func TestNewIMAPListener(t *testing.T) { l := NewIMAPListener() assert.NotNil(t, l) assert.NotNil(t, l.lastUID) } func TestIMAPListener_ValidateConnection_IncompleteConfig(t *testing.T) { l := NewIMAPListener() err := l.ValidateConnection(channelpkg.ChannelConfig{}) assert.Error(t, err) assert.Contains(t, err.Error(), "required") } func TestIMAPListener_Fetch_IncompleteConfig(t *testing.T) { l := NewIMAPListener() inbox := &model.Inbox{Base: model.Base{ID: 1}} _, err := l.Fetch(context.Background(), inbox, channelpkg.ChannelConfig{}) assert.Error(t, err) assert.Contains(t, err.Error(), "incomplete") } // ============================================================ // SMTPSender tests // ============================================================ func TestNewSMTPSender(t *testing.T) { s := NewSMTPSender() assert.NotNil(t, s) } func TestSMTPSender_ValidateConnection_IncompleteConfig(t *testing.T) { s := NewSMTPSender() err := s.ValidateConnection(channelpkg.ChannelConfig{}) assert.Error(t, err) assert.Contains(t, err.Error(), "required") } func TestSMTPSender_Send_IncompleteConfig(t *testing.T) { s := NewSMTPSender() _, err := s.Send(channelpkg.ChannelConfig{}, "", "to@test.com", "subject", "body") assert.Error(t, err) assert.Contains(t, err.Error(), "incomplete") } func TestComposeSMTPMessage_PlainText(t *testing.T) { msg := composeSMTPMessage("from@test.com", "to@test.com", "Subject", "Hello body", "") assert.Contains(t, msg, "From: from@test.com") assert.Contains(t, msg, "To: to@test.com") assert.Contains(t, msg, "Subject: Subject") assert.Contains(t, msg, "Message-Id: ") assert.Contains(t, msg, "Content-Type: text/plain") assert.Contains(t, msg, "Hello body") } func TestComposeSMTPMessage_HTML(t *testing.T) { msg := composeSMTPMessage("from@test.com", "to@test.com", "Subject", "

HTML body

", "") assert.Contains(t, msg, "Content-Type: text/html") } func TestExtractMessageIDFromRaw(t *testing.T) { raw := []byte("From: a@b\r\nMessage-Id: \r\n\r\nbody") id := extractMessageIDFromRaw(raw) assert.Equal(t, "", id) raw2 := []byte("From: a@b\r\nMessage-ID: \r\n\r\nbody") id2 := extractMessageIDFromRaw(raw2) assert.Equal(t, "", id2) raw3 := []byte("no message id here") id3 := extractMessageIDFromRaw(raw3) assert.Equal(t, "", id3) } // ============================================================ // Pipeline tests // ============================================================ func TestNewIncomingProcessor(t *testing.T) { p := NewIncomingProcessor(nil) assert.NotNil(t, p) } func TestIncomingProcessor_Process_NilHandler(t *testing.T) { p := NewIncomingProcessor(nil) inbox := &model.Inbox{Base: model.Base{ID: 1}} emailMsg := &EmailMessage{MessageID: "test"} _, err := p.Process(context.Background(), inbox, emailMsg) assert.Error(t, err) assert.Contains(t, err.Error(), "not initialized") } func TestIncomingProcessor_Process_WithHandler(t *testing.T) { // Use a mock handler that returns a dummy IncomingMessage mockHandler := &mockIncomingHandler{} p := NewIncomingProcessor(mockHandler) inbox := &model.Inbox{Base: model.Base{ID: 1}} emailMsg := &EmailMessage{MessageID: "test", FromAddress: "a@b.com"} _, err := p.Process(context.Background(), inbox, emailMsg) assert.NoError(t, err) } func TestNewOutgoingProcessor(t *testing.T) { p := NewOutgoingProcessor(nil) assert.NotNil(t, p) } func TestOutgoingProcessor_Process_NilHandler(t *testing.T) { p := NewOutgoingProcessor(nil) inbox := &model.Inbox{Base: model.Base{ID: 1}} msg := &model.Message{Content: "test"} contact := &model.Contact{} _, err := p.Process(context.Background(), inbox, msg, contact) assert.Error(t, err) assert.Contains(t, err.Error(), "not initialized") } func TestSerializeEmailMessage(t *testing.T) { emailMsg := &EmailMessage{ MessageID: "test-id", FromAddress: "from@test.com", Subject: "Test", TextContent: "body", } data, err := serializeEmailMessage(emailMsg) require.NoError(t, err) assert.NotEmpty(t, data) // Verify it's valid JSON var m map[string]interface{} err = json.Unmarshal(data, &m) require.NoError(t, err) assert.Equal(t, "test-id", m["message_id"]) } // ============================================================ // Repository tests (using in-memory sqlite) // ============================================================ func TestRepository_PollStateTableName(t *testing.T) { assert.Equal(t, "email_poll_states", PollState{}.TableName()) } // ============================================================ // Webhook handler tests // ============================================================ func TestNewWebhookHandler(t *testing.T) { h := NewWebhookHandler() assert.NotNil(t, h) } func TestWebhookHandler_HandleWebhookRequest_EmptyBody(t *testing.T) { h := NewWebhookHandler() req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader("")) w := httptest.NewRecorder() h.HandleWebhookRequest(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestWebhookHandler_HandleWebhookRequest_GenericJSON(t *testing.T) { h := NewWebhookHandler() body := `{"from":"sender@example.com","subject":"Test","text":"Hello"}` req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() h.HandleWebhookRequest(w, req) assert.Equal(t, http.StatusOK, w.Code) } func TestWebhookHandler_HandleWebhookRequest_MailgunFormat(t *testing.T) { h := NewWebhookHandler() body := `{"from":"sender@example.com","subject":"MG Test","body-plain":"Hello MG","Message-Id":""}` req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) req.Header.Set("X-Mailgun-Sid", "mg-123") w := httptest.NewRecorder() h.HandleWebhookRequest(w, req) assert.Equal(t, http.StatusOK, w.Code) } func TestWebhookHandler_HandleWebhookRequest_SendGridFormat(t *testing.T) { h := NewWebhookHandler() body := `{"envelope":{"from":"sg@example.com","to":["support@gochat.com"]},"subject":"SG Test","text":"Hello SG","messageId":"sg-123"}` req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) req.Header.Set("X-Sg-Envelope-Id", "sg-456") w := httptest.NewRecorder() h.HandleWebhookRequest(w, req) assert.Equal(t, http.StatusOK, w.Code) } func TestWebhookHandler_HandleWebhookRequest_RawEmail(t *testing.T) { h := NewWebhookHandler() body := "From: raw@example.com\r\nSubject: Raw Test\r\n\r\nHello raw email" req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) w := httptest.NewRecorder() h.HandleWebhookRequest(w, req) assert.Equal(t, http.StatusOK, w.Code) } func TestWebhookHandler_ParseWebhookBody_GenericJSON(t *testing.T) { h := NewWebhookHandler() body := []byte(`{"from":"sender@example.com","subject":"Test","text":"Hello","to":"support@gochat.com"}`) headers := http.Header{} headers.Set("Content-Type", "application/json") msg, err := h.ParseWebhookBody(body, headers) require.NoError(t, err) assert.Equal(t, "sender@example.com", msg.FromAddress) assert.Equal(t, "Test", msg.Subject) assert.Equal(t, "Hello", msg.TextContent) assert.Equal(t, []string{"support@gochat.com"}, msg.ToAddresses) } func TestWebhookHandler_ParseWebhookBody_Mailgun(t *testing.T) { h := NewWebhookHandler() body := []byte(`{"from":"mg@example.com","subject":"MG","body-plain":"text","body-html":"

html

","Message-Id":"","to":"support@gochat.com"}`) headers := http.Header{} headers.Set("X-Mailgun-Sid", "mg-123") msg, err := h.ParseWebhookBody(body, headers) require.NoError(t, err) assert.Equal(t, "mg@example.com", msg.FromAddress) assert.Equal(t, "MG", msg.Subject) assert.Equal(t, "text", msg.TextContent) assert.Equal(t, "

html

", msg.HTMLContent) assert.Equal(t, "", msg.MessageID) } func TestWebhookHandler_ParseWebhookBody_SendGrid(t *testing.T) { h := NewWebhookHandler() body := []byte(`{"envelope":{"from":"sg@example.com","to":["support@gochat.com"]},"subject":"SG","text":"SG text","html":"

SG html

","messageId":"sg-123"}`) headers := http.Header{} headers.Set("X-Sg-Envelope-Id", "sg-456") msg, err := h.ParseWebhookBody(body, headers) require.NoError(t, err) assert.Equal(t, "sg@example.com", msg.FromAddress) assert.Equal(t, "SG", msg.Subject) assert.Equal(t, "SG text", msg.TextContent) assert.Equal(t, []string{"support@gochat.com"}, msg.ToAddresses) assert.Equal(t, "sg-123", msg.MessageID) } func TestWebhookHandler_ParseWebhookBody_RawEmail(t *testing.T) { h := NewWebhookHandler() body := []byte("This is a raw email body") headers := http.Header{} msg, err := h.ParseWebhookBody(body, headers) require.NoError(t, err) assert.Equal(t, "This is a raw email body", msg.TextContent) } func TestIsMailgunPayload(t *testing.T) { headers := http.Header{} headers.Set("X-Mailgun-Sid", "123") assert.True(t, isMailgunPayload(headers)) headers2 := http.Header{} assert.False(t, isMailgunPayload(headers2)) } func TestIsSendGridPayload(t *testing.T) { headers := http.Header{} headers.Set("X-Sg-Envelope-Id", "456") assert.True(t, isSendGridPayload(headers)) headers2 := http.Header{} assert.False(t, isSendGridPayload(headers2)) } // ============================================================ // Mock handlers // ============================================================ type mockIncomingHandler struct{} func (m *mockIncomingHandler) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channelpkg.IncomingMessage, error) { return &channelpkg.IncomingMessage{ ChannelType: channelpkg.ChannelEmail, SourceID: "mock-msg-id", InboxID: inbox.ID, }, nil }