Files
gochat/backend/internal/channel/email/email_test.go
T

836 lines
28 KiB
Go

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@example.com"},
{`"John Doe" <john@example.com>`, "john@example.com"},
{"<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@example.com>", "John Doe"},
{`"John Doe" <john@example.com>`, "John Doe"},
{"<john@example.com>", ""},
}
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: &lt; &gt; become < > which stripHTMLTags interprets as tag delimiters.
tests := []struct {
name string
html string
want string
}{
{"empty", "", ""},
{"plain text", "Hello world", "Hello world"},
{"basic HTML", "<p>Hello</p>", "Hello"},
{"nested HTML", "<div><p>Hello</p></div>", "Hello"},
{"amp entity only", "&amp;", "&"},
{"with nbsp", "&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("<b>hello</b>"))
assert.Equal(t, " a b ", stripHTMLTags("<div>a</div><span>b</span>"))
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("<html><body>hi</body></html>"))
assert.True(t, looksLikeHTML("<p>hi</p>"))
assert.True(t, looksLikeHTML("<div>hi</div>"))
assert.True(t, looksLikeHTML("<b>bold</b>"))
assert.True(t, looksLikeHTML("<br>"))
assert.False(t, looksLikeHTML("just text"))
assert.False(t, looksLikeHTML(""))
}
func TestStripContentIDBrackets(t *testing.T) {
assert.Equal(t, "abc", stripContentIDBrackets("<abc>"))
assert.Equal(t, "abc", stripContentIDBrackets("abc"))
assert.Equal(t, "abc", stripContentIDBrackets(" <abc> "))
assert.Equal(t, "<abc", stripContentIDBrackets("<abc"))
}
func TestExtractDomainFromAddress(t *testing.T) {
assert.Equal(t, "example.com", extractDomainFromAddress("user@example.com"))
assert.Equal(t, "", extractDomainFromAddress("invalid"))
assert.Equal(t, "", extractDomainFromAddress(""))
}
func TestBuildSubjectForReply(t *testing.T) {
assert.Equal(t, "Re: Your inquiry", buildSubjectForReply(""))
assert.Equal(t, "Re: Hello", buildSubjectForReply("Hello"))
assert.Equal(t, "Re: Hello", buildSubjectForReply("Re: Hello"))
assert.Equal(t, "RE: Hello", buildSubjectForReply("RE: Hello"))
}
func TestExtractThreadIDFromReferences(t *testing.T) {
assert.Equal(t, "", extractThreadIDFromReferences(""))
assert.Equal(t, "abc@domain", extractThreadIDFromReferences("<abc@domain>"))
assert.Equal(t, "abc@domain", extractThreadIDFromReferences("<abc@domain> <def@domain>"))
}
func TestParseMessageIDList(t *testing.T) {
assert.Nil(t, parseMessageIDList(""))
assert.Equal(t, []string{"abc"}, parseMessageIDList("<abc>"))
assert.Equal(t, []string{"a", "b"}, parseMessageIDList("<a> <b>"))
}
func TestGenerateMessageID(t *testing.T) {
id := generateMessageID("example.com")
assert.True(t, strings.HasPrefix(id, "<gochat-"))
assert.True(t, strings.HasSuffix(id, "@example.com>"))
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: "<parent-msg> <child-msg>",
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: "<p>HTML body</p>",
}
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", "<msg-id@test>")
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: <msg-id@test>")
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", "<p>HTML body</p>", "<msg-id@test>")
assert.Contains(t, msg, "Content-Type: text/html")
}
func TestExtractMessageIDFromRaw(t *testing.T) {
raw := []byte("From: a@b\r\nMessage-Id: <test@id>\r\n\r\nbody")
id := extractMessageIDFromRaw(raw)
assert.Equal(t, "<test@id>", id)
raw2 := []byte("From: a@b\r\nMessage-ID: <test2@id>\r\n\r\nbody")
id2 := extractMessageIDFromRaw(raw2)
assert.Equal(t, "<test2@id>", 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":"<mg123@example.com>"}`
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":"<p>html</p>","Message-Id":"<mg1@ex.com>","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, "<p>html</p>", msg.HTMLContent)
assert.Equal(t, "<mg1@ex.com>", 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":"<p>SG html</p>","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
}