feat: SMTP salida propio en webhook de soporte
This commit is contained in:
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user