This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/notifications/email.go
Jeffrey Smith 11fd8c1e57 step 1: rename module chat-switchboard → switchboard-core
- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
2026-03-25 19:48:04 -04:00

199 lines
5.4 KiB
Go

package notifications
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/smtp"
"strings"
"time"
"switchboard-core/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
}