SOGOMS v1.0.1 - Microservices logs, smtp et roadmap

Nouveaux services:
- sogoms-logs : logging centralisé avec rotation
- sogoms-smtp : envoi emails avec templates YAML

Nouvelles fonctionnalités:
- Queries YAML externalisées (config/queries/{app}/)
- CRUD générique paramétrable
- Filtres par rôle (default, admin)
- Templates email (config/emails/{app}/)

Documentation:
- DOCTECH.md : documentation technique complète
- README.md : vision et roadmap
- TODO.md : phases 11-15 planifiées

Roadmap:
- Phase 11: sogoms-crypt (chiffrement)
- Phase 12: sogoms-imap/mailproc (emails)
- Phase 13: sogoms-cron (tâches planifiées)
- Phase 14: sogoms-push (MQTT temps réel)
- Phase 15: sogoms-schema (API auto-générée)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-16 14:58:46 +01:00
parent 7e27f87d6f
commit a4694a10d1
36 changed files with 2786 additions and 2387 deletions

View File

@@ -14,6 +14,7 @@ import (
"strings"
"sync"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
@@ -24,8 +25,29 @@ import (
var (
socketPath = flag.String("socket", "/run/sogoms-db.1.sock", "Unix socket path")
configDir = flag.String("config", "/config", "Configuration directory")
logsSocket = flag.String("logs-socket", "/run/sogoms-logs.1.sock", "sogoms-logs socket path")
)
var logsPool *protocol.Pool
// logError envoie une erreur à sogoms-logs (fire and forget).
func logError(appID, level, message string, ctx map[string]any) {
if logsPool == nil {
return
}
go func() {
req := protocol.NewRequest("log_error", map[string]any{
"app_id": appID,
"level": level,
"message": message,
"context": ctx,
})
reqCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
logsPool.Call(reqCtx, req)
}()
}
// DBPool gère les connexions DB par application.
type DBPool struct {
registry *config.Registry
@@ -108,6 +130,10 @@ func main() {
dbPool := NewDBPool(registry)
defer dbPool.Close()
// Pool de connexions vers sogoms-logs
logsPool = protocol.NewPool(*logsSocket, 3)
defer logsPool.Close()
// Handler des requêtes
handler := func(ctx context.Context, req *protocol.Request) *protocol.Response {
return handleRequest(ctx, req, dbPool)
@@ -144,15 +170,15 @@ func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *
switch req.Action {
case "query":
return handleQuery(req, db)
return handleQuery(req, db, appID)
case "query_one":
return handleQueryOne(req, db)
return handleQueryOne(req, db, appID)
case "insert":
return handleInsert(req, db)
return handleInsert(req, db, appID)
case "update":
return handleUpdate(req, db)
return handleUpdate(req, db, appID)
case "delete":
return handleDelete(req, db)
return handleDelete(req, db, appID)
case "health":
return handleHealth(req, db)
default:
@@ -161,7 +187,7 @@ func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *
}
// handleQuery exécute un SELECT et retourne plusieurs lignes.
func handleQuery(req *protocol.Request, db *sql.DB) *protocol.Response {
func handleQuery(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
query, args, err := extractQueryParams(req.Params)
if err != nil {
return protocol.Failure(req.ID, "INVALID_PARAMS", err.Error())
@@ -169,12 +195,14 @@ func handleQuery(req *protocol.Request, db *sql.DB) *protocol.Response {
rows, err := db.Query(query, args...)
if err != nil {
logError(appID, "error", "query_failed", map[string]any{"query": query, "error": err.Error()})
return protocol.Failure(req.ID, "QUERY_ERROR", err.Error())
}
defer rows.Close()
results, err := scanRows(rows)
if err != nil {
logError(appID, "error", "scan_failed", map[string]any{"query": query, "error": err.Error()})
return protocol.Failure(req.ID, "SCAN_ERROR", err.Error())
}
@@ -182,7 +210,7 @@ func handleQuery(req *protocol.Request, db *sql.DB) *protocol.Response {
}
// handleQueryOne exécute un SELECT et retourne une seule ligne.
func handleQueryOne(req *protocol.Request, db *sql.DB) *protocol.Response {
func handleQueryOne(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
query, args, err := extractQueryParams(req.Params)
if err != nil {
return protocol.Failure(req.ID, "INVALID_PARAMS", err.Error())
@@ -190,12 +218,14 @@ func handleQueryOne(req *protocol.Request, db *sql.DB) *protocol.Response {
rows, err := db.Query(query, args...)
if err != nil {
logError(appID, "error", "query_failed", map[string]any{"query": query, "error": err.Error()})
return protocol.Failure(req.ID, "QUERY_ERROR", err.Error())
}
defer rows.Close()
results, err := scanRows(rows)
if err != nil {
logError(appID, "error", "scan_failed", map[string]any{"query": query, "error": err.Error()})
return protocol.Failure(req.ID, "SCAN_ERROR", err.Error())
}
@@ -207,7 +237,7 @@ func handleQueryOne(req *protocol.Request, db *sql.DB) *protocol.Response {
}
// handleInsert exécute un INSERT et retourne l'ID inséré.
func handleInsert(req *protocol.Request, db *sql.DB) *protocol.Response {
func handleInsert(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
table, ok := req.Params["table"].(string)
if !ok {
return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required")
@@ -236,6 +266,7 @@ func handleInsert(req *protocol.Request, db *sql.DB) *protocol.Response {
result, err := db.Exec(query, values...)
if err != nil {
logError(appID, "error", "insert_failed", map[string]any{"table": table, "error": err.Error()})
return protocol.Failure(req.ID, "INSERT_ERROR", err.Error())
}
@@ -246,7 +277,7 @@ func handleInsert(req *protocol.Request, db *sql.DB) *protocol.Response {
}
// handleUpdate exécute un UPDATE et retourne le nombre de lignes affectées.
func handleUpdate(req *protocol.Request, db *sql.DB) *protocol.Response {
func handleUpdate(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
table, ok := req.Params["table"].(string)
if !ok {
return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required")
@@ -285,6 +316,7 @@ func handleUpdate(req *protocol.Request, db *sql.DB) *protocol.Response {
result, err := db.Exec(query, values...)
if err != nil {
logError(appID, "error", "update_failed", map[string]any{"table": table, "error": err.Error()})
return protocol.Failure(req.ID, "UPDATE_ERROR", err.Error())
}
@@ -295,7 +327,7 @@ func handleUpdate(req *protocol.Request, db *sql.DB) *protocol.Response {
}
// handleDelete exécute un DELETE et retourne le nombre de lignes affectées.
func handleDelete(req *protocol.Request, db *sql.DB) *protocol.Response {
func handleDelete(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
table, ok := req.Params["table"].(string)
if !ok {
return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required")
@@ -321,6 +353,7 @@ func handleDelete(req *protocol.Request, db *sql.DB) *protocol.Response {
result, err := db.Exec(query, values...)
if err != nil {
logError(appID, "error", "delete_failed", map[string]any{"table": table, "error": err.Error()})
return protocol.Failure(req.ID, "DELETE_ERROR", err.Error())
}

285
cmd/sogoms/logs/main.go Normal file
View File

@@ -0,0 +1,285 @@
// sogoms-logs : Microservice de logging centralisé.
// Écrit les logs applicatifs dans des fichiers avec rotation automatique.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
"sogoms.com/internal/config"
"sogoms.com/internal/protocol"
)
var (
socketPath = flag.String("socket", "/run/sogoms-logs.1.sock", "Unix socket path")
configDir = flag.String("config", "/config", "Configuration directory")
logDir = flag.String("logdir", "/var/log/sogoms", "Log files directory")
retentionDays = flag.Int("retention", 30, "Default retention days")
)
// LogEntry représente une entrée de log au format JSON.
type LogEntry struct {
Timestamp string `json:"timestamp"`
App string `json:"app"`
Type string `json:"type"`
Level string `json:"level,omitempty"`
Message string `json:"message,omitempty"`
EventType string `json:"event_type,omitempty"`
Data map[string]any `json:"data,omitempty"`
Context map[string]any `json:"context,omitempty"`
}
// LogPool gère les fichiers de log par application.
type LogPool struct {
registry *config.Registry
logDir string
retentionDays int
files map[string]*os.File // clé: "{app}-{date}-{type}"
mu sync.Mutex
stopRotation chan struct{}
}
func NewLogPool(registry *config.Registry, logDir string, retentionDays int) *LogPool {
return &LogPool{
registry: registry,
logDir: logDir,
retentionDays: retentionDays,
files: make(map[string]*os.File),
stopRotation: make(chan struct{}),
}
}
// Start démarre la goroutine de rotation des logs.
func (p *LogPool) Start() {
// Créer le répertoire de logs si nécessaire
if err := os.MkdirAll(p.logDir, 0755); err != nil {
log.Printf("[logs] warning: cannot create log dir: %v", err)
}
// Rotation initiale
p.rotate()
// Goroutine de rotation quotidienne
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
p.rotate()
case <-p.stopRotation:
return
}
}
}()
}
// rotate supprime les fichiers de log plus vieux que retentionDays.
func (p *LogPool) rotate() {
cutoff := time.Now().AddDate(0, 0, -p.retentionDays)
entries, err := os.ReadDir(p.logDir)
if err != nil {
log.Printf("[logs] rotation error: %v", err)
return
}
deleted := 0
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
path := filepath.Join(p.logDir, entry.Name())
if err := os.Remove(path); err == nil {
deleted++
}
}
}
if deleted > 0 {
log.Printf("[logs] rotation: deleted %d old files", deleted)
}
}
// Write écrit une entrée de log dans le fichier approprié.
func (p *LogPool) Write(appID, logType string, entry *LogEntry) error {
date := time.Now().Format("20060102")
key := fmt.Sprintf("%s-%s-%s", appID, date, logType)
filename := filepath.Join(p.logDir, key+".log")
p.mu.Lock()
defer p.mu.Unlock()
// Obtenir ou créer le fichier
f, ok := p.files[key]
if !ok {
var err error
f, err = os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("open log file: %w", err)
}
p.files[key] = f
}
// Écrire l'entrée JSON
data, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("marshal entry: %w", err)
}
data = append(data, '\n')
if _, err := f.Write(data); err != nil {
return fmt.Errorf("write entry: %w", err)
}
return nil
}
// Close ferme tous les fichiers ouverts.
func (p *LogPool) Close() {
close(p.stopRotation)
p.mu.Lock()
defer p.mu.Unlock()
for key, f := range p.files {
f.Close()
delete(p.files, key)
}
log.Printf("[logs] closed all log files")
}
func main() {
flag.Parse()
log.SetFlags(log.Ltime | log.Lshortfile)
// Charger les configurations
registry := config.NewRegistry(*configDir)
if err := registry.Load(); err != nil {
log.Fatalf("load config: %v", err)
}
log.Printf("[logs] loaded apps: %v", registry.Apps())
// Pool de fichiers logs
logPool := NewLogPool(registry, *logDir, *retentionDays)
logPool.Start()
defer logPool.Close()
// Handler des requêtes
handler := func(ctx context.Context, req *protocol.Request) *protocol.Response {
return handleRequest(ctx, req, logPool)
}
// Démarrer le serveur
server := protocol.NewServer(*socketPath, handler)
if err := server.Start(); err != nil {
log.Fatalf("start server: %v", err)
}
log.Printf("[logs] sogoms-logs started on %s", *socketPath)
// Attendre signal d'arrêt
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Printf("[logs] shutting down...")
server.Stop()
}
func handleRequest(ctx context.Context, req *protocol.Request, logPool *LogPool) *protocol.Response {
switch req.Action {
case "health":
return protocol.Success(req.ID, map[string]any{"status": "ok"})
case "log_error":
return handleLogError(req, logPool)
case "log_event":
return handleLogEvent(req, logPool)
default:
return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action)
}
}
// handleLogError écrit un log d'erreur.
// Params: app_id, level (error|warn|info), message, context (optional)
func handleLogError(req *protocol.Request, logPool *LogPool) *protocol.Response {
appID, ok := req.Params["app_id"].(string)
if !ok || appID == "" {
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
}
level, _ := req.Params["level"].(string)
if level == "" {
level = "error"
}
message, ok := req.Params["message"].(string)
if !ok || message == "" {
return protocol.Failure(req.ID, "MISSING_MESSAGE", "message is required")
}
var context map[string]any
if ctx, ok := req.Params["context"].(map[string]any); ok {
context = ctx
}
entry := &LogEntry{
Timestamp: time.Now().Format(time.RFC3339),
App: appID,
Type: "error",
Level: level,
Message: message,
Context: context,
}
if err := logPool.Write(appID, "error", entry); err != nil {
return protocol.Failure(req.ID, "WRITE_ERROR", err.Error())
}
return protocol.Success(req.ID, map[string]any{"logged": true})
}
// handleLogEvent écrit un log d'événement.
// Params: app_id, event_type, data
func handleLogEvent(req *protocol.Request, logPool *LogPool) *protocol.Response {
appID, ok := req.Params["app_id"].(string)
if !ok || appID == "" {
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
}
eventType, ok := req.Params["event_type"].(string)
if !ok || eventType == "" {
return protocol.Failure(req.ID, "MISSING_EVENT_TYPE", "event_type is required")
}
var data map[string]any
if d, ok := req.Params["data"].(map[string]any); ok {
data = d
}
entry := &LogEntry{
Timestamp: time.Now().Format(time.RFC3339),
App: appID,
Type: "event",
EventType: eventType,
Data: data,
}
if err := logPool.Write(appID, "event", entry); err != nil {
return protocol.Failure(req.ID, "WRITE_ERROR", err.Error())
}
return protocol.Success(req.ID, map[string]any{"logged": true})
}

706
cmd/sogoms/smtp/main.go Normal file
View File

@@ -0,0 +1,706 @@
// sogoms-smtp : Microservice d'envoi d'emails.
// Envoie des emails via SMTP avec support des templates YAML.
package main
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"flag"
"fmt"
"log"
"net"
"net/smtp"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"text/template"
"time"
"gopkg.in/yaml.v3"
"sogoms.com/internal/config"
"sogoms.com/internal/protocol"
)
var (
socketPath = flag.String("socket", "/run/sogoms-smtp.1.sock", "Unix socket path")
configDir = flag.String("config", "/config", "Configuration directory")
logsSocket = flag.String("logs-socket", "/run/sogoms-logs.1.sock", "Logs service socket")
)
// SMTPConfig représente la configuration SMTP d'une application.
type SMTPConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
PasswordFile string `yaml:"password_file"`
From string `yaml:"from"`
FromName string `yaml:"from_name"`
TLS bool `yaml:"tls"`
password string
}
// EmailTemplate représente un template d'email.
type EmailTemplate struct {
Subject string `yaml:"subject"`
Body string `yaml:"body"`
BodyHTML string `yaml:"body_html"`
}
// SMTPPool gère les connexions SMTP par application.
type SMTPPool struct {
registry *config.Registry
configDir string
configs map[string]*SMTPConfig // appID -> config SMTP
templates map[string]map[string]*EmailTemplate // appID -> templateName -> template
logsPool *protocol.Pool
mu sync.RWMutex
}
func NewSMTPPool(registry *config.Registry, configDir string, logsPool *protocol.Pool) *SMTPPool {
return &SMTPPool{
registry: registry,
configDir: configDir,
configs: make(map[string]*SMTPConfig),
templates: make(map[string]map[string]*EmailTemplate),
logsPool: logsPool,
}
}
// Load charge les configurations SMTP et templates pour toutes les apps.
func (p *SMTPPool) Load() error {
p.mu.Lock()
defer p.mu.Unlock()
for _, appID := range p.registry.Apps() {
// Charger config SMTP depuis config/routes/{app}.yaml (section smtp:)
smtpCfg, err := p.loadSMTPConfig(appID)
if err != nil {
log.Printf("[smtp] warning: no SMTP config for %s: %v", appID, err)
continue
}
p.configs[appID] = smtpCfg
// Charger templates depuis config/emails/{app}/
templates, err := p.loadTemplates(appID)
if err != nil {
log.Printf("[smtp] warning: no templates for %s: %v", appID, err)
}
p.templates[appID] = templates
log.Printf("[smtp] loaded config for %s (%d templates)", appID, len(templates))
}
return nil
}
// loadSMTPConfig charge la config SMTP depuis un fichier YAML d'application.
func (p *SMTPPool) loadSMTPConfig(appID string) (*SMTPConfig, error) {
path := filepath.Join(p.configDir, "routes", appID+".yaml")
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var raw struct {
SMTP *SMTPConfig `yaml:"smtp"`
}
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, err
}
if raw.SMTP == nil {
return nil, fmt.Errorf("no smtp section")
}
cfg := raw.SMTP
// Port par défaut
if cfg.Port == 0 {
if cfg.TLS {
cfg.Port = 465
} else {
cfg.Port = 587
}
}
// Charger le mot de passe depuis le fichier
if cfg.PasswordFile != "" {
passData, err := os.ReadFile(cfg.PasswordFile)
if err != nil {
return nil, fmt.Errorf("read password file: %w", err)
}
cfg.password = strings.TrimSpace(string(passData))
}
return cfg, nil
}
// loadTemplates charge les templates email pour une application.
func (p *SMTPPool) loadTemplates(appID string) (map[string]*EmailTemplate, error) {
templatesDir := filepath.Join(p.configDir, "emails", appID)
entries, err := os.ReadDir(templatesDir)
if err != nil {
return nil, err
}
templates := make(map[string]*EmailTemplate)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
continue
}
name := strings.TrimSuffix(entry.Name(), ".yaml")
path := filepath.Join(templatesDir, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
continue
}
var tpl EmailTemplate
if err := yaml.Unmarshal(data, &tpl); err != nil {
continue
}
templates[name] = &tpl
}
return templates, nil
}
// GetConfig retourne la configuration SMTP pour une application.
func (p *SMTPPool) GetConfig(appID string) (*SMTPConfig, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
cfg, ok := p.configs[appID]
return cfg, ok
}
// GetTemplate retourne un template pour une application.
func (p *SMTPPool) GetTemplate(appID, name string) (*EmailTemplate, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
if tpls, ok := p.templates[appID]; ok {
tpl, ok := tpls[name]
return tpl, ok
}
return nil, false
}
// Send envoie un email.
func (p *SMTPPool) Send(appID string, to []string, subject, body, bodyHTML string) error {
cfg, ok := p.GetConfig(appID)
if !ok {
return fmt.Errorf("no SMTP config for app %s", appID)
}
// Construire le message MIME
msg := p.buildMessage(cfg, to, subject, body, bodyHTML)
// Envoyer
return p.sendMail(cfg, to, msg)
}
// buildMessage construit un message email MIME.
func (p *SMTPPool) buildMessage(cfg *SMTPConfig, to []string, subject, body, bodyHTML string) []byte {
var buf bytes.Buffer
// Headers
from := cfg.From
if cfg.FromName != "" {
from = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.From)
}
buf.WriteString(fmt.Sprintf("From: %s\r\n", from))
buf.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(to, ", ")))
buf.WriteString(fmt.Sprintf("Subject: =?UTF-8?B?%s?=\r\n", base64.StdEncoding.EncodeToString([]byte(subject))))
buf.WriteString("MIME-Version: 1.0\r\n")
if bodyHTML != "" {
// Multipart
boundary := fmt.Sprintf("boundary_%d", time.Now().UnixNano())
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
buf.WriteString("\r\n")
// Plain text part
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
buf.WriteString("\r\n")
buf.WriteString(base64.StdEncoding.EncodeToString([]byte(body)))
buf.WriteString("\r\n")
// HTML part
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
buf.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
buf.WriteString("\r\n")
buf.WriteString(base64.StdEncoding.EncodeToString([]byte(bodyHTML)))
buf.WriteString("\r\n")
buf.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
} else {
// Plain text only
buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
buf.WriteString("\r\n")
buf.WriteString(base64.StdEncoding.EncodeToString([]byte(body)))
}
return buf.Bytes()
}
// sendMail envoie un email via SMTP.
func (p *SMTPPool) sendMail(cfg *SMTPConfig, to []string, msg []byte) error {
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
if cfg.TLS {
// TLS direct (port 465)
return p.sendMailTLS(cfg, addr, to, msg)
}
// STARTTLS (port 587)
return p.sendMailSTARTTLS(cfg, addr, to, msg)
}
// sendMailTLS envoie via TLS direct (port 465).
func (p *SMTPPool) sendMailTLS(cfg *SMTPConfig, addr string, to []string, msg []byte) error {
tlsConfig := &tls.Config{
ServerName: cfg.Host,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("TLS dial: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
return fmt.Errorf("SMTP client: %w", err)
}
defer client.Close()
return p.sendMailClient(client, cfg, to, msg)
}
// sendMailSTARTTLS envoie via STARTTLS (port 587).
func (p *SMTPPool) sendMailSTARTTLS(cfg *SMTPConfig, addr string, to []string, msg []byte) error {
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("dial: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
return fmt.Errorf("SMTP client: %w", err)
}
defer client.Close()
// STARTTLS
tlsConfig := &tls.Config{
ServerName: cfg.Host,
}
if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("STARTTLS: %w", err)
}
return p.sendMailClient(client, cfg, to, msg)
}
// sendMailClient envoie un email via un client SMTP établi.
func (p *SMTPPool) sendMailClient(client *smtp.Client, cfg *SMTPConfig, to []string, msg []byte) error {
// Auth
if cfg.User != "" && cfg.password != "" {
auth := smtp.PlainAuth("", cfg.User, cfg.password, cfg.Host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("auth: %w", err)
}
}
// From
if err := client.Mail(cfg.From); err != nil {
return fmt.Errorf("MAIL FROM: %w", err)
}
// To
for _, addr := range to {
if err := client.Rcpt(addr); err != nil {
return fmt.Errorf("RCPT TO %s: %w", addr, err)
}
}
// Data
w, err := client.Data()
if err != nil {
return fmt.Errorf("DATA: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("write body: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("close data: %w", err)
}
return client.Quit()
}
// Log envoie un log au service logs.
func (p *SMTPPool) Log(appID, level, message string, data map[string]any) {
if p.logsPool == nil {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req := protocol.NewRequest("log_event", map[string]any{
"app_id": appID,
"event_type": "email_" + level,
"data": data,
})
p.logsPool.Call(ctx, req)
}()
}
func main() {
flag.Parse()
log.SetFlags(log.Ltime | log.Lshortfile)
// Charger les configurations
registry := config.NewRegistry(*configDir)
if err := registry.Load(); err != nil {
log.Fatalf("load config: %v", err)
}
log.Printf("[smtp] loaded apps: %v", registry.Apps())
// Pool de logs (optionnel)
var logsPool *protocol.Pool
if *logsSocket != "" {
logsPool = protocol.NewPool(*logsSocket, 2)
}
// Pool SMTP
smtpPool := NewSMTPPool(registry, *configDir, logsPool)
if err := smtpPool.Load(); err != nil {
log.Fatalf("load SMTP config: %v", err)
}
// Handler des requêtes
handler := func(ctx context.Context, req *protocol.Request) *protocol.Response {
return handleRequest(ctx, req, smtpPool)
}
// Démarrer le serveur
server := protocol.NewServer(*socketPath, handler)
if err := server.Start(); err != nil {
log.Fatalf("start server: %v", err)
}
log.Printf("[smtp] sogoms-smtp started on %s", *socketPath)
// Attendre signal d'arrêt
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Printf("[smtp] shutting down...")
server.Stop()
}
func handleRequest(ctx context.Context, req *protocol.Request, pool *SMTPPool) *protocol.Response {
switch req.Action {
case "health":
return protocol.Success(req.ID, map[string]any{"status": "ok"})
case "send":
return handleSend(req, pool)
case "send_template":
return handleSendTemplate(req, pool)
case "send_bulk":
return handleSendBulk(req, pool)
default:
return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action)
}
}
// handleSend envoie un email simple.
// Params: app_id, to (string ou []string), subject, body, body_html (optionnel)
func handleSend(req *protocol.Request, pool *SMTPPool) *protocol.Response {
appID, ok := req.Params["app_id"].(string)
if !ok || appID == "" {
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
}
// To peut être string ou []string
var to []string
switch v := req.Params["to"].(type) {
case string:
to = []string{v}
case []any:
for _, addr := range v {
if s, ok := addr.(string); ok {
to = append(to, s)
}
}
default:
return protocol.Failure(req.ID, "MISSING_TO", "to is required (string or array)")
}
if len(to) == 0 {
return protocol.Failure(req.ID, "MISSING_TO", "to is required")
}
subject, ok := req.Params["subject"].(string)
if !ok || subject == "" {
return protocol.Failure(req.ID, "MISSING_SUBJECT", "subject is required")
}
body, _ := req.Params["body"].(string)
bodyHTML, _ := req.Params["body_html"].(string)
if body == "" && bodyHTML == "" {
return protocol.Failure(req.ID, "MISSING_BODY", "body or body_html is required")
}
// Envoyer
if err := pool.Send(appID, to, subject, body, bodyHTML); err != nil {
pool.Log(appID, "error", "send failed", map[string]any{
"to": to,
"error": err.Error(),
})
return protocol.Failure(req.ID, "SEND_ERROR", err.Error())
}
pool.Log(appID, "sent", "email sent", map[string]any{
"to": to,
"subject": subject,
})
return protocol.Success(req.ID, map[string]any{"sent": true, "recipients": len(to)})
}
// handleSendTemplate envoie un email à partir d'un template.
// Params: app_id, to, template, data (map pour le template)
func handleSendTemplate(req *protocol.Request, pool *SMTPPool) *protocol.Response {
appID, ok := req.Params["app_id"].(string)
if !ok || appID == "" {
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
}
// To
var to []string
switch v := req.Params["to"].(type) {
case string:
to = []string{v}
case []any:
for _, addr := range v {
if s, ok := addr.(string); ok {
to = append(to, s)
}
}
default:
return protocol.Failure(req.ID, "MISSING_TO", "to is required")
}
if len(to) == 0 {
return protocol.Failure(req.ID, "MISSING_TO", "to is required")
}
templateName, ok := req.Params["template"].(string)
if !ok || templateName == "" {
return protocol.Failure(req.ID, "MISSING_TEMPLATE", "template is required")
}
// Charger le template
tpl, ok := pool.GetTemplate(appID, templateName)
if !ok {
return protocol.Failure(req.ID, "TEMPLATE_NOT_FOUND", "template not found: "+templateName)
}
// Data pour le template
data := make(map[string]any)
if d, ok := req.Params["data"].(map[string]any); ok {
data = d
}
// Rendre le template
subject, err := renderTemplate(tpl.Subject, data)
if err != nil {
return protocol.Failure(req.ID, "TEMPLATE_ERROR", "subject: "+err.Error())
}
body, err := renderTemplate(tpl.Body, data)
if err != nil {
return protocol.Failure(req.ID, "TEMPLATE_ERROR", "body: "+err.Error())
}
var bodyHTML string
if tpl.BodyHTML != "" {
bodyHTML, err = renderTemplate(tpl.BodyHTML, data)
if err != nil {
return protocol.Failure(req.ID, "TEMPLATE_ERROR", "body_html: "+err.Error())
}
}
// Envoyer
if err := pool.Send(appID, to, subject, body, bodyHTML); err != nil {
pool.Log(appID, "error", "send_template failed", map[string]any{
"to": to,
"template": templateName,
"error": err.Error(),
})
return protocol.Failure(req.ID, "SEND_ERROR", err.Error())
}
pool.Log(appID, "sent", "template email sent", map[string]any{
"to": to,
"template": templateName,
})
return protocol.Success(req.ID, map[string]any{"sent": true, "recipients": len(to), "template": templateName})
}
// handleSendBulk envoie des emails en masse.
// Params: app_id, recipients ([]map avec to, subject, body ou template+data)
func handleSendBulk(req *protocol.Request, pool *SMTPPool) *protocol.Response {
appID, ok := req.Params["app_id"].(string)
if !ok || appID == "" {
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
}
recipients, ok := req.Params["recipients"].([]any)
if !ok || len(recipients) == 0 {
return protocol.Failure(req.ID, "MISSING_RECIPIENTS", "recipients array is required")
}
sent := 0
failed := 0
var errors []string
for i, r := range recipients {
rcpt, ok := r.(map[string]any)
if !ok {
failed++
errors = append(errors, fmt.Sprintf("#%d: invalid recipient format", i))
continue
}
// To
var to []string
switch v := rcpt["to"].(type) {
case string:
to = []string{v}
case []any:
for _, addr := range v {
if s, ok := addr.(string); ok {
to = append(to, s)
}
}
}
if len(to) == 0 {
failed++
errors = append(errors, fmt.Sprintf("#%d: missing to", i))
continue
}
var subject, body, bodyHTML string
// Template ou direct
if templateName, ok := rcpt["template"].(string); ok && templateName != "" {
tpl, ok := pool.GetTemplate(appID, templateName)
if !ok {
failed++
errors = append(errors, fmt.Sprintf("#%d: template not found: %s", i, templateName))
continue
}
data := make(map[string]any)
if d, ok := rcpt["data"].(map[string]any); ok {
data = d
}
var err error
subject, err = renderTemplate(tpl.Subject, data)
if err != nil {
failed++
errors = append(errors, fmt.Sprintf("#%d: subject template error", i))
continue
}
body, _ = renderTemplate(tpl.Body, data)
if tpl.BodyHTML != "" {
bodyHTML, _ = renderTemplate(tpl.BodyHTML, data)
}
} else {
subject, _ = rcpt["subject"].(string)
body, _ = rcpt["body"].(string)
bodyHTML, _ = rcpt["body_html"].(string)
}
if subject == "" {
failed++
errors = append(errors, fmt.Sprintf("#%d: missing subject", i))
continue
}
if body == "" && bodyHTML == "" {
failed++
errors = append(errors, fmt.Sprintf("#%d: missing body", i))
continue
}
// Envoyer
if err := pool.Send(appID, to, subject, body, bodyHTML); err != nil {
failed++
errors = append(errors, fmt.Sprintf("#%d: %s", i, err.Error()))
continue
}
sent++
}
pool.Log(appID, "bulk", "bulk send completed", map[string]any{
"sent": sent,
"failed": failed,
})
result := map[string]any{
"sent": sent,
"failed": failed,
"total": len(recipients),
}
if len(errors) > 0 {
result["errors"] = errors
}
return protocol.Success(req.ID, result)
}
// renderTemplate rend un template Go text/template avec les données.
func renderTemplate(text string, data map[string]any) (string, error) {
if text == "" {
return "", nil
}
tpl, err := template.New("email").Parse(text)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}