Skip to content

Commit b55a031

Browse files
committed
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.
1 parent 319a7ca commit b55a031

File tree

10 files changed

+1450
-0
lines changed

10 files changed

+1450
-0
lines changed

plugin/email/README.md

Lines changed: 507 additions & 0 deletions
Large diffs are not rendered by default.

plugin/email/client.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package email
2+
3+
import (
4+
"crypto/tls"
5+
"net/smtp"
6+
7+
"github.com/pkg/errors"
8+
)
9+
10+
// Client represents an SMTP email client.
11+
type Client struct {
12+
config *Config
13+
}
14+
15+
// NewClient creates a new email client with the given configuration.
16+
func NewClient(config *Config) *Client {
17+
return &Client{
18+
config: config,
19+
}
20+
}
21+
22+
// validateConfig validates the client configuration.
23+
func (c *Client) validateConfig() error {
24+
if c.config == nil {
25+
return errors.New("email configuration is required")
26+
}
27+
return c.config.Validate()
28+
}
29+
30+
// createAuth creates an SMTP auth mechanism if credentials are provided.
31+
func (c *Client) createAuth() smtp.Auth {
32+
if c.config.SMTPUsername == "" && c.config.SMTPPassword == "" {
33+
return nil
34+
}
35+
return smtp.PlainAuth("", c.config.SMTPUsername, c.config.SMTPPassword, c.config.SMTPHost)
36+
}
37+
38+
// createTLSConfig creates a TLS configuration for secure connections.
39+
func (c *Client) createTLSConfig() *tls.Config {
40+
return &tls.Config{
41+
ServerName: c.config.SMTPHost,
42+
MinVersion: tls.VersionTLS12,
43+
}
44+
}
45+
46+
// Send sends an email message via SMTP.
47+
func (c *Client) Send(message *Message) error {
48+
// Validate configuration
49+
if err := c.validateConfig(); err != nil {
50+
return errors.Wrap(err, "invalid email configuration")
51+
}
52+
53+
// Validate message
54+
if message == nil {
55+
return errors.New("message is required")
56+
}
57+
if err := message.Validate(); err != nil {
58+
return errors.Wrap(err, "invalid email message")
59+
}
60+
61+
// Format the message
62+
body := message.Format(c.config.FromEmail, c.config.FromName)
63+
64+
// Get all recipients
65+
recipients := message.GetAllRecipients()
66+
67+
// Create auth
68+
auth := c.createAuth()
69+
70+
// Send based on encryption type
71+
if c.config.UseSSL {
72+
return c.sendWithSSL(auth, recipients, body)
73+
}
74+
return c.sendWithTLS(auth, recipients, body)
75+
}
76+
77+
// sendWithTLS sends email using STARTTLS (port 587).
78+
func (c *Client) sendWithTLS(auth smtp.Auth, recipients []string, body string) error {
79+
serverAddr := c.config.GetServerAddress()
80+
81+
if c.config.UseTLS {
82+
// Use STARTTLS
83+
return smtp.SendMail(serverAddr, auth, c.config.FromEmail, recipients, []byte(body))
84+
}
85+
86+
// Send without encryption (not recommended)
87+
return smtp.SendMail(serverAddr, auth, c.config.FromEmail, recipients, []byte(body))
88+
}
89+
90+
// sendWithSSL sends email using SSL/TLS (port 465).
91+
func (c *Client) sendWithSSL(auth smtp.Auth, recipients []string, body string) error {
92+
serverAddr := c.config.GetServerAddress()
93+
94+
// Create TLS connection
95+
tlsConfig := c.createTLSConfig()
96+
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
97+
if err != nil {
98+
return errors.Wrapf(err, "failed to connect to SMTP server with SSL: %s", serverAddr)
99+
}
100+
defer conn.Close()
101+
102+
// Create SMTP client
103+
client, err := smtp.NewClient(conn, c.config.SMTPHost)
104+
if err != nil {
105+
return errors.Wrap(err, "failed to create SMTP client")
106+
}
107+
defer client.Quit()
108+
109+
// Authenticate
110+
if auth != nil {
111+
if err := client.Auth(auth); err != nil {
112+
return errors.Wrap(err, "SMTP authentication failed")
113+
}
114+
}
115+
116+
// Set sender
117+
if err := client.Mail(c.config.FromEmail); err != nil {
118+
return errors.Wrap(err, "failed to set sender")
119+
}
120+
121+
// Set recipients
122+
for _, recipient := range recipients {
123+
if err := client.Rcpt(recipient); err != nil {
124+
return errors.Wrapf(err, "failed to set recipient: %s", recipient)
125+
}
126+
}
127+
128+
// Send message body
129+
writer, err := client.Data()
130+
if err != nil {
131+
return errors.Wrap(err, "failed to send DATA command")
132+
}
133+
134+
if _, err := writer.Write([]byte(body)); err != nil {
135+
return errors.Wrap(err, "failed to write message body")
136+
}
137+
138+
if err := writer.Close(); err != nil {
139+
return errors.Wrap(err, "failed to close message writer")
140+
}
141+
142+
return nil
143+
}

plugin/email/client_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package email
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestNewClient(t *testing.T) {
10+
config := &Config{
11+
SMTPHost: "smtp.example.com",
12+
SMTPPort: 587,
13+
SMTPUsername: "user@example.com",
14+
SMTPPassword: "password",
15+
FromEmail: "noreply@example.com",
16+
FromName: "Test App",
17+
UseTLS: true,
18+
}
19+
20+
client := NewClient(config)
21+
22+
assert.NotNil(t, client)
23+
assert.Equal(t, config, client.config)
24+
}
25+
26+
func TestClientValidateConfig(t *testing.T) {
27+
tests := []struct {
28+
name string
29+
config *Config
30+
wantErr bool
31+
}{
32+
{
33+
name: "valid config",
34+
config: &Config{
35+
SMTPHost: "smtp.example.com",
36+
SMTPPort: 587,
37+
FromEmail: "test@example.com",
38+
},
39+
wantErr: false,
40+
},
41+
{
42+
name: "nil config",
43+
config: nil,
44+
wantErr: true,
45+
},
46+
{
47+
name: "invalid config",
48+
config: &Config{
49+
SMTPHost: "",
50+
SMTPPort: 587,
51+
},
52+
wantErr: true,
53+
},
54+
}
55+
56+
for _, tt := range tests {
57+
t.Run(tt.name, func(t *testing.T) {
58+
client := NewClient(tt.config)
59+
err := client.validateConfig()
60+
if tt.wantErr {
61+
assert.Error(t, err)
62+
} else {
63+
assert.NoError(t, err)
64+
}
65+
})
66+
}
67+
}
68+
69+
func TestClientSendValidation(t *testing.T) {
70+
config := &Config{
71+
SMTPHost: "smtp.example.com",
72+
SMTPPort: 587,
73+
FromEmail: "test@example.com",
74+
}
75+
client := NewClient(config)
76+
77+
tests := []struct {
78+
name string
79+
message *Message
80+
wantErr bool
81+
}{
82+
{
83+
name: "valid message",
84+
message: &Message{
85+
To: []string{"recipient@example.com"},
86+
Subject: "Test",
87+
Body: "Test body",
88+
},
89+
wantErr: false, // Will fail on actual send, but passes validation
90+
},
91+
{
92+
name: "nil message",
93+
message: nil,
94+
wantErr: true,
95+
},
96+
{
97+
name: "invalid message",
98+
message: &Message{
99+
To: []string{},
100+
Subject: "Test",
101+
Body: "Test",
102+
},
103+
wantErr: true,
104+
},
105+
}
106+
107+
for _, tt := range tests {
108+
t.Run(tt.name, func(t *testing.T) {
109+
err := client.Send(tt.message)
110+
// We expect validation errors for invalid messages
111+
// For valid messages, we'll get connection errors (which is expected in tests)
112+
if tt.wantErr {
113+
assert.Error(t, err)
114+
// Should fail validation before attempting connection
115+
assert.NotContains(t, err.Error(), "dial")
116+
}
117+
// Note: We don't assert NoError for valid messages because
118+
// we don't have a real SMTP server in tests
119+
})
120+
}
121+
}

plugin/email/config.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package email
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/pkg/errors"
7+
)
8+
9+
// Config represents the SMTP configuration for email sending.
10+
// These settings should be provided by the self-hosted instance administrator.
11+
type Config struct {
12+
// SMTPHost is the SMTP server hostname (e.g., "smtp.gmail.com")
13+
SMTPHost string
14+
// SMTPPort is the SMTP server port (common: 587 for TLS, 465 for SSL, 25 for unencrypted)
15+
SMTPPort int
16+
// SMTPUsername is the SMTP authentication username (usually the email address)
17+
SMTPUsername string
18+
// SMTPPassword is the SMTP authentication password or app-specific password
19+
SMTPPassword string
20+
// FromEmail is the email address that will appear in the "From" field
21+
FromEmail string
22+
// FromName is the display name that will appear in the "From" field
23+
FromName string
24+
// UseTLS enables STARTTLS encryption (recommended for port 587)
25+
UseTLS bool
26+
// UseSSL enables SSL/TLS encryption (for port 465)
27+
UseSSL bool
28+
}
29+
30+
// Validate checks if the configuration is valid.
31+
func (c *Config) Validate() error {
32+
if c.SMTPHost == "" {
33+
return errors.New("SMTP host is required")
34+
}
35+
if c.SMTPPort <= 0 || c.SMTPPort > 65535 {
36+
return errors.New("SMTP port must be between 1 and 65535")
37+
}
38+
if c.FromEmail == "" {
39+
return errors.New("from email is required")
40+
}
41+
return nil
42+
}
43+
44+
// GetServerAddress returns the SMTP server address in the format "host:port".
45+
func (c *Config) GetServerAddress() string {
46+
return fmt.Sprintf("%s:%d", c.SMTPHost, c.SMTPPort)
47+
}

plugin/email/config_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package email
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestConfigValidation(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
config *Config
13+
wantErr bool
14+
}{
15+
{
16+
name: "valid config",
17+
config: &Config{
18+
SMTPHost: "smtp.gmail.com",
19+
SMTPPort: 587,
20+
SMTPUsername: "user@example.com",
21+
SMTPPassword: "password",
22+
FromEmail: "noreply@example.com",
23+
FromName: "Memos",
24+
},
25+
wantErr: false,
26+
},
27+
{
28+
name: "missing host",
29+
config: &Config{
30+
SMTPPort: 587,
31+
SMTPUsername: "user@example.com",
32+
SMTPPassword: "password",
33+
FromEmail: "noreply@example.com",
34+
},
35+
wantErr: true,
36+
},
37+
{
38+
name: "invalid port",
39+
config: &Config{
40+
SMTPHost: "smtp.gmail.com",
41+
SMTPPort: 0,
42+
SMTPUsername: "user@example.com",
43+
SMTPPassword: "password",
44+
FromEmail: "noreply@example.com",
45+
},
46+
wantErr: true,
47+
},
48+
{
49+
name: "missing from email",
50+
config: &Config{
51+
SMTPHost: "smtp.gmail.com",
52+
SMTPPort: 587,
53+
SMTPUsername: "user@example.com",
54+
SMTPPassword: "password",
55+
},
56+
wantErr: true,
57+
},
58+
}
59+
60+
for _, tt := range tests {
61+
t.Run(tt.name, func(t *testing.T) {
62+
err := tt.config.Validate()
63+
if tt.wantErr {
64+
assert.Error(t, err)
65+
} else {
66+
assert.NoError(t, err)
67+
}
68+
})
69+
}
70+
}
71+
72+
func TestConfigGetServerAddress(t *testing.T) {
73+
config := &Config{
74+
SMTPHost: "smtp.gmail.com",
75+
SMTPPort: 587,
76+
}
77+
78+
expected := "smtp.gmail.com:587"
79+
assert.Equal(t, expected, config.GetServerAddress())
80+
}

0 commit comments

Comments
 (0)