Commit b55a0314 authored by Johnny's avatar Johnny

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.
parent 319a7cac
This diff is collapsed.
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
}
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
})
}
}
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)
}
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())
}
// 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
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))
}
}()
}
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")
}
}
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
}
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")
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment