289 lines
6.9 KiB
Go
289 lines
6.9 KiB
Go
package email
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/emersion/go-imap/v2"
|
|
"github.com/emersion/go-imap/v2/imapclient"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Generate self-signed certificate for TLS testing
|
|
func generateTLSCert() tls.Certificate {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
template := x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
DNSNames: []string{"localhost", "127.0.0.1"},
|
|
}
|
|
|
|
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
|
|
|
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return tlsCert
|
|
}
|
|
|
|
// startFakeSMTPServer creates a TLS-enabled fake SMTP server
|
|
func startFakeSMTPServer(t *testing.T, handler func(conn net.Conn)) string {
|
|
t.Helper()
|
|
|
|
cert := generateTLSCert()
|
|
tlsConfig := &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
}
|
|
|
|
listener, err := tls.Listen("tcp", "127.0.0.1:0", tlsConfig)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { listener.Close() })
|
|
|
|
go func() {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
handler(conn)
|
|
}()
|
|
|
|
_, port, _ := net.SplitHostPort(listener.Addr().String())
|
|
return port
|
|
}
|
|
|
|
// createTestClient is a helper to create a Client with TLS skip verification
|
|
func createTestClient(smtpPort, username, password string) *Client {
|
|
return &Client{
|
|
smtpAddr: fmt.Sprintf("127.0.0.1:%s", smtpPort),
|
|
imapAddr: "imap.test.com:993",
|
|
username: username,
|
|
password: password,
|
|
fromAddr: "user@test.com",
|
|
pollInterval: 10 * time.Second,
|
|
tlsConfig: &tls.Config{InsecureSkipVerify: true},
|
|
}
|
|
}
|
|
|
|
func TestNew(t *testing.T) {
|
|
t.Run("with defaults", func(t *testing.T) {
|
|
c, err := New(Config{
|
|
SMTPHost: "smtp.test.com",
|
|
IMAPHost: "imap.test.com",
|
|
Username: "user@test.com",
|
|
Password: "pass",
|
|
FromAddr: "user@test.com",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "smtp.test.com:465", c.smtpAddr)
|
|
assert.Equal(t, "imap.test.com:993", c.imapAddr)
|
|
assert.Equal(t, "user@test.com", c.username)
|
|
assert.Equal(t, 10*time.Second, c.pollInterval)
|
|
})
|
|
|
|
t.Run("custom ports", func(t *testing.T) {
|
|
c, err := New(Config{
|
|
SMTPHost: "smtp.test.com",
|
|
SMTPPort: "587",
|
|
IMAPHost: "imap.test.com",
|
|
IMAPPort: "143",
|
|
Username: "user@test.com",
|
|
Password: "pass",
|
|
FromAddr: "user@test.com",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "smtp.test.com:587", c.smtpAddr)
|
|
assert.Equal(t, "imap.test.com:143", c.imapAddr)
|
|
})
|
|
}
|
|
|
|
func TestSend_EmptyRecipient(t *testing.T) {
|
|
c, _ := New(Config{
|
|
SMTPHost: "smtp.test.com",
|
|
IMAPHost: "imap.test.com",
|
|
Username: "user@test.com",
|
|
Password: "pass",
|
|
FromAddr: "user@test.com",
|
|
})
|
|
|
|
err := c.Send(context.Background(), "", "hello")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "empty recipient")
|
|
}
|
|
|
|
func TestSend_SMTPServerDown(t *testing.T) {
|
|
c, _ := New(Config{
|
|
SMTPHost: "127.0.0.1",
|
|
SMTPPort: "25999",
|
|
IMAPHost: "imap.test.com",
|
|
Username: "user@test.com",
|
|
Password: "pass",
|
|
FromAddr: "user@test.com",
|
|
})
|
|
|
|
err := c.Send(context.Background(), "to@test.com", "hello")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "smtp connect")
|
|
}
|
|
|
|
func TestSend_Success(t *testing.T) {
|
|
port := startFakeSMTPServer(t, func(conn net.Conn) {
|
|
reader := bufio.NewReader(conn)
|
|
|
|
// Greeting
|
|
fmt.Fprintf(conn, "220 smtp.example.com ESMTP\r\n")
|
|
|
|
// Read EHLO
|
|
reader.ReadString('\n')
|
|
fmt.Fprintf(conn, "250-smtp.example.com\r\n")
|
|
fmt.Fprintf(conn, "250 AUTH PLAIN\r\n")
|
|
|
|
// Read AUTH
|
|
reader.ReadString('\n')
|
|
fmt.Fprintf(conn, "235 2.7.0 Authentication successful\r\n")
|
|
|
|
// Read MAIL FROM
|
|
reader.ReadString('\n')
|
|
fmt.Fprintf(conn, "250 2.1.0 Ok\r\n")
|
|
|
|
// Read RCPT TO
|
|
reader.ReadString('\n')
|
|
fmt.Fprintf(conn, "250 2.1.5 Ok\r\n")
|
|
|
|
// Read DATA
|
|
reader.ReadString('\n')
|
|
fmt.Fprintf(conn, "354 End data with <CR><LF>.<CR><LF>\r\n")
|
|
|
|
// Read message body (ending with \r\n.\r\n)
|
|
reader.ReadString('\n')
|
|
reader.ReadString('\n')
|
|
fmt.Fprintf(conn, "250 2.0.0 Ok: queued\r\n")
|
|
})
|
|
|
|
c := createTestClient(port, "user@test.com", "pass")
|
|
err := c.Send(context.Background(), "to@test.com", "Hello World")
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestSend_AuthFailure(t *testing.T) {
|
|
port := startFakeSMTPServer(t, func(conn net.Conn) {
|
|
reader := bufio.NewReader(conn)
|
|
|
|
fmt.Fprintf(conn, "220 smtp.example.com ESMTP\r\n")
|
|
reader.ReadString('\n')
|
|
fmt.Fprintf(conn, "250-smtp.example.com\r\n")
|
|
fmt.Fprintf(conn, "250 AUTH PLAIN\r\n")
|
|
reader.ReadString('\n')
|
|
fmt.Fprintf(conn, "535 5.7.8 Authentication failed\r\n")
|
|
})
|
|
|
|
c := createTestClient(port, "user@test.com", "wrong")
|
|
err := c.Send(context.Background(), "to@test.com", "Hello")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "smtp auth")
|
|
}
|
|
|
|
func TestReceive_ContextCancel(t *testing.T) {
|
|
c, _ := New(Config{
|
|
SMTPHost: "smtp.test.com",
|
|
IMAPHost: "127.0.0.1",
|
|
IMAPPort: "25998",
|
|
Username: "user@test.com",
|
|
Password: "pass",
|
|
FromAddr: "user@test.com",
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ch := c.Receive(ctx)
|
|
cancel()
|
|
|
|
// Should close quickly
|
|
select {
|
|
case _, ok := <-ch:
|
|
assert.False(t, ok)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("channel not closed after cancel")
|
|
}
|
|
}
|
|
|
|
func TestReceive_ConnectionFailure(t *testing.T) {
|
|
c, _ := New(Config{
|
|
SMTPHost: "smtp.test.com",
|
|
IMAPHost: "127.0.0.1",
|
|
IMAPPort: "25999",
|
|
Username: "user@test.com",
|
|
Password: "pass",
|
|
FromAddr: "user@test.com",
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
ch := c.Receive(ctx)
|
|
|
|
select {
|
|
case msg := <-ch:
|
|
t.Fatalf("unexpected message: %+v", msg)
|
|
case <-time.After(2 * time.Second):
|
|
// Expected - connection failed, channel closed
|
|
}
|
|
}
|
|
|
|
func TestExtractFrom(t *testing.T) {
|
|
t.Run("nil envelope", func(t *testing.T) {
|
|
assert.Equal(t, "", extractFrom(nil))
|
|
})
|
|
|
|
t.Run("with data", func(t *testing.T) {
|
|
env := &imap.Envelope{
|
|
From: []imap.Address{
|
|
{
|
|
Name: "John Doe",
|
|
Mailbox: "john",
|
|
Host: "example.com",
|
|
},
|
|
},
|
|
}
|
|
assert.Equal(t, "john@example.com", extractFrom(env))
|
|
})
|
|
}
|
|
|
|
func TestExtractBody(t *testing.T) {
|
|
t.Run("empty sections", func(t *testing.T) {
|
|
msg := &imapclient.FetchMessageBuffer{}
|
|
assert.Equal(t, "", extractBody(msg))
|
|
})
|
|
|
|
t.Run("with data", func(t *testing.T) {
|
|
msg := &imapclient.FetchMessageBuffer{
|
|
BodySection: []imapclient.FetchBodySectionBuffer{{
|
|
Section: &imap.FetchItemBodySection{},
|
|
Bytes: []byte("hello world"),
|
|
}},
|
|
}
|
|
assert.Equal(t, "hello world", extractBody(msg))
|
|
})
|
|
}
|