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:
@@ -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
285
cmd/sogoms/logs/main.go
Normal 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
706
cmd/sogoms/smtp/main.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user