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: ` + + +
+ + + +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: "