- 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)
199 lines
5.4 KiB
Go
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
|
|
}
|