mirror of https://github.com/usememos/memos.git
feat: add Email Plugin with SMTP functionality
- Implemented the Email Plugin for self-hosted Memos instances, providing SMTP email sending capabilities. - Created configuration structure for SMTP settings with validation. - Developed message structure for email content with validation and formatting. - Added synchronous and asynchronous email sending methods. - Implemented error handling and logging for email sending processes. - Included tests for client, configuration, and message functionalities to ensure reliability. - Updated documentation to reflect new features and usage instructions.
This commit is contained in:
parent
319a7cac94
commit
b55a0314f8
|
|
@ -0,0 +1,507 @@
|
|||
# Email Plugin
|
||||
|
||||
SMTP email sending functionality for self-hosted Memos instances.
|
||||
|
||||
## Overview
|
||||
|
||||
This plugin provides a simple, reliable email sending interface following industry-standard SMTP protocols. It's designed for self-hosted environments where instance administrators configure their own email service, similar to platforms like GitHub, GitLab, and Discourse.
|
||||
|
||||
## Features
|
||||
|
||||
- Standard SMTP protocol support
|
||||
- TLS/STARTTLS and SSL/TLS encryption
|
||||
- HTML and plain text emails
|
||||
- Multiple recipients (To, Cc, Bcc)
|
||||
- Synchronous and asynchronous sending
|
||||
- Detailed error reporting with context
|
||||
- Works with all major email providers
|
||||
- Reply-To header support
|
||||
- RFC 5322 compliant message formatting
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Configure SMTP Settings
|
||||
|
||||
```go
|
||||
import "github.com/usememos/memos/plugin/email"
|
||||
|
||||
config := &email.Config{
|
||||
SMTPHost: "smtp.gmail.com",
|
||||
SMTPPort: 587,
|
||||
SMTPUsername: "your-email@gmail.com",
|
||||
SMTPPassword: "your-app-password",
|
||||
FromEmail: "noreply@yourdomain.com",
|
||||
FromName: "Memos",
|
||||
UseTLS: true,
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create and Send Email
|
||||
|
||||
```go
|
||||
message := &email.Message{
|
||||
To: []string{"user@example.com"},
|
||||
Subject: "Welcome to Memos!",
|
||||
Body: "Thanks for signing up.",
|
||||
IsHTML: false,
|
||||
}
|
||||
|
||||
// Synchronous send (waits for result)
|
||||
err := email.Send(config, message)
|
||||
if err != nil {
|
||||
log.Printf("Failed to send email: %v", err)
|
||||
}
|
||||
|
||||
// Asynchronous send (returns immediately)
|
||||
email.SendAsync(config, message)
|
||||
```
|
||||
|
||||
## Provider Configuration
|
||||
|
||||
### Gmail
|
||||
|
||||
Requires an [App Password](https://support.google.com/accounts/answer/185833) (2FA must be enabled):
|
||||
|
||||
```go
|
||||
config := &email.Config{
|
||||
SMTPHost: "smtp.gmail.com",
|
||||
SMTPPort: 587,
|
||||
SMTPUsername: "your-email@gmail.com",
|
||||
SMTPPassword: "your-16-char-app-password",
|
||||
FromEmail: "your-email@gmail.com",
|
||||
FromName: "Memos",
|
||||
UseTLS: true,
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative (SSL):**
|
||||
```go
|
||||
config := &email.Config{
|
||||
SMTPHost: "smtp.gmail.com",
|
||||
SMTPPort: 465,
|
||||
SMTPUsername: "your-email@gmail.com",
|
||||
SMTPPassword: "your-16-char-app-password",
|
||||
FromEmail: "your-email@gmail.com",
|
||||
FromName: "Memos",
|
||||
UseSSL: true,
|
||||
}
|
||||
```
|
||||
|
||||
### SendGrid
|
||||
|
||||
```go
|
||||
config := &email.Config{
|
||||
SMTPHost: "smtp.sendgrid.net",
|
||||
SMTPPort: 587,
|
||||
SMTPUsername: "apikey",
|
||||
SMTPPassword: "your-sendgrid-api-key",
|
||||
FromEmail: "noreply@yourdomain.com",
|
||||
FromName: "Memos",
|
||||
UseTLS: true,
|
||||
}
|
||||
```
|
||||
|
||||
### AWS SES
|
||||
|
||||
```go
|
||||
config := &email.Config{
|
||||
SMTPHost: "email-smtp.us-east-1.amazonaws.com",
|
||||
SMTPPort: 587,
|
||||
SMTPUsername: "your-smtp-username",
|
||||
SMTPPassword: "your-smtp-password",
|
||||
FromEmail: "verified@yourdomain.com",
|
||||
FromName: "Memos",
|
||||
UseTLS: true,
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Replace `us-east-1` with your AWS region. Email must be verified in SES.
|
||||
|
||||
### Mailgun
|
||||
|
||||
```go
|
||||
config := &email.Config{
|
||||
SMTPHost: "smtp.mailgun.org",
|
||||
SMTPPort: 587,
|
||||
SMTPUsername: "postmaster@yourdomain.com",
|
||||
SMTPPassword: "your-mailgun-smtp-password",
|
||||
FromEmail: "noreply@yourdomain.com",
|
||||
FromName: "Memos",
|
||||
UseTLS: true,
|
||||
}
|
||||
```
|
||||
|
||||
### Self-Hosted SMTP (Postfix, Exim, etc.)
|
||||
|
||||
```go
|
||||
config := &email.Config{
|
||||
SMTPHost: "mail.yourdomain.com",
|
||||
SMTPPort: 587,
|
||||
SMTPUsername: "username",
|
||||
SMTPPassword: "password",
|
||||
FromEmail: "noreply@yourdomain.com",
|
||||
FromName: "Memos",
|
||||
UseTLS: true,
|
||||
}
|
||||
```
|
||||
|
||||
## HTML Emails
|
||||
|
||||
```go
|
||||
message := &email.Message{
|
||||
To: []string{"user@example.com"},
|
||||
Subject: "Welcome to Memos!",
|
||||
Body: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif;">
|
||||
<h1 style="color: #333;">Welcome to Memos!</h1>
|
||||
<p>We're excited to have you on board.</p>
|
||||
<a href="https://yourdomain.com" style="background-color: #4CAF50; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Get Started</a>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
email.Send(config, message)
|
||||
```
|
||||
|
||||
## Multiple Recipients
|
||||
|
||||
```go
|
||||
message := &email.Message{
|
||||
To: []string{"user1@example.com", "user2@example.com"},
|
||||
Cc: []string{"manager@example.com"},
|
||||
Bcc: []string{"admin@example.com"},
|
||||
Subject: "Team Update",
|
||||
Body: "Important team announcement...",
|
||||
ReplyTo: "support@yourdomain.com",
|
||||
}
|
||||
|
||||
email.Send(config, message)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
go test ./plugin/email/... -v
|
||||
|
||||
# With coverage
|
||||
go test ./plugin/email/... -v -cover
|
||||
|
||||
# With race detector
|
||||
go test ./plugin/email/... -race
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
|
||||
Create a simple test program:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"github.com/usememos/memos/plugin/email"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config := &email.Config{
|
||||
SMTPHost: "smtp.gmail.com",
|
||||
SMTPPort: 587,
|
||||
SMTPUsername: "your-email@gmail.com",
|
||||
SMTPPassword: "your-app-password",
|
||||
FromEmail: "your-email@gmail.com",
|
||||
FromName: "Test",
|
||||
UseTLS: true,
|
||||
}
|
||||
|
||||
message := &email.Message{
|
||||
To: []string{"recipient@example.com"},
|
||||
Subject: "Test Email",
|
||||
Body: "This is a test email from Memos email plugin.",
|
||||
}
|
||||
|
||||
if err := email.Send(config, message); err != nil {
|
||||
log.Fatalf("Failed to send email: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Email sent successfully!")
|
||||
}
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### 1. Use TLS/SSL Encryption
|
||||
|
||||
Always enable encryption in production:
|
||||
|
||||
```go
|
||||
// STARTTLS (port 587) - Recommended
|
||||
config.UseTLS = true
|
||||
|
||||
// SSL/TLS (port 465)
|
||||
config.UseSSL = true
|
||||
```
|
||||
|
||||
### 2. Secure Credential Storage
|
||||
|
||||
Never hardcode credentials. Use environment variables:
|
||||
|
||||
```go
|
||||
import "os"
|
||||
|
||||
config := &email.Config{
|
||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||
SMTPPort: 587,
|
||||
SMTPUsername: os.Getenv("SMTP_USERNAME"),
|
||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||
FromEmail: os.Getenv("SMTP_FROM_EMAIL"),
|
||||
UseTLS: true,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Use App-Specific Passwords
|
||||
|
||||
For Gmail and similar services, use app passwords instead of your main account password.
|
||||
|
||||
### 4. Validate and Sanitize Input
|
||||
|
||||
Always validate email addresses and sanitize content:
|
||||
|
||||
```go
|
||||
// Validate before sending
|
||||
if err := message.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Implement Rate Limiting
|
||||
|
||||
Prevent abuse by limiting email sending:
|
||||
|
||||
```go
|
||||
// Example using golang.org/x/time/rate
|
||||
limiter := rate.NewLimiter(rate.Every(time.Second), 10) // 10 emails per second
|
||||
|
||||
if !limiter.Allow() {
|
||||
return errors.New("rate limit exceeded")
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Monitor and Log
|
||||
|
||||
Log email sending activity for security monitoring:
|
||||
|
||||
```go
|
||||
if err := email.Send(config, message); err != nil {
|
||||
slog.Error("Email send failed",
|
||||
slog.String("recipient", message.To[0]),
|
||||
slog.Any("error", err))
|
||||
}
|
||||
```
|
||||
|
||||
## Common Ports
|
||||
|
||||
| Port | Protocol | Security | Use Case |
|
||||
|------|----------|----------|----------|
|
||||
| **587** | SMTP + STARTTLS | Encrypted | **Recommended** for most providers |
|
||||
| **465** | SMTP over SSL/TLS | Encrypted | Alternative secure option |
|
||||
| **25** | SMTP | Unencrypted | Legacy, often blocked by ISPs |
|
||||
| **2525** | SMTP + STARTTLS | Encrypted | Alternative when 587 is blocked |
|
||||
|
||||
**Port 587 (STARTTLS)** is the recommended standard for modern SMTP:
|
||||
```go
|
||||
config := &email.Config{
|
||||
SMTPPort: 587,
|
||||
UseTLS: true,
|
||||
}
|
||||
```
|
||||
|
||||
**Port 465 (SSL/TLS)** is the alternative:
|
||||
```go
|
||||
config := &email.Config{
|
||||
SMTPPort: 465,
|
||||
UseSSL: true,
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The package provides detailed, contextual errors:
|
||||
|
||||
```go
|
||||
err := email.Send(config, message)
|
||||
if err != nil {
|
||||
// Error messages include context:
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "invalid email configuration"):
|
||||
// Configuration error (missing host, invalid port, etc.)
|
||||
log.Printf("Configuration error: %v", err)
|
||||
|
||||
case strings.Contains(err.Error(), "invalid email message"):
|
||||
// Message validation error (missing recipients, subject, body)
|
||||
log.Printf("Message error: %v", err)
|
||||
|
||||
case strings.Contains(err.Error(), "authentication failed"):
|
||||
// SMTP authentication failed (wrong credentials)
|
||||
log.Printf("Auth error: %v", err)
|
||||
|
||||
case strings.Contains(err.Error(), "failed to connect"):
|
||||
// Network/connection error
|
||||
log.Printf("Connection error: %v", err)
|
||||
|
||||
case strings.Contains(err.Error(), "recipient rejected"):
|
||||
// SMTP server rejected recipient
|
||||
log.Printf("Recipient error: %v", err)
|
||||
|
||||
default:
|
||||
log.Printf("Unknown error: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Error Messages
|
||||
|
||||
```
|
||||
❌ "invalid email configuration: SMTP host is required"
|
||||
→ Fix: Set config.SMTPHost
|
||||
|
||||
❌ "invalid email configuration: SMTP port must be between 1 and 65535"
|
||||
→ Fix: Set valid config.SMTPPort (usually 587 or 465)
|
||||
|
||||
❌ "invalid email configuration: from email is required"
|
||||
→ Fix: Set config.FromEmail
|
||||
|
||||
❌ "invalid email message: at least one recipient is required"
|
||||
→ Fix: Set message.To with at least one email address
|
||||
|
||||
❌ "invalid email message: subject is required"
|
||||
→ Fix: Set message.Subject
|
||||
|
||||
❌ "invalid email message: body is required"
|
||||
→ Fix: Set message.Body
|
||||
|
||||
❌ "SMTP authentication failed"
|
||||
→ Fix: Check credentials (username/password)
|
||||
|
||||
❌ "failed to connect to SMTP server"
|
||||
→ Fix: Verify host/port, check firewall, ensure TLS/SSL settings match server
|
||||
```
|
||||
|
||||
### Async Error Handling
|
||||
|
||||
For async sending, errors are logged automatically:
|
||||
|
||||
```go
|
||||
email.SendAsync(config, message)
|
||||
// Errors logged as:
|
||||
// [WARN] Failed to send email asynchronously recipients=user@example.com error=...
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Required
|
||||
|
||||
- **Go 1.25+**
|
||||
- Standard library: `net/smtp`, `crypto/tls`
|
||||
- `github.com/pkg/errors` - Error wrapping with context
|
||||
|
||||
### No External SMTP Libraries
|
||||
|
||||
This plugin uses Go's standard `net/smtp` library for maximum compatibility and minimal dependencies.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Types
|
||||
|
||||
#### `Config`
|
||||
```go
|
||||
type Config struct {
|
||||
SMTPHost string // SMTP server hostname
|
||||
SMTPPort int // SMTP server port
|
||||
SMTPUsername string // SMTP auth username
|
||||
SMTPPassword string // SMTP auth password
|
||||
FromEmail string // From email address
|
||||
FromName string // From display name (optional)
|
||||
UseTLS bool // Enable STARTTLS (port 587)
|
||||
UseSSL bool // Enable SSL/TLS (port 465)
|
||||
}
|
||||
```
|
||||
|
||||
#### `Message`
|
||||
```go
|
||||
type Message struct {
|
||||
To []string // Recipients
|
||||
Cc []string // CC recipients (optional)
|
||||
Bcc []string // BCC recipients (optional)
|
||||
Subject string // Email subject
|
||||
Body string // Email body (plain text or HTML)
|
||||
IsHTML bool // true for HTML, false for plain text
|
||||
ReplyTo string // Reply-To address (optional)
|
||||
}
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
#### `Send(config *Config, message *Message) error`
|
||||
Sends an email synchronously. Blocks until email is sent or error occurs.
|
||||
|
||||
#### `SendAsync(config *Config, message *Message)`
|
||||
Sends an email asynchronously in a goroutine. Returns immediately. Errors are logged.
|
||||
|
||||
#### `NewClient(config *Config) *Client`
|
||||
Creates a new SMTP client for advanced usage.
|
||||
|
||||
#### `Client.Send(message *Message) error`
|
||||
Sends email using the client's configuration.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
plugin/email/
|
||||
├── config.go # SMTP configuration types
|
||||
├── message.go # Email message types and formatting
|
||||
├── client.go # SMTP client implementation
|
||||
├── email.go # High-level Send/SendAsync API
|
||||
├── doc.go # Package documentation
|
||||
└── *_test.go # Unit tests
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Part of the Memos project. See main repository for license details.
|
||||
|
||||
## Contributing
|
||||
|
||||
This plugin follows the Memos contribution guidelines. Please ensure:
|
||||
|
||||
1. All code is tested (TDD approach)
|
||||
2. Tests pass: `go test ./plugin/email/... -v`
|
||||
3. Code is formatted: `go fmt ./plugin/email/...`
|
||||
4. No linting errors: `golangci-lint run ./plugin/email/...`
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
|
||||
- Memos GitHub Issues: https://github.com/usememos/memos/issues
|
||||
- Memos Documentation: https://usememos.com/docs
|
||||
|
||||
## Roadmap
|
||||
|
||||
Future enhancements may include:
|
||||
|
||||
- Email template system
|
||||
- Attachment support
|
||||
- Inline image embedding
|
||||
- Email queuing system
|
||||
- Delivery status tracking
|
||||
- Bounce handling
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/smtp"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Client represents an SMTP email client.
|
||||
type Client struct {
|
||||
config *Config
|
||||
}
|
||||
|
||||
// NewClient creates a new email client with the given configuration.
|
||||
func NewClient(config *Config) *Client {
|
||||
return &Client{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// validateConfig validates the client configuration.
|
||||
func (c *Client) validateConfig() error {
|
||||
if c.config == nil {
|
||||
return errors.New("email configuration is required")
|
||||
}
|
||||
return c.config.Validate()
|
||||
}
|
||||
|
||||
// createAuth creates an SMTP auth mechanism if credentials are provided.
|
||||
func (c *Client) createAuth() smtp.Auth {
|
||||
if c.config.SMTPUsername == "" && c.config.SMTPPassword == "" {
|
||||
return nil
|
||||
}
|
||||
return smtp.PlainAuth("", c.config.SMTPUsername, c.config.SMTPPassword, c.config.SMTPHost)
|
||||
}
|
||||
|
||||
// createTLSConfig creates a TLS configuration for secure connections.
|
||||
func (c *Client) createTLSConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
ServerName: c.config.SMTPHost,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
|
||||
// Send sends an email message via SMTP.
|
||||
func (c *Client) Send(message *Message) error {
|
||||
// Validate configuration
|
||||
if err := c.validateConfig(); err != nil {
|
||||
return errors.Wrap(err, "invalid email configuration")
|
||||
}
|
||||
|
||||
// Validate message
|
||||
if message == nil {
|
||||
return errors.New("message is required")
|
||||
}
|
||||
if err := message.Validate(); err != nil {
|
||||
return errors.Wrap(err, "invalid email message")
|
||||
}
|
||||
|
||||
// Format the message
|
||||
body := message.Format(c.config.FromEmail, c.config.FromName)
|
||||
|
||||
// Get all recipients
|
||||
recipients := message.GetAllRecipients()
|
||||
|
||||
// Create auth
|
||||
auth := c.createAuth()
|
||||
|
||||
// Send based on encryption type
|
||||
if c.config.UseSSL {
|
||||
return c.sendWithSSL(auth, recipients, body)
|
||||
}
|
||||
return c.sendWithTLS(auth, recipients, body)
|
||||
}
|
||||
|
||||
// sendWithTLS sends email using STARTTLS (port 587).
|
||||
func (c *Client) sendWithTLS(auth smtp.Auth, recipients []string, body string) error {
|
||||
serverAddr := c.config.GetServerAddress()
|
||||
|
||||
if c.config.UseTLS {
|
||||
// Use STARTTLS
|
||||
return smtp.SendMail(serverAddr, auth, c.config.FromEmail, recipients, []byte(body))
|
||||
}
|
||||
|
||||
// Send without encryption (not recommended)
|
||||
return smtp.SendMail(serverAddr, auth, c.config.FromEmail, recipients, []byte(body))
|
||||
}
|
||||
|
||||
// sendWithSSL sends email using SSL/TLS (port 465).
|
||||
func (c *Client) sendWithSSL(auth smtp.Auth, recipients []string, body string) error {
|
||||
serverAddr := c.config.GetServerAddress()
|
||||
|
||||
// Create TLS connection
|
||||
tlsConfig := c.createTLSConfig()
|
||||
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to connect to SMTP server with SSL: %s", serverAddr)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Create SMTP client
|
||||
client, err := smtp.NewClient(conn, c.config.SMTPHost)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create SMTP client")
|
||||
}
|
||||
defer client.Quit()
|
||||
|
||||
// Authenticate
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return errors.Wrap(err, "SMTP authentication failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Set sender
|
||||
if err := client.Mail(c.config.FromEmail); err != nil {
|
||||
return errors.Wrap(err, "failed to set sender")
|
||||
}
|
||||
|
||||
// Set recipients
|
||||
for _, recipient := range recipients {
|
||||
if err := client.Rcpt(recipient); err != nil {
|
||||
return errors.Wrapf(err, "failed to set recipient: %s", recipient)
|
||||
}
|
||||
}
|
||||
|
||||
// Send message body
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to send DATA command")
|
||||
}
|
||||
|
||||
if _, err := writer.Write([]byte(body)); err != nil {
|
||||
return errors.Wrap(err, "failed to write message body")
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return errors.Wrap(err, "failed to close message writer")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
package email
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
config := &Config{
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
SMTPUsername: "user@example.com",
|
||||
SMTPPassword: "password",
|
||||
FromEmail: "noreply@example.com",
|
||||
FromName: "Test App",
|
||||
UseTLS: true,
|
||||
}
|
||||
|
||||
client := NewClient(config)
|
||||
|
||||
assert.NotNil(t, client)
|
||||
assert.Equal(t, config, client.config)
|
||||
}
|
||||
|
||||
func TestClientValidateConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
config: &Config{
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
FromEmail: "test@example.com",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nil config",
|
||||
config: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid config",
|
||||
config: &Config{
|
||||
SMTPHost: "",
|
||||
SMTPPort: 587,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := NewClient(tt.config)
|
||||
err := client.validateConfig()
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSendValidation(t *testing.T) {
|
||||
config := &Config{
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
FromEmail: "test@example.com",
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
message *Message
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid message",
|
||||
message: &Message{
|
||||
To: []string{"recipient@example.com"},
|
||||
Subject: "Test",
|
||||
Body: "Test body",
|
||||
},
|
||||
wantErr: false, // Will fail on actual send, but passes validation
|
||||
},
|
||||
{
|
||||
name: "nil message",
|
||||
message: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid message",
|
||||
message: &Message{
|
||||
To: []string{},
|
||||
Subject: "Test",
|
||||
Body: "Test",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := client.Send(tt.message)
|
||||
// We expect validation errors for invalid messages
|
||||
// For valid messages, we'll get connection errors (which is expected in tests)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
// Should fail validation before attempting connection
|
||||
assert.NotContains(t, err.Error(), "dial")
|
||||
}
|
||||
// Note: We don't assert NoError for valid messages because
|
||||
// we don't have a real SMTP server in tests
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Config represents the SMTP configuration for email sending.
|
||||
// These settings should be provided by the self-hosted instance administrator.
|
||||
type Config struct {
|
||||
// SMTPHost is the SMTP server hostname (e.g., "smtp.gmail.com")
|
||||
SMTPHost string
|
||||
// SMTPPort is the SMTP server port (common: 587 for TLS, 465 for SSL, 25 for unencrypted)
|
||||
SMTPPort int
|
||||
// SMTPUsername is the SMTP authentication username (usually the email address)
|
||||
SMTPUsername string
|
||||
// SMTPPassword is the SMTP authentication password or app-specific password
|
||||
SMTPPassword string
|
||||
// FromEmail is the email address that will appear in the "From" field
|
||||
FromEmail string
|
||||
// FromName is the display name that will appear in the "From" field
|
||||
FromName string
|
||||
// UseTLS enables STARTTLS encryption (recommended for port 587)
|
||||
UseTLS bool
|
||||
// UseSSL enables SSL/TLS encryption (for port 465)
|
||||
UseSSL bool
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid.
|
||||
func (c *Config) Validate() error {
|
||||
if c.SMTPHost == "" {
|
||||
return errors.New("SMTP host is required")
|
||||
}
|
||||
if c.SMTPPort <= 0 || c.SMTPPort > 65535 {
|
||||
return errors.New("SMTP port must be between 1 and 65535")
|
||||
}
|
||||
if c.FromEmail == "" {
|
||||
return errors.New("from email is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServerAddress returns the SMTP server address in the format "host:port".
|
||||
func (c *Config) GetServerAddress() string {
|
||||
return fmt.Sprintf("%s:%d", c.SMTPHost, c.SMTPPort)
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package email
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
config: &Config{
|
||||
SMTPHost: "smtp.gmail.com",
|
||||
SMTPPort: 587,
|
||||
SMTPUsername: "user@example.com",
|
||||
SMTPPassword: "password",
|
||||
FromEmail: "noreply@example.com",
|
||||
FromName: "Memos",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing host",
|
||||
config: &Config{
|
||||
SMTPPort: 587,
|
||||
SMTPUsername: "user@example.com",
|
||||
SMTPPassword: "password",
|
||||
FromEmail: "noreply@example.com",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid port",
|
||||
config: &Config{
|
||||
SMTPHost: "smtp.gmail.com",
|
||||
SMTPPort: 0,
|
||||
SMTPUsername: "user@example.com",
|
||||
SMTPPassword: "password",
|
||||
FromEmail: "noreply@example.com",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing from email",
|
||||
config: &Config{
|
||||
SMTPHost: "smtp.gmail.com",
|
||||
SMTPPort: 587,
|
||||
SMTPUsername: "user@example.com",
|
||||
SMTPPassword: "password",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetServerAddress(t *testing.T) {
|
||||
config := &Config{
|
||||
SMTPHost: "smtp.gmail.com",
|
||||
SMTPPort: 587,
|
||||
}
|
||||
|
||||
expected := "smtp.gmail.com:587"
|
||||
assert.Equal(t, expected, config.GetServerAddress())
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
// Package email provides SMTP email sending functionality for self-hosted Memos instances.
|
||||
//
|
||||
// This package is designed for self-hosted environments where instance administrators
|
||||
// configure their own SMTP servers. It follows industry-standard patterns used by
|
||||
// platforms like GitHub, GitLab, and Discourse.
|
||||
//
|
||||
// # Configuration
|
||||
//
|
||||
// The package requires SMTP server configuration provided by the instance administrator:
|
||||
//
|
||||
// config := &email.Config{
|
||||
// SMTPHost: "smtp.gmail.com",
|
||||
// SMTPPort: 587,
|
||||
// SMTPUsername: "your-email@gmail.com",
|
||||
// SMTPPassword: "your-app-password",
|
||||
// FromEmail: "noreply@yourdomain.com",
|
||||
// FromName: "Memos Notifications",
|
||||
// UseTLS: true,
|
||||
// }
|
||||
//
|
||||
// # Common SMTP Settings
|
||||
//
|
||||
// Gmail (requires App Password):
|
||||
// - Host: smtp.gmail.com
|
||||
// - Port: 587 (TLS) or 465 (SSL)
|
||||
// - Username: your-email@gmail.com
|
||||
// - UseTLS: true (for port 587) or UseSSL: true (for port 465)
|
||||
//
|
||||
// SendGrid:
|
||||
// - Host: smtp.sendgrid.net
|
||||
// - Port: 587
|
||||
// - Username: apikey
|
||||
// - Password: your-sendgrid-api-key
|
||||
// - UseTLS: true
|
||||
//
|
||||
// AWS SES:
|
||||
// - Host: email-smtp.[region].amazonaws.com
|
||||
// - Port: 587
|
||||
// - Username: your-smtp-username
|
||||
// - Password: your-smtp-password
|
||||
// - UseTLS: true
|
||||
//
|
||||
// Mailgun:
|
||||
// - Host: smtp.mailgun.org
|
||||
// - Port: 587
|
||||
// - Username: your-mailgun-smtp-username
|
||||
// - Password: your-mailgun-smtp-password
|
||||
// - UseTLS: true
|
||||
//
|
||||
// # Sending Email
|
||||
//
|
||||
// Synchronous (waits for completion):
|
||||
//
|
||||
// message := &email.Message{
|
||||
// To: []string{"user@example.com"},
|
||||
// Subject: "Welcome to Memos",
|
||||
// Body: "Thank you for joining!",
|
||||
// IsHTML: false,
|
||||
// }
|
||||
//
|
||||
// err := email.Send(config, message)
|
||||
// if err != nil {
|
||||
// // Handle error
|
||||
// }
|
||||
//
|
||||
// Asynchronous (returns immediately):
|
||||
//
|
||||
// email.SendAsync(config, message)
|
||||
// // Errors are logged but not returned
|
||||
//
|
||||
// # HTML Email
|
||||
//
|
||||
// message := &email.Message{
|
||||
// To: []string{"user@example.com"},
|
||||
// Subject: "Welcome!",
|
||||
// Body: "<html><body><h1>Welcome to Memos!</h1></body></html>",
|
||||
// IsHTML: true,
|
||||
// }
|
||||
//
|
||||
// # Security Considerations
|
||||
//
|
||||
// - Always use TLS (port 587) or SSL (port 465) for production
|
||||
// - Store SMTP credentials securely (environment variables or secrets management)
|
||||
// - Use app-specific passwords for services like Gmail
|
||||
// - Validate and sanitize email content to prevent injection attacks
|
||||
// - Rate limit email sending to prevent abuse
|
||||
//
|
||||
// # Error Handling
|
||||
//
|
||||
// The package returns descriptive errors for common issues:
|
||||
// - Configuration validation errors (missing host, invalid port, etc.)
|
||||
// - Message validation errors (missing recipients, subject, or body)
|
||||
// - Connection errors (cannot reach SMTP server)
|
||||
// - Authentication errors (invalid credentials)
|
||||
// - SMTP protocol errors (recipient rejected, etc.)
|
||||
//
|
||||
// All errors are wrapped with context using github.com/pkg/errors for better debugging.
|
||||
package email
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package email
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Send sends an email synchronously.
|
||||
// Returns an error if the email fails to send.
|
||||
func Send(config *Config, message *Message) error {
|
||||
if config == nil {
|
||||
return errors.New("email configuration is required")
|
||||
}
|
||||
if message == nil {
|
||||
return errors.New("email message is required")
|
||||
}
|
||||
|
||||
client := NewClient(config)
|
||||
return client.Send(message)
|
||||
}
|
||||
|
||||
// SendAsync sends an email asynchronously.
|
||||
// It spawns a new goroutine to handle the sending and does not wait for the response.
|
||||
// Any errors are logged but not returned.
|
||||
func SendAsync(config *Config, message *Message) {
|
||||
go func() {
|
||||
if err := Send(config, message); err != nil {
|
||||
// Since we're in a goroutine, we can only log the error
|
||||
recipients := ""
|
||||
if message != nil && len(message.To) > 0 {
|
||||
recipients = message.To[0]
|
||||
if len(message.To) > 1 {
|
||||
recipients += " and others"
|
||||
}
|
||||
}
|
||||
|
||||
slog.Warn("Failed to send email asynchronously",
|
||||
slog.String("recipients", recipients),
|
||||
slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
package email
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSend(t *testing.T) {
|
||||
config := &Config{
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
FromEmail: "test@example.com",
|
||||
}
|
||||
|
||||
message := &Message{
|
||||
To: []string{"recipient@example.com"},
|
||||
Subject: "Test",
|
||||
Body: "Test body",
|
||||
}
|
||||
|
||||
// This will fail to connect (no real server), but should validate inputs
|
||||
err := Send(config, message)
|
||||
// We expect an error because there's no real SMTP server
|
||||
// But it should be a connection error, not a validation error
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "dial")
|
||||
}
|
||||
|
||||
func TestSendValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
message *Message
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "nil config",
|
||||
config: nil,
|
||||
message: &Message{To: []string{"test@example.com"}, Subject: "Test", Body: "Test"},
|
||||
wantErr: true,
|
||||
errMsg: "configuration is required",
|
||||
},
|
||||
{
|
||||
name: "nil message",
|
||||
config: &Config{SMTPHost: "smtp.example.com", SMTPPort: 587, FromEmail: "from@example.com"},
|
||||
message: nil,
|
||||
wantErr: true,
|
||||
errMsg: "message is required",
|
||||
},
|
||||
{
|
||||
name: "invalid config",
|
||||
config: &Config{
|
||||
SMTPHost: "",
|
||||
SMTPPort: 587,
|
||||
},
|
||||
message: &Message{To: []string{"test@example.com"}, Subject: "Test", Body: "Test"},
|
||||
wantErr: true,
|
||||
errMsg: "invalid email configuration",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Send(tt.config, tt.message)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendAsync(t *testing.T) {
|
||||
config := &Config{
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
FromEmail: "test@example.com",
|
||||
}
|
||||
|
||||
message := &Message{
|
||||
To: []string{"recipient@example.com"},
|
||||
Subject: "Test Async",
|
||||
Body: "Test async body",
|
||||
}
|
||||
|
||||
// SendAsync should not block
|
||||
start := time.Now()
|
||||
SendAsync(config, message)
|
||||
duration := time.Since(start)
|
||||
|
||||
// Should return almost immediately (< 100ms)
|
||||
assert.Less(t, duration, 100*time.Millisecond)
|
||||
|
||||
// Give goroutine time to start
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestSendAsyncConcurrent(t *testing.T) {
|
||||
config := &Config{
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
FromEmail: "test@example.com",
|
||||
}
|
||||
|
||||
// Send multiple emails concurrently
|
||||
var wg sync.WaitGroup
|
||||
count := 5
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
message := &Message{
|
||||
To: []string{"recipient@example.com"},
|
||||
Subject: "Concurrent Test",
|
||||
Body: "Test body",
|
||||
}
|
||||
SendAsync(config, message)
|
||||
}()
|
||||
}
|
||||
|
||||
// Should complete without deadlock
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
wg.Wait()
|
||||
done <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Success
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("SendAsync calls did not complete in time")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package email
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message represents an email message to be sent.
|
||||
type Message struct {
|
||||
To []string // Required: recipient email addresses
|
||||
Cc []string // Optional: carbon copy recipients
|
||||
Bcc []string // Optional: blind carbon copy recipients
|
||||
Subject string // Required: email subject
|
||||
Body string // Required: email body content
|
||||
IsHTML bool // Whether the body is HTML (default: false for plain text)
|
||||
ReplyTo string // Optional: reply-to address
|
||||
}
|
||||
|
||||
// Validate checks that the message has all required fields.
|
||||
func (m *Message) Validate() error {
|
||||
if len(m.To) == 0 {
|
||||
return errors.New("at least one recipient is required")
|
||||
}
|
||||
if m.Subject == "" {
|
||||
return errors.New("subject is required")
|
||||
}
|
||||
if m.Body == "" {
|
||||
return errors.New("body is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Format creates an RFC 5322 formatted email message.
|
||||
func (m *Message) Format(fromEmail, fromName string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// From header
|
||||
if fromName != "" {
|
||||
sb.WriteString(fmt.Sprintf("From: %s <%s>\r\n", fromName, fromEmail))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("From: %s\r\n", fromEmail))
|
||||
}
|
||||
|
||||
// To header
|
||||
sb.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(m.To, ", ")))
|
||||
|
||||
// Cc header (optional)
|
||||
if len(m.Cc) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Cc: %s\r\n", strings.Join(m.Cc, ", ")))
|
||||
}
|
||||
|
||||
// Reply-To header (optional)
|
||||
if m.ReplyTo != "" {
|
||||
sb.WriteString(fmt.Sprintf("Reply-To: %s\r\n", m.ReplyTo))
|
||||
}
|
||||
|
||||
// Subject header
|
||||
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", m.Subject))
|
||||
|
||||
// Date header (RFC 5322 format)
|
||||
sb.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().Format(time.RFC1123Z)))
|
||||
|
||||
// MIME headers
|
||||
sb.WriteString("MIME-Version: 1.0\r\n")
|
||||
|
||||
// Content-Type header
|
||||
if m.IsHTML {
|
||||
sb.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
||||
} else {
|
||||
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
}
|
||||
|
||||
// Empty line separating headers from body
|
||||
sb.WriteString("\r\n")
|
||||
|
||||
// Body
|
||||
sb.WriteString(m.Body)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GetAllRecipients returns all recipients (To, Cc, Bcc) as a single slice.
|
||||
func (m *Message) GetAllRecipients() []string {
|
||||
var recipients []string
|
||||
recipients = append(recipients, m.To...)
|
||||
recipients = append(recipients, m.Cc...)
|
||||
recipients = append(recipients, m.Bcc...)
|
||||
return recipients
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
package email
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMessageValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg Message
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid message",
|
||||
msg: Message{
|
||||
To: []string{"user@example.com"},
|
||||
Subject: "Test Subject",
|
||||
Body: "Test Body",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no recipients",
|
||||
msg: Message{
|
||||
To: []string{},
|
||||
Subject: "Test Subject",
|
||||
Body: "Test Body",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no subject",
|
||||
msg: Message{
|
||||
To: []string{"user@example.com"},
|
||||
Subject: "",
|
||||
Body: "Test Body",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no body",
|
||||
msg: Message{
|
||||
To: []string{"user@example.com"},
|
||||
Subject: "Test Subject",
|
||||
Body: "",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "multiple recipients",
|
||||
msg: Message{
|
||||
To: []string{"user1@example.com", "user2@example.com"},
|
||||
Cc: []string{"cc@example.com"},
|
||||
Subject: "Test Subject",
|
||||
Body: "Test Body",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.msg.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageFormatPlainText(t *testing.T) {
|
||||
msg := Message{
|
||||
To: []string{"user@example.com"},
|
||||
Subject: "Test Subject",
|
||||
Body: "Test Body",
|
||||
IsHTML: false,
|
||||
}
|
||||
|
||||
formatted := msg.Format("sender@example.com", "Sender Name")
|
||||
|
||||
// Check required headers
|
||||
if !strings.Contains(formatted, "From: Sender Name <sender@example.com>") {
|
||||
t.Error("Missing or incorrect From header")
|
||||
}
|
||||
if !strings.Contains(formatted, "To: user@example.com") {
|
||||
t.Error("Missing or incorrect To header")
|
||||
}
|
||||
if !strings.Contains(formatted, "Subject: Test Subject") {
|
||||
t.Error("Missing or incorrect Subject header")
|
||||
}
|
||||
if !strings.Contains(formatted, "Content-Type: text/plain; charset=utf-8") {
|
||||
t.Error("Missing or incorrect Content-Type header for plain text")
|
||||
}
|
||||
if !strings.Contains(formatted, "Test Body") {
|
||||
t.Error("Missing message body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageFormatHTML(t *testing.T) {
|
||||
msg := Message{
|
||||
To: []string{"user@example.com"},
|
||||
Subject: "Test Subject",
|
||||
Body: "<html><body>Test Body</body></html>",
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
formatted := msg.Format("sender@example.com", "Sender Name")
|
||||
|
||||
// Check HTML content-type
|
||||
if !strings.Contains(formatted, "Content-Type: text/html; charset=utf-8") {
|
||||
t.Error("Missing or incorrect Content-Type header for HTML")
|
||||
}
|
||||
if !strings.Contains(formatted, "<html><body>Test Body</body></html>") {
|
||||
t.Error("Missing HTML body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageFormatMultipleRecipients(t *testing.T) {
|
||||
msg := Message{
|
||||
To: []string{"user1@example.com", "user2@example.com"},
|
||||
Cc: []string{"cc1@example.com", "cc2@example.com"},
|
||||
Bcc: []string{"bcc@example.com"},
|
||||
Subject: "Test Subject",
|
||||
Body: "Test Body",
|
||||
ReplyTo: "reply@example.com",
|
||||
}
|
||||
|
||||
formatted := msg.Format("sender@example.com", "Sender Name")
|
||||
|
||||
// Check To header formatting
|
||||
if !strings.Contains(formatted, "To: user1@example.com, user2@example.com") {
|
||||
t.Error("Missing or incorrect To header with multiple recipients")
|
||||
}
|
||||
// Check Cc header formatting
|
||||
if !strings.Contains(formatted, "Cc: cc1@example.com, cc2@example.com") {
|
||||
t.Error("Missing or incorrect Cc header")
|
||||
}
|
||||
// Bcc should NOT appear in the formatted message
|
||||
if strings.Contains(formatted, "Bcc:") {
|
||||
t.Error("Bcc header should not appear in formatted message")
|
||||
}
|
||||
// Check Reply-To header
|
||||
if !strings.Contains(formatted, "Reply-To: reply@example.com") {
|
||||
t.Error("Missing or incorrect Reply-To header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllRecipients(t *testing.T) {
|
||||
msg := Message{
|
||||
To: []string{"user1@example.com", "user2@example.com"},
|
||||
Cc: []string{"cc@example.com"},
|
||||
Bcc: []string{"bcc@example.com"},
|
||||
}
|
||||
|
||||
recipients := msg.GetAllRecipients()
|
||||
|
||||
// Should have all 4 recipients
|
||||
if len(recipients) != 4 {
|
||||
t.Errorf("GetAllRecipients() returned %d recipients, want 4", len(recipients))
|
||||
}
|
||||
|
||||
// Check all recipients are present
|
||||
expectedRecipients := map[string]bool{
|
||||
"user1@example.com": true,
|
||||
"user2@example.com": true,
|
||||
"cc@example.com": true,
|
||||
"bcc@example.com": true,
|
||||
}
|
||||
|
||||
for _, recipient := range recipients {
|
||||
if !expectedRecipients[recipient] {
|
||||
t.Errorf("Unexpected recipient: %s", recipient)
|
||||
}
|
||||
delete(expectedRecipients, recipient)
|
||||
}
|
||||
|
||||
if len(expectedRecipients) > 0 {
|
||||
t.Error("Not all expected recipients were returned")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue