feat: SMTP salida propio en webhook de soporte

This commit is contained in:
Lizandro Guarnizo
2026-07-30 12:19:56 -05:00
parent 333fe03de0
commit 03b18418eb
5 changed files with 203 additions and 10 deletions
+9
View File
@@ -15,6 +15,15 @@ type SoporteWebhookConfig struct {
ResponderAuto bool `json:"responder_auto" gorm:"column:responder_auto;default:true"`
MensajeAuto string `json:"mensaje_auto" gorm:"column:mensaje_auto;type:text"`
AsignarA *uint `json:"asignar_a" gorm:"column:asignar_a;index"` // auto-asignar tickets a este user
// SMTP salida para notificaciones y auto-respuesta
SmtpHost string `json:"smtp_host" gorm:"column:smtp_host;size:255"`
SmtpPort int `json:"smtp_port" gorm:"column:smtp_port;default:587"`
SmtpUsername string `json:"smtp_username" gorm:"column:smtp_username;size:255"`
SmtpPassword string `json:"smtp_password" gorm:"column:smtp_password;size:255"` // cifrado AES
SmtpEncryption string `json:"smtp_encryption" gorm:"column:smtp_encryption;size:20;default:'tls'"` // tls|starttls|none
SmtpFromAddr string `json:"smtp_from_addr" gorm:"column:smtp_from_addr;size:255"`
SmtpFromName string `json:"smtp_from_name" gorm:"column:smtp_from_name;size:255"`
}
func (SoporteWebhookConfig) TableName() string { return "soporte_webhook_config" }
+11
View File
@@ -56,3 +56,14 @@ func TestSoporteWebhookConfigAsignarANil(t *testing.T) {
t.Errorf("AsignarA debe ser nil por defecto, got %v", *cfg.AsignarA)
}
}
func TestSoporteWebhookConfigSmtpDefaults(t *testing.T) {
cfg := SoporteWebhookConfig{Nombre: "SMTP Test"}
// Go zero values — los defaults reales los aplica GORM o el controller
if cfg.SmtpHost != "" {
t.Errorf("SmtpHost debe ser vacío por defecto, got %q", cfg.SmtpHost)
}
if cfg.SmtpPort != 0 {
t.Errorf("SmtpPort debe ser 0 en Go, got %d", cfg.SmtpPort)
}
}
+84 -2
View File
@@ -1,13 +1,95 @@
package services
import (
"crypto/tls"
"fmt"
"log"
"net/smtp"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// soporteSendMail envía un correo usando la configuración SMTP del webhook de soporte.
// Si no hay SMTP configurado, usa app.Http.Mail como fallback.
func soporteSendMail(to, subject, htmlBody string) error {
cfg, err := models.GetSoporteWebhookActivo()
if err != nil || cfg == nil || cfg.SmtpHost == "" {
return app.Http.Mail.Send(to, subject, htmlBody)
}
from := cfg.SmtpFromAddr
if from == "" {
from = cfg.EmailDestino
}
if from == "" {
return app.Http.Mail.Send(to, subject, htmlBody)
}
fromName := cfg.SmtpFromName
if fromName == "" {
fromName = "Soporte"
}
port := cfg.SmtpPort
if port == 0 {
port = 587
}
auth := smtp.PlainAuth("", cfg.SmtpUsername, cfg.SmtpPassword, cfg.SmtpHost)
msg := []byte(fmt.Sprintf("From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s", fromName, from, to, subject, htmlBody))
addr := fmt.Sprintf("%s:%d", cfg.SmtpHost, port)
enc := strings.ToLower(cfg.SmtpEncryption)
if enc == "tls" {
tlsCfg := &tls.Config{ServerName: cfg.SmtpHost}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return fmt.Errorf("soporte SMTP TLS: %w", err)
}
client, err := smtp.NewClient(conn, cfg.SmtpHost)
if err != nil {
conn.Close()
return fmt.Errorf("soporte SMTP client: %w", err)
}
defer client.Close()
if err = client.Auth(auth); err != nil {
return fmt.Errorf("soporte SMTP auth: %w", err)
}
if err = client.Mail(from); err != nil {
return err
}
if err = client.Rcpt(to); err != nil {
return err
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write(msg)
if err != nil {
return err
}
return w.Close()
}
if enc == "starttls" {
tlsCfg := &tls.Config{ServerName: cfg.SmtpHost}
conn, err := smtp.Dial(addr)
if err != nil {
goto fallback
}
if err = conn.StartTLS(tlsCfg); err != nil {
conn.Close()
goto fallback
}
if err = conn.Auth(auth); err != nil {
conn.Close()
return fmt.Errorf("soporte SMTP STARTTLS auth: %w", err)
}
return smtp.SendMail(addr, auth, from, []string{to}, msg)
}
return smtp.SendMail(addr, auth, from, []string{to}, msg)
fallback:
return app.Http.Mail.Send(to, subject, htmlBody)
}
// SendSoporteAutoRespuesta envía acuse de recibo automático al crear un ticket por email
func SendSoporteAutoRespuesta(ticket *models.ProyectoTicket) {
if ticket == nil || ticket.EmailFrom == "" {
@@ -39,7 +121,7 @@ func SendSoporteAutoRespuesta(ticket *models.ProyectoTicket) {
</div></body></html>`, ticket.AutorNombre, mensaje, ticket.ID, ticket.Titulo, ticket.Descripcion)
go func() {
if err := app.Http.Mail.Send(ticket.EmailFrom, subject, htmlBody); err != nil {
if err := soporteSendMail(ticket.EmailFrom, subject, htmlBody); err != nil {
log.Printf("[Soporte] Error enviando auto-respuesta a %s: %v", ticket.EmailFrom, err)
} else {
log.Printf("[Soporte] Auto-respuesta enviada a %s (ticket #%d)", ticket.EmailFrom, ticket.ID)
@@ -78,7 +160,7 @@ func SendSoporteNotifAdmin(ticket *models.ProyectoTicket) {
</div></body></html>`, ticket.AutorNombre, ticket.EmailFrom, ticket.Titulo, ticket.Descripcion, absAppURL(""), ticket.ID)
go func() {
if err := app.Http.Mail.Send(adminEmail, subject, htmlBody); err != nil {
if err := soporteSendMail(adminEmail, subject, htmlBody); err != nil {
log.Printf("[Soporte] Error notificando admin: %v", err)
}
}()
+70 -1
View File
@@ -49,7 +49,62 @@
<p class="text-xs text-slate-400 mt-1">Los tickets creados por email se asignarán automáticamente a este admin.</p>
</div>
<div class="pt-2">
<!-- ─── SMTP salida ──────────────────────────────────────────── -->
<div class="border-t border-slate-100 pt-4 mt-4">
<p class="text-sm font-semibold text-slate-700 mb-3">SMTP salida (notificaciones y auto-respuesta)</p>
<div class="grid grid-cols-2 gap-3">
<div class="col-span-1">
<label class="block text-xs font-medium text-slate-600 mb-1">Host</label>
<input x-model="cfg.smtp_host" type="text" placeholder="smtp.example.com"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
<div class="col-span-1">
<label class="block text-xs font-medium text-slate-600 mb-1">Puerto</label>
<input x-model="cfg.smtp_port" type="number" placeholder="587"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
</div>
<div class="grid grid-cols-2 gap-3 mt-2">
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Usuario</label>
<input x-model="cfg.smtp_username" type="text" placeholder="correo@example.com"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Contraseña</label>
<input x-model="cfg.smtp_password" type="password" placeholder="••••••••"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
</div>
<div class="grid grid-cols-2 gap-3 mt-2">
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Email remitente</label>
<input x-model="cfg.smtp_from_addr" type="text" placeholder="soporte@u-s.app"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Nombre remitente</label>
<input x-model="cfg.smtp_from_name" type="text" placeholder="Soporte U-site"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
</div>
<div class="mt-2">
<label class="block text-xs font-medium text-slate-600 mb-1">Encriptación</label>
<select x-model="cfg.smtp_encryption" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
<option value="tls">TLS (puerto 465)</option>
<option value="starttls">STARTTLS (puerto 587)</option>
<option value="none">Sin encriptación</option>
</select>
</div>
<p class="text-xs text-slate-400 mt-2">Si se deja vacío, se usará la configuración SMTP general del sistema.</p>
</div>
<div class="pt-4">
<button @click="guardar()"
class="px-6 py-2 rounded-xl text-white text-sm font-medium transition-colors"
style="background:#8eb02f"
@@ -78,6 +133,13 @@ function soporteWebhook() {
responder_auto: true,
mensaje_auto: '',
asignar_a: '',
smtp_host: '',
smtp_port: 587,
smtp_username: '',
smtp_password: '',
smtp_encryption: 'starttls',
smtp_from_addr: '',
smtp_from_name: '',
},
admins: [],
webhookUrl: '',
@@ -107,6 +169,13 @@ function soporteWebhook() {
responder_auto: this.cfg.responder_auto,
mensaje_auto: this.cfg.mensaje_auto,
asignar_a: this.cfg.asignar_a ? parseInt(this.cfg.asignar_a) : null,
smtp_host: this.cfg.smtp_host || '',
smtp_port: parseInt(this.cfg.smtp_port) || 587,
smtp_username: this.cfg.smtp_username || '',
smtp_password: this.cfg.smtp_password || '',
smtp_encryption: this.cfg.smtp_encryption || 'starttls',
smtp_from_addr: this.cfg.smtp_from_addr || '',
smtp_from_name: this.cfg.smtp_from_name || '',
};
try {
await axios.post('/app/soporte/webhook', payload);
+29 -7
View File
@@ -217,20 +217,42 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
ResponderAuto bool `json:"responder_auto"`
MensajeAuto string `json:"mensaje_auto"`
AsignarA *uint `json:"asignar_a"`
SmtpHost string `json:"smtp_host"`
SmtpPort int `json:"smtp_port"`
SmtpUsername string `json:"smtp_username"`
SmtpPassword string `json:"smtp_password"`
SmtpEncryption string `json:"smtp_encryption"`
SmtpFromAddr string `json:"smtp_from_addr"`
SmtpFromName string `json:"smtp_from_name"`
}
var b body
if err := c.BodyParser(&b); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
}
port := b.SmtpPort
if port == 0 {
port = 587
}
enc := b.SmtpEncryption
if enc == "" {
enc = "starttls"
}
cfg := &models.SoporteWebhookConfig{
Nombre: b.Nombre,
Provider: b.Provider,
ApiKey: b.ApiKey,
EmailDestino: b.EmailDestino,
Nombre: b.Nombre,
Provider: b.Provider,
ApiKey: b.ApiKey,
EmailDestino: b.EmailDestino,
ResponderAuto: b.ResponderAuto,
MensajeAuto: b.MensajeAuto,
AsignarA: b.AsignarA,
Activo: true,
MensajeAuto: b.MensajeAuto,
AsignarA: b.AsignarA,
SmtpHost: b.SmtpHost,
SmtpPort: port,
SmtpUsername: b.SmtpUsername,
SmtpPassword: b.SmtpPassword,
SmtpEncryption: enc,
SmtpFromAddr: b.SmtpFromAddr,
SmtpFromName: b.SmtpFromName,
Activo: true,
}
cfg.ID = b.ID
if err := models.SaveSoporteWebhookConfig(cfg); err != nil {