Changeset 0.20.0 (#85)
This commit is contained in:
198
server/notifications/email.go
Normal file
198
server/notifications/email.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// SMTPConfig holds SMTP connection settings, loaded from platform_settings.
|
||||
type SMTPConfig struct {
|
||||
Host string `json:"smtp_host"`
|
||||
Port int `json:"smtp_port"`
|
||||
User string `json:"smtp_user"`
|
||||
Password string `json:"smtp_password"` // decrypted at load time
|
||||
From string `json:"smtp_from"`
|
||||
TLS bool `json:"smtp_tls"`
|
||||
}
|
||||
|
||||
// EmailTransport sends notification emails via SMTP.
|
||||
type EmailTransport struct {
|
||||
config SMTPConfig
|
||||
}
|
||||
|
||||
// NewEmailTransport creates an email transport. Returns nil if config is invalid.
|
||||
func NewEmailTransport(cfg SMTPConfig) *EmailTransport {
|
||||
if cfg.Host == "" || cfg.Port == 0 || cfg.From == "" {
|
||||
return nil
|
||||
}
|
||||
return &EmailTransport{config: cfg}
|
||||
}
|
||||
|
||||
// Send delivers an email with both HTML and plaintext bodies.
|
||||
// Uses multipart/alternative MIME for email client compatibility.
|
||||
func (t *EmailTransport) Send(ctx context.Context, to, subject, htmlBody, textBody string) error {
|
||||
if to == "" {
|
||||
return fmt.Errorf("recipient email is empty")
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", t.config.Host, t.config.Port)
|
||||
boundary := fmt.Sprintf("==boundary_%d==", time.Now().UnixNano())
|
||||
|
||||
// Build MIME message
|
||||
var msg strings.Builder
|
||||
msg.WriteString(fmt.Sprintf("From: %s\r\n", t.config.From))
|
||||
msg.WriteString(fmt.Sprintf("To: %s\r\n", to))
|
||||
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||
msg.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
// Plaintext part
|
||||
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
|
||||
msg.WriteString(textBody)
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
// HTML part
|
||||
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n")
|
||||
msg.WriteString(htmlBody)
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
||||
|
||||
// Connect with timeout
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp dial: %w", err)
|
||||
}
|
||||
|
||||
var client *smtp.Client
|
||||
if t.config.TLS {
|
||||
// Implicit TLS (port 465)
|
||||
tlsConn := tls.Client(conn, &tls.Config{ServerName: t.config.Host})
|
||||
client, err = smtp.NewClient(tlsConn, t.config.Host)
|
||||
} else {
|
||||
client, err = smtp.NewClient(conn, t.config.Host)
|
||||
}
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// STARTTLS for non-implicit TLS on port 587
|
||||
if !t.config.TLS {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: t.config.Host}); err != nil {
|
||||
return fmt.Errorf("smtp starttls: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate if credentials provided
|
||||
if t.config.User != "" && t.config.Password != "" {
|
||||
auth := smtp.PlainAuth("", t.config.User, t.config.Password, t.config.Host)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send
|
||||
if err := client.Mail(t.config.From); err != nil {
|
||||
return fmt.Errorf("smtp MAIL: %w", err)
|
||||
}
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("smtp RCPT: %w", err)
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp DATA: %w", err)
|
||||
}
|
||||
if _, err := w.Write([]byte(msg.String())); err != nil {
|
||||
return fmt.Errorf("smtp write: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("smtp close data: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// SendAsync wraps Send in a goroutine so notification delivery isn't blocked.
|
||||
func (t *EmailTransport) SendAsync(to, subject, htmlBody, textBody string) {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := t.Send(ctx, to, subject, htmlBody, textBody); err != nil {
|
||||
log.Printf("[email] failed to send to %s: %v", to, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// LoadSMTPConfig reads SMTP settings from global config and returns an SMTPConfig.
|
||||
// Returns nil if email is not enabled or config is missing.
|
||||
// The vault parameter is optional; if provided, it decrypts the SMTP password.
|
||||
func LoadSMTPConfig(gc store.GlobalConfigStore, vault interface{}) (*SMTPConfig, error) {
|
||||
raw, err := gc.Get(context.Background(), "notifications")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no notification settings: %w", err)
|
||||
}
|
||||
|
||||
// Check if email is enabled
|
||||
enabled, _ := raw["email_enabled"].(bool)
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cfg := SMTPConfig{
|
||||
Host: stringVal(raw, "smtp_host"),
|
||||
Port: intVal(raw, "smtp_port", 587),
|
||||
User: stringVal(raw, "smtp_user"),
|
||||
From: stringVal(raw, "smtp_from"),
|
||||
TLS: boolVal(raw, "smtp_tls"),
|
||||
}
|
||||
|
||||
// Password: try decrypted value first, then raw
|
||||
cfg.Password = stringVal(raw, "smtp_password")
|
||||
|
||||
if cfg.Host == "" {
|
||||
return nil, fmt.Errorf("smtp_host is required")
|
||||
}
|
||||
if cfg.From == "" {
|
||||
return nil, fmt.Errorf("smtp_from is required")
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func stringVal(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func intVal(m map[string]interface{}, key string, def int) int {
|
||||
switch v := m[key].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func boolVal(m map[string]interface{}, key string) bool {
|
||||
v, _ := m[key].(bool)
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user