From b55a0314f815497828eb8070ecb727f7d3fa84a6 Mon Sep 17 00:00:00 2001 From: Johnny Date: Sat, 20 Dec 2025 14:23:15 +0800 Subject: [PATCH] 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. --- plugin/email/README.md | 507 +++++++++++++++++++++++++++++++++++ plugin/email/client.go | 143 ++++++++++ plugin/email/client_test.go | 121 +++++++++ plugin/email/config.go | 47 ++++ plugin/email/config_test.go | 80 ++++++ plugin/email/doc.go | 98 +++++++ plugin/email/email.go | 43 +++ plugin/email/email_test.go | 139 ++++++++++ plugin/email/message.go | 91 +++++++ plugin/email/message_test.go | 181 +++++++++++++ 10 files changed, 1450 insertions(+) create mode 100644 plugin/email/README.md create mode 100644 plugin/email/client.go create mode 100644 plugin/email/client_test.go create mode 100644 plugin/email/config.go create mode 100644 plugin/email/config_test.go create mode 100644 plugin/email/doc.go create mode 100644 plugin/email/email.go create mode 100644 plugin/email/email_test.go create mode 100644 plugin/email/message.go create mode 100644 plugin/email/message_test.go diff --git a/plugin/email/README.md b/plugin/email/README.md new file mode 100644 index 000000000..d64a89f15 --- /dev/null +++ b/plugin/email/README.md @@ -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: ` + + + + + + +

Welcome to Memos!

+

We're excited to have you on board.

+ Get Started + + + `, + 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 diff --git a/plugin/email/client.go b/plugin/email/client.go new file mode 100644 index 000000000..6f44237c9 --- /dev/null +++ b/plugin/email/client.go @@ -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 +} diff --git a/plugin/email/client_test.go b/plugin/email/client_test.go new file mode 100644 index 000000000..a1c3d09c6 --- /dev/null +++ b/plugin/email/client_test.go @@ -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 + }) + } +} diff --git a/plugin/email/config.go b/plugin/email/config.go new file mode 100644 index 000000000..01cb66bf7 --- /dev/null +++ b/plugin/email/config.go @@ -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) +} diff --git a/plugin/email/config_test.go b/plugin/email/config_test.go new file mode 100644 index 000000000..2def23abc --- /dev/null +++ b/plugin/email/config_test.go @@ -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()) +} diff --git a/plugin/email/doc.go b/plugin/email/doc.go new file mode 100644 index 000000000..9e96ed2e6 --- /dev/null +++ b/plugin/email/doc.go @@ -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: "

Welcome to Memos!

", +// 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 diff --git a/plugin/email/email.go b/plugin/email/email.go new file mode 100644 index 000000000..38284f1eb --- /dev/null +++ b/plugin/email/email.go @@ -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)) + } + }() +} diff --git a/plugin/email/email_test.go b/plugin/email/email_test.go new file mode 100644 index 000000000..7927512ec --- /dev/null +++ b/plugin/email/email_test.go @@ -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") + } +} diff --git a/plugin/email/message.go b/plugin/email/message.go new file mode 100644 index 000000000..8d97923e0 --- /dev/null +++ b/plugin/email/message.go @@ -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 +} diff --git a/plugin/email/message_test.go b/plugin/email/message_test.go new file mode 100644 index 000000000..7641b0655 --- /dev/null +++ b/plugin/email/message_test.go @@ -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 ") { + 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: "Test Body", + 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, "Test Body") { + 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") + } +}