Compare commits
2
Commits
1ebae791f6
...
e681f41639
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e681f41639 | ||
|
|
8c3ab79e88 |
@@ -75,6 +75,9 @@ require (
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
|
||||
@@ -175,6 +175,12 @@ github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5O
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
|
||||
github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
|
||||
@@ -7,23 +7,38 @@ import (
|
||||
|
||||
type SoporteWebhookConfig struct {
|
||||
gorm.Model
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:100"`
|
||||
Provider string `json:"provider" gorm:"column:provider;size:50;default:'sendgrid'"` // sendgrid|mailgun|generic
|
||||
ApiKey string `json:"api_key" gorm:"column:api_key;size:255"`
|
||||
EmailDestino string `json:"email_destino" gorm:"column:email_destino;size:255"` // ej: soporte@u-s.app
|
||||
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
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:100"`
|
||||
Provider string `json:"provider" gorm:"column:provider;size:50;default:'sendgrid'"` // sendgrid|mailgun|generic
|
||||
ApiKey string `json:"api_key" gorm:"column:api_key;size:255"`
|
||||
EmailDestino string `json:"email_destino" gorm:"column:email_destino;size:255"` // ej: soporte@u-s.app
|
||||
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
|
||||
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"`
|
||||
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"`
|
||||
|
||||
// IMAP entrante: leer el buzón directamente en vez de depender de que un
|
||||
// proveedor nos haga POST. Es lo único que hace falta para responderle a un
|
||||
// cliente que escribe a soporte@ desde su correo de siempre.
|
||||
ImapActivo bool `json:"imap_activo" gorm:"column:imap_activo;default:false"`
|
||||
ImapHost string `json:"imap_host" gorm:"column:imap_host;size:255"`
|
||||
ImapPort int `json:"imap_port" gorm:"column:imap_port;default:993"`
|
||||
ImapUsername string `json:"imap_username" gorm:"column:imap_username;size:255"`
|
||||
ImapPasswordEnc string `json:"-" gorm:"column:imap_password_enc;type:text"` // AES-GCM con APP_KEY
|
||||
ImapEncryption string `json:"imap_encryption" gorm:"column:imap_encryption;size:20;default:'ssl'"` // ssl|starttls
|
||||
ImapCarpeta string `json:"imap_carpeta" gorm:"column:imap_carpeta;size:100;default:'INBOX'"`
|
||||
|
||||
// Solo para la vista: dice si ya hay contraseña guardada sin exponerla, para
|
||||
// que el formulario sepa que puede mandar el campo vacío sin borrarla.
|
||||
TieneImapPassword bool `json:"tiene_imap_password" gorm:"-"`
|
||||
}
|
||||
|
||||
func (SoporteWebhookConfig) TableName() string { return "soporte_webhook_config" }
|
||||
@@ -42,7 +57,13 @@ func GetAllSoporteWebhookConfigs() ([]SoporteWebhookConfig, error) {
|
||||
|
||||
func SaveSoporteWebhookConfig(s *SoporteWebhookConfig) error {
|
||||
if s.ID > 0 {
|
||||
return app.Http.Database.DB.Model(s).Updates(s).Error
|
||||
// Select("*") para que los booleanos en false (activo, responder_auto,
|
||||
// imap_activo) también se guarden: Updates con struct ignora los ceros,
|
||||
// así que desactivar algo no tenía efecto.
|
||||
return app.Http.Database.DB.Model(&SoporteWebhookConfig{}).
|
||||
Where("id = ?", s.ID).
|
||||
Select("*").Omit("id", "created_at", "deleted_at").
|
||||
Updates(s).Error
|
||||
}
|
||||
return app.Http.Database.DB.Create(s).Error
|
||||
}
|
||||
|
||||
@@ -78,8 +78,15 @@ func IniciarCron() {
|
||||
return
|
||||
}
|
||||
|
||||
// Buzón de soporte por IMAP — cada 2 minutos. No hace nada si no está
|
||||
// configurado, así que registrarlo siempre no cuesta.
|
||||
if _, err := cronScheduler.AddFunc("*/2 * * * *", RevisarBuzonSoporte); err != nil {
|
||||
log.Printf("[CRON] Error registrando tarea soporte_imap: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
cronScheduler.Start()
|
||||
log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min")
|
||||
log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min, buzón de soporte cada 2min")
|
||||
}
|
||||
|
||||
// DetenerCron para graceful shutdown
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// CompletarTextoIA hace una llamada simple (sin streaming, sin tools) al
|
||||
// proveedor configurado para el servicio dado y devuelve el texto de la
|
||||
// respuesta. Es la contraparte no-streaming de GeneraTextoStream, para los
|
||||
// casos en que el backend necesita el resultado completo antes de seguir.
|
||||
func CompletarTextoIA(servicio, sistema, usuario string) (string, error) {
|
||||
config, err := models.GetAiConfigForService(servicio)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no hay configuración de IA activa para %q; configurá una en /app/ai-config", servicio)
|
||||
}
|
||||
modelo := config.ModelName
|
||||
if modelo == "" {
|
||||
return "", fmt.Errorf("la configuración de IA de %q no tiene modelo definido", servicio)
|
||||
}
|
||||
|
||||
provider := strings.ToLower(config.Provider)
|
||||
clave := config.ClaveEnClaro()
|
||||
baseURL := strings.TrimRight(config.BaseURL, "/")
|
||||
|
||||
var endpoint string
|
||||
var cuerpo []byte
|
||||
if provider == "gemini" {
|
||||
endpoint = fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", modelo, clave)
|
||||
cuerpo, _ = json.Marshal(map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{"parts": []map[string]string{{"text": sistema + "\n\n" + usuario}}},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
endpoint = strings.TrimSuffix(baseURL, "/v1") + "/v1/chat/completions"
|
||||
cuerpo, _ = json.Marshal(map[string]any{
|
||||
"model": modelo,
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": sistema},
|
||||
{"role": "user", "content": usuario},
|
||||
},
|
||||
"stream": false,
|
||||
})
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(cuerpo))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if provider != "gemini" && clave != "" {
|
||||
if provider == "ollama" && clave != "ollama" {
|
||||
req.SetBasicAuth("ollama", clave)
|
||||
} else if provider != "ollama" {
|
||||
req.Header.Set("Authorization", "Bearer "+clave)
|
||||
}
|
||||
}
|
||||
|
||||
// Generar una plantilla entera es lento; el timeout es alto a propósito.
|
||||
resp, err := (&http.Client{Timeout: 180 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no se pudo conectar al proveedor de IA: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("el proveedor de IA respondió %d: %s", resp.StatusCode, recortar(strings.TrimSpace(string(raw)), 300))
|
||||
}
|
||||
|
||||
if provider == "gemini" {
|
||||
var out struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("respuesta inesperada del proveedor: %s", recortar(strings.TrimSpace(string(raw)), 300))
|
||||
}
|
||||
if len(out.Candidates) == 0 || len(out.Candidates[0].Content.Parts) == 0 {
|
||||
return "", fmt.Errorf("el proveedor de IA no devolvió texto")
|
||||
}
|
||||
return out.Candidates[0].Content.Parts[0].Text, nil
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("respuesta inesperada del proveedor: %s", recortar(strings.TrimSpace(string(raw)), 300))
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("el proveedor de IA no devolvió texto")
|
||||
}
|
||||
return out.Choices[0].Message.Content, nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// variablesPorTipo documenta, para la IA, qué campos recibe cada plantilla al
|
||||
// renderizarse. Sale de DatosBaseDocumento + lo que arma cada generador
|
||||
// (ver CrearCotizacion, contrato_documento_service, cuenta_cobro_documento_service).
|
||||
var variablesPorTipo = map[string]string{
|
||||
"cotizacion": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Cliente.Email}}, {{.Cliente.Telefono}},
|
||||
{{.Alcance}}, {{.TipoProyecto}}, {{.Total}},
|
||||
{{range .Items}} … {{.Descripcion}} {{.Cantidad}} {{.Unidad}} {{.ValorUnitario}} … {{end}}`,
|
||||
"contrato": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Alcance}}, {{.Total}}, {{.Servicio}}, {{.Periodicidad}}`,
|
||||
"acta": `{{.Cliente.Nombre}}, {{.Proyecto}}, {{.Alcance}}, {{.Entregables}}`,
|
||||
"cuenta_cobro": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Concepto}}, {{.Total}}, {{.Numero}}`,
|
||||
}
|
||||
|
||||
const variablesComunes = `{{.Fecha}}, {{.EmpresaNombre}}, {{.EmpresaWeb}}`
|
||||
|
||||
// ExtraerTextoDePlantilla saca el texto de un archivo subido para usarlo como
|
||||
// referencia. Los formatos de texto se leen directo; el .docx es un zip con XML
|
||||
// adentro (stdlib alcanza) y las imágenes pasan por OCR.
|
||||
func ExtraerTextoDePlantilla(nombreArchivo string, datos []byte) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(nombreArchivo))
|
||||
switch ext {
|
||||
case ".html", ".htm", ".txt", ".md":
|
||||
return string(datos), nil
|
||||
case ".docx":
|
||||
return textoDeDocx(datos)
|
||||
case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff":
|
||||
mime := "image/png"
|
||||
if ext == ".jpg" || ext == ".jpeg" {
|
||||
mime = "image/jpeg"
|
||||
} else if ext != ".png" {
|
||||
mime = "image/" + strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
// agenteID 0: es una acción del staff, no se le cobra a ningún cliente.
|
||||
return ExtraerTextoOCR(0, datos, mime)
|
||||
case ".pdf":
|
||||
return "", fmt.Errorf("el PDF todavía no se puede leer acá; exportalo a .docx o subí una captura de pantalla del documento")
|
||||
default:
|
||||
return "", fmt.Errorf("formato %s no soportado: subí .docx, .html, .txt o una imagen del documento", ext)
|
||||
}
|
||||
}
|
||||
|
||||
var etiquetaXML = regexp.MustCompile(`<[^>]+>`)
|
||||
|
||||
// textoDeDocx lee word/document.xml del .docx y lo aplana a texto. No pretende
|
||||
// conservar el formato: la IA solo necesita el contenido y el orden.
|
||||
func textoDeDocx(datos []byte) (string, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(datos), int64(len(datos)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("el .docx no se pudo abrir: %w", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
xmlBytes, err := io.ReadAll(io.LimitReader(rc, 8<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := string(xmlBytes)
|
||||
// Un párrafo y un salto de línea explícito valen como salto de línea;
|
||||
// el resto de las etiquetas se descarta.
|
||||
s = strings.ReplaceAll(s, "</w:p>", "\n")
|
||||
s = strings.ReplaceAll(s, "<w:br/>", "\n")
|
||||
s = strings.ReplaceAll(s, "</w:tr>", "\n")
|
||||
s = strings.ReplaceAll(s, "</w:tc>", "\t")
|
||||
s = etiquetaXML.ReplaceAllString(s, "")
|
||||
s = strings.NewReplacer("&", "&", "<", "<", ">", ">", """, `"`, "'", "'").Replace(s)
|
||||
return strings.TrimSpace(s), nil
|
||||
}
|
||||
return "", fmt.Errorf("el archivo no parece un .docx (no tiene word/document.xml)")
|
||||
}
|
||||
|
||||
// ConvertirEnPlantilla le pide a la IA que rearme el documento como plantilla
|
||||
// HTML con las variables Go que usa el generador. Lo que devuelve va al editor
|
||||
// para que el admin lo revise antes de guardar: no se guarda solo.
|
||||
func ConvertirEnPlantilla(tipo, textoDocumento string) (string, error) {
|
||||
if strings.TrimSpace(textoDocumento) == "" {
|
||||
return "", fmt.Errorf("no se pudo leer texto del archivo")
|
||||
}
|
||||
if len(textoDocumento) > 20000 {
|
||||
textoDocumento = textoDocumento[:20000]
|
||||
}
|
||||
vars := variablesPorTipo[tipo]
|
||||
if vars == "" {
|
||||
vars = variablesComunes
|
||||
}
|
||||
|
||||
sistema := `Sos un asistente que convierte documentos en plantillas HTML para Go text/template.
|
||||
Reglas:
|
||||
- Devolvé SOLO el HTML de la plantilla, sin explicaciones y sin bloques de código markdown.
|
||||
- Reemplazá los datos concretos del documento (nombres, NITs, fechas, montos, ítems) por las variables de la lista. Lo que sea texto fijo del formato se deja tal cual.
|
||||
- Si el documento tiene una tabla de ítems, usá {{range .Items}}…{{end}} para las filas.
|
||||
- Usá estilos inline (style="…"), sin CSS externo ni <script>: el HTML se convierte a PDF.
|
||||
- No inventes variables que no estén en la lista.`
|
||||
|
||||
usuario := fmt.Sprintf(`Tipo de documento: %s
|
||||
|
||||
Variables disponibles:
|
||||
%s
|
||||
%s
|
||||
|
||||
Documento de referencia:
|
||||
---
|
||||
%s
|
||||
---`, tipo, variablesComunes, vars, textoDocumento)
|
||||
|
||||
salida, err := CompletarTextoIA("ia", sistema, usuario)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return limpiarCercaDeCodigo(salida), nil
|
||||
}
|
||||
|
||||
// limpiarCercaDeCodigo saca el ```html … ``` que los modelos agregan aunque se
|
||||
// les pida que no lo hagan.
|
||||
func limpiarCercaDeCodigo(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "```") {
|
||||
return s
|
||||
}
|
||||
if i := strings.Index(s, "\n"); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
if i := strings.LastIndex(s, "```"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func docxDePrueba(t *testing.T, documentXML string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, err := zw.Create("word/document.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(documentXML)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestExtraerTextoDePlantillaDocx(t *testing.T) {
|
||||
xml := `<?xml version="1.0"?><w:document><w:body>` +
|
||||
`<w:p><w:r><w:t>COTIZACI&Oacute;N</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>Cliente: </w:t></w:r><w:r><w:t>Acme SAS</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>Total: $1.500.000</w:t></w:r></w:p>` +
|
||||
`</w:body></w:document>`
|
||||
|
||||
got, err := ExtraerTextoDePlantilla("cotizacion.docx", docxDePrueba(t, xml))
|
||||
if err != nil {
|
||||
t.Fatalf("ExtraerTextoDePlantilla: %v", err)
|
||||
}
|
||||
// Los runs de un mismo párrafo quedan pegados; los párrafos, en líneas.
|
||||
if !strings.Contains(got, "Cliente: Acme SAS") {
|
||||
t.Errorf("falta la línea del cliente en:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "Total: $1.500.000") {
|
||||
t.Errorf("falta el total en:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "<w:") {
|
||||
t.Errorf("quedaron etiquetas XML en:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtraerTextoDePlantillaFormatoNoSoportado(t *testing.T) {
|
||||
if _, err := ExtraerTextoDePlantilla("plantilla.pdf", []byte("x")); err == nil {
|
||||
t.Error("el PDF debería devolver error explicando la alternativa")
|
||||
}
|
||||
if _, err := ExtraerTextoDePlantilla("plantilla.xyz", []byte("x")); err == nil {
|
||||
t.Error("una extensión desconocida debería devolver error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimpiarCercaDeCodigo(t *testing.T) {
|
||||
casos := map[string]string{
|
||||
"```html\n<p>hola</p>\n```": "<p>hola</p>",
|
||||
"```\n<p>hola</p>\n```": "<p>hola</p>",
|
||||
"<p>hola</p>": "<p>hola</p>",
|
||||
}
|
||||
for in, want := range casos {
|
||||
if got := limpiarCercaDeCodigo(in); got != want {
|
||||
t.Errorf("limpiarCercaDeCodigo(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// El poller corre por cron; este lock evita que dos corridas se pisen si una
|
||||
// tarda más que el intervalo (buzón grande, servidor lento).
|
||||
var imapEnCurso sync.Mutex
|
||||
|
||||
// RevisarBuzonSoporte lee los correos sin leer del buzón configurado y los
|
||||
// convierte en tickets. Cada correo procesado se marca como \Seen, que es lo
|
||||
// que evita volver a leerlo; la deduplicación por Message-Id es el segundo
|
||||
// cinturón por si el marcado falla.
|
||||
func RevisarBuzonSoporte() {
|
||||
cfg, err := models.GetSoporteWebhookActivo()
|
||||
if err != nil || cfg == nil || !cfg.ImapActivo || cfg.ImapHost == "" {
|
||||
return
|
||||
}
|
||||
if !imapEnCurso.TryLock() {
|
||||
log.Printf("[SoporteIMAP] corrida anterior todavía en curso, se salta esta")
|
||||
return
|
||||
}
|
||||
defer imapEnCurso.Unlock()
|
||||
|
||||
n, err := revisarBuzon(cfg)
|
||||
if err != nil {
|
||||
log.Printf("[SoporteIMAP] %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[SoporteIMAP] %d correo(s) procesado(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ProbarConexionImap valida credenciales sin procesar nada — lo usa el botón
|
||||
// "Probar" de la vista de configuración.
|
||||
func ProbarConexionImap(cfg *models.SoporteWebhookConfig) error {
|
||||
c, err := conectarImap(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
carpeta := cfg.ImapCarpeta
|
||||
if carpeta == "" {
|
||||
carpeta = "INBOX"
|
||||
}
|
||||
if _, err := c.Select(carpeta, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
|
||||
return fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err)
|
||||
}
|
||||
_ = c.Logout().Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func conectarImap(cfg *models.SoporteWebhookConfig) (*imapclient.Client, error) {
|
||||
pass, err := DescifrarSecretoUmind(cfg.ImapPasswordEnc)
|
||||
if err != nil || pass == "" {
|
||||
return nil, fmt.Errorf("no hay contraseña IMAP guardada")
|
||||
}
|
||||
port := cfg.ImapPort
|
||||
if port == 0 {
|
||||
port = 993
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", cfg.ImapHost, port)
|
||||
|
||||
var c *imapclient.Client
|
||||
if strings.EqualFold(cfg.ImapEncryption, "starttls") {
|
||||
c, err = imapclient.DialStartTLS(addr, nil)
|
||||
} else {
|
||||
c, err = imapclient.DialTLS(addr, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no se pudo conectar a %s: %w", addr, err)
|
||||
}
|
||||
if err := c.Login(cfg.ImapUsername, pass).Wait(); err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("login IMAP rechazado para %s: %w", cfg.ImapUsername, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, error) {
|
||||
c, err := conectarImap(cfg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
carpeta := cfg.ImapCarpeta
|
||||
if carpeta == "" {
|
||||
carpeta = "INBOX"
|
||||
}
|
||||
if _, err := c.Select(carpeta, nil).Wait(); err != nil {
|
||||
return 0, fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err)
|
||||
}
|
||||
|
||||
buscados, err := c.Search(&imap.SearchCriteria{
|
||||
NotFlag: []imap.Flag{imap.FlagSeen},
|
||||
}, &imap.SearchOptions{ReturnAll: true}).Wait()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("búsqueda de no leídos falló: %w", err)
|
||||
}
|
||||
uids := buscados.AllUIDs()
|
||||
if len(uids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// ponytail: tope por corrida para no atragantarse con un buzón que nunca
|
||||
// se leyó. Los que sobran quedan sin leer y entran en la corrida siguiente.
|
||||
const maxPorCorrida = 50
|
||||
if len(uids) > maxPorCorrida {
|
||||
uids = uids[:maxPorCorrida]
|
||||
}
|
||||
|
||||
msgs, err := c.Fetch(imap.UIDSetNum(uids...), &imap.FetchOptions{
|
||||
BodySection: []*imap.FetchItemBodySection{{}},
|
||||
}).Collect()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("descarga de correos falló: %w", err)
|
||||
}
|
||||
|
||||
procesados := 0
|
||||
for _, m := range msgs {
|
||||
var crudo []byte
|
||||
for _, b := range m.BodySection {
|
||||
crudo = b.Bytes
|
||||
break
|
||||
}
|
||||
if len(crudo) == 0 {
|
||||
continue
|
||||
}
|
||||
correo, err := parsearCorreo(crudo)
|
||||
if err != nil {
|
||||
log.Printf("[SoporteIMAP] no se pudo leer un correo (uid=%v): %v", m.UID, err)
|
||||
continue
|
||||
}
|
||||
if IngestarCorreoSoporte(cfg, correo) {
|
||||
procesados++
|
||||
}
|
||||
// Se marca leído aunque se haya ignorado por duplicado: si no, se
|
||||
// vuelve a bajar en cada corrida para siempre.
|
||||
if err := c.Store(imap.UIDSetNum(m.UID), &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsAdd,
|
||||
Silent: true,
|
||||
Flags: []imap.Flag{imap.FlagSeen},
|
||||
}, nil).Close(); err != nil {
|
||||
log.Printf("[SoporteIMAP] no se pudo marcar leído el uid=%v: %v", m.UID, err)
|
||||
}
|
||||
}
|
||||
return procesados, nil
|
||||
}
|
||||
|
||||
// parsearCorreo saca remitente, asunto y cuerpo de texto de un mensaje RFC822.
|
||||
func parsearCorreo(crudo []byte) (CorreoSoporte, error) {
|
||||
msg, err := mail.ReadMessage(strings.NewReader(string(crudo)))
|
||||
if err != nil {
|
||||
return CorreoSoporte{}, err
|
||||
}
|
||||
dec := new(mime.WordDecoder)
|
||||
decodificar := func(s string) string {
|
||||
if out, err := dec.DecodeHeader(s); err == nil {
|
||||
return out
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
from := decodificar(msg.Header.Get("From"))
|
||||
nombre := ExtraerNombre(from)
|
||||
if dir, err := mail.ParseAddress(from); err == nil && dir.Name != "" {
|
||||
nombre = dir.Name
|
||||
}
|
||||
|
||||
cuerpo, err := cuerpoDeTexto(msg.Header.Get("Content-Type"), msg.Body)
|
||||
if err != nil {
|
||||
return CorreoSoporte{}, err
|
||||
}
|
||||
|
||||
return CorreoSoporte{
|
||||
From: from,
|
||||
FromName: nombre,
|
||||
Subject: decodificar(msg.Header.Get("Subject")),
|
||||
Texto: limpiarCitas(cuerpo),
|
||||
MessageID: strings.TrimSpace(msg.Header.Get("Message-Id")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// cuerpoDeTexto devuelve el text/plain del correo; si es multipart baja por las
|
||||
// partes hasta encontrarlo y cae al HTML solo si no hay texto plano.
|
||||
func cuerpoDeTexto(contentType string, cuerpo io.Reader) (string, error) {
|
||||
medio, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
medio = "text/plain"
|
||||
params = map[string]string{}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(medio, "multipart/") {
|
||||
b, err := io.ReadAll(io.LimitReader(cuerpo, 1<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
frontera := params["boundary"]
|
||||
if frontera == "" {
|
||||
return "", fmt.Errorf("multipart sin boundary")
|
||||
}
|
||||
lector := multipart.NewReader(cuerpo, frontera)
|
||||
var html string
|
||||
for {
|
||||
parte, err := lector.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tipoParte, _, _ := mime.ParseMediaType(parte.Header.Get("Content-Type"))
|
||||
if strings.HasPrefix(tipoParte, "multipart/") {
|
||||
anidado, err := cuerpoDeTexto(parte.Header.Get("Content-Type"), parte)
|
||||
if err == nil && anidado != "" {
|
||||
return anidado, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if tipoParte != "text/plain" && tipoParte != "text/html" {
|
||||
continue
|
||||
}
|
||||
var lect io.Reader = io.LimitReader(parte, 1<<20)
|
||||
switch strings.ToLower(parte.Header.Get("Content-Transfer-Encoding")) {
|
||||
case "quoted-printable":
|
||||
lect = quotedprintable.NewReader(lect)
|
||||
case "base64":
|
||||
lect = base64.NewDecoder(base64.StdEncoding, lect)
|
||||
}
|
||||
b, err := io.ReadAll(lect)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if tipoParte == "text/plain" {
|
||||
return string(b), nil
|
||||
}
|
||||
html = string(b)
|
||||
}
|
||||
return html, nil
|
||||
}
|
||||
|
||||
// limpiarCitas corta el correo en la primera línea citada o en el separador
|
||||
// típico de respuesta, para que el ticket no repita todo el hilo anterior.
|
||||
func limpiarCitas(texto string) string {
|
||||
lineas := strings.Split(strings.ReplaceAll(texto, "\r\n", "\n"), "\n")
|
||||
var out []string
|
||||
for _, l := range lineas {
|
||||
t := strings.TrimSpace(l)
|
||||
if strings.HasPrefix(t, ">") ||
|
||||
strings.HasPrefix(t, "-----Original Message-----") ||
|
||||
strings.HasPrefix(t, "-----Mensaje original-----") ||
|
||||
(strings.HasPrefix(t, "El ") && strings.HasSuffix(t, "escribió:")) ||
|
||||
(strings.HasPrefix(t, "On ") && strings.HasSuffix(t, "wrote:")) {
|
||||
break
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParsearCorreoMultipartQuotedPrintable(t *testing.T) {
|
||||
crudo := "From: =?UTF-8?Q?Juan_P=C3=A9rez?= <juan@cliente.com>\r\n" +
|
||||
"Subject: =?UTF-8?Q?No_puedo_entrar_a_la_plataforma?=\r\n" +
|
||||
"Message-Id: <abc123@cliente.com>\r\n" +
|
||||
"Content-Type: multipart/alternative; boundary=\"XX\"\r\n" +
|
||||
"\r\n" +
|
||||
"--XX\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||
"Content-Transfer-Encoding: quoted-printable\r\n" +
|
||||
"\r\n" +
|
||||
"Hola, la contrase=C3=B1a no me sirve.\r\n" +
|
||||
"\r\n" +
|
||||
"El 3 de marzo, Soporte escribi=C3=B3:\r\n" +
|
||||
"> proba de nuevo\r\n" +
|
||||
"--XX\r\n" +
|
||||
"Content-Type: text/html; charset=UTF-8\r\n" +
|
||||
"\r\n" +
|
||||
"<p>ignorame</p>\r\n" +
|
||||
"--XX--\r\n"
|
||||
|
||||
c, err := parsearCorreo([]byte(crudo))
|
||||
if err != nil {
|
||||
t.Fatalf("parsearCorreo: %v", err)
|
||||
}
|
||||
if c.FromName != "Juan Pérez" {
|
||||
t.Errorf("FromName = %q, want %q", c.FromName, "Juan Pérez")
|
||||
}
|
||||
if ExtraerEmail(c.From) != "juan@cliente.com" {
|
||||
t.Errorf("From = %q", c.From)
|
||||
}
|
||||
if c.Subject != "No puedo entrar a la plataforma" {
|
||||
t.Errorf("Subject = %q", c.Subject)
|
||||
}
|
||||
if c.MessageID != "<abc123@cliente.com>" {
|
||||
t.Errorf("MessageID = %q", c.MessageID)
|
||||
}
|
||||
// Cuerpo decodificado, sin la cita del hilo anterior ni el HTML.
|
||||
if c.Texto != "Hola, la contraseña no me sirve." {
|
||||
t.Errorf("Texto = %q", c.Texto)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsearCorreoPlano(t *testing.T) {
|
||||
crudo := "From: ana@cliente.com\r\nSubject: Consulta\r\n\r\nHola\r\n"
|
||||
c, err := parsearCorreo([]byte(crudo))
|
||||
if err != nil {
|
||||
t.Fatalf("parsearCorreo: %v", err)
|
||||
}
|
||||
if c.Texto != "Hola" || c.Subject != "Consulta" {
|
||||
t.Errorf("got %+v", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// CorreoSoporte es un correo entrante ya normalizado, sin importar por dónde
|
||||
// llegó (webhook de SendGrid/Mailgun o buzón IMAP). Los dos caminos terminan
|
||||
// acá para que enhebrado, deduplicación y auto-respuesta se comporten igual.
|
||||
type CorreoSoporte struct {
|
||||
From string // puede venir como "Nombre <mail@x.com>"
|
||||
FromName string
|
||||
Subject string
|
||||
Texto string
|
||||
MessageID string
|
||||
}
|
||||
|
||||
var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`)
|
||||
|
||||
// IngestarCorreoSoporte convierte un correo en ticket nuevo o en respuesta a un
|
||||
// ticket existente. Devuelve true si se procesó algo (false = ignorado por
|
||||
// duplicado o por venir vacío).
|
||||
func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bool {
|
||||
if e.From == "" || e.Subject == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Deduplicación: el proveedor puede reintentar la entrega, y el poller IMAP
|
||||
// puede releer un correo si falló el marcado como leído.
|
||||
if models.EmailMessageIDYaProcesado(e.MessageID) {
|
||||
log.Printf("[Soporte] Correo duplicado ignorado (message_id=%s)", e.MessageID)
|
||||
return false
|
||||
}
|
||||
|
||||
fromEmail := ExtraerEmail(e.From)
|
||||
fromName := e.FromName
|
||||
if fromName == "" {
|
||||
fromName = ExtraerNombre(e.From)
|
||||
}
|
||||
if fromName == "" {
|
||||
fromName = fromEmail
|
||||
}
|
||||
|
||||
contenido := strings.TrimSpace(e.Texto)
|
||||
if len(contenido) > 5000 {
|
||||
contenido = contenido[:5000]
|
||||
}
|
||||
|
||||
// Enhebrado: si el asunto trae "[Ticket #N]" (lo agregamos nosotros en el
|
||||
// auto-ack) y ese ticket es del mismo remitente, es una respuesta — se
|
||||
// agrega como mensaje en vez de abrir un ticket nuevo.
|
||||
if hilo := buscarTicketDeHilo(fromEmail, e.Subject); hilo != nil {
|
||||
msg := &models.TicketMensaje{
|
||||
TicketID: hilo.ID,
|
||||
Contenido: contenido,
|
||||
EsAdmin: false,
|
||||
AutorNombre: fromName,
|
||||
MessageID: e.MessageID,
|
||||
}
|
||||
if err := models.CreateTicketMensaje(msg); err != nil {
|
||||
log.Printf("[Soporte] Error agregando mensaje al ticket #%d: %v", hilo.ID, err)
|
||||
return false
|
||||
}
|
||||
if hilo.Estado == "resuelto" || hilo.Estado == "cerrado" {
|
||||
_ = models.UpdateTicketEstado(hilo.ID, "abierto")
|
||||
}
|
||||
log.Printf("[Soporte] Respuesta agregada al ticket #%d (%s)", hilo.ID, fromEmail)
|
||||
return true
|
||||
}
|
||||
|
||||
ticket := &models.ProyectoTicket{
|
||||
AutorNombre: fromName,
|
||||
EmailFrom: fromEmail,
|
||||
Titulo: e.Subject,
|
||||
Descripcion: contenido,
|
||||
Estado: "abierto",
|
||||
Origen: "email",
|
||||
MessageID: e.MessageID,
|
||||
}
|
||||
if cfg != nil && cfg.AsignarA != nil {
|
||||
ticket.AsignadoA = cfg.AsignarA
|
||||
}
|
||||
if err := models.CreateProyectoTicket(ticket); err != nil {
|
||||
log.Printf("[Soporte] Error creando ticket: %v", err)
|
||||
return false
|
||||
}
|
||||
log.Printf("[Soporte] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject)
|
||||
|
||||
SendSoporteNotifAdmin(ticket)
|
||||
if cfg != nil && cfg.ResponderAuto {
|
||||
SendSoporteAutoRespuesta(ticket)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta:
|
||||
// primero por el token "[Ticket #N]" en el asunto (verificando que sea del mismo
|
||||
// remitente, para que nadie pueda inyectar mensajes en el ticket de otro
|
||||
// adivinando el número), y si no hay token, por el último ticket abierto de ese
|
||||
// remitente cuando el asunto tiene pinta de respuesta (Re:/RE:/Fwd:).
|
||||
func buscarTicketDeHilo(fromEmail, subject string) *models.ProyectoTicket {
|
||||
if m := ticketRefRe.FindStringSubmatch(subject); m != nil {
|
||||
id, _ := strconv.ParseUint(m[1], 10, 32)
|
||||
if id > 0 {
|
||||
t, err := models.GetTicketByID(uint(id))
|
||||
if err == nil && strings.EqualFold(t.EmailFrom, fromEmail) {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
lower := strings.ToLower(strings.TrimSpace(subject))
|
||||
if strings.HasPrefix(lower, "re:") || strings.HasPrefix(lower, "fwd:") || strings.HasPrefix(lower, "fw:") {
|
||||
if t, err := models.GetUltimoTicketAbiertoPorEmail(fromEmail); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtraerEmail saca la dirección de un "Nombre <mail@x.com>".
|
||||
func ExtraerEmail(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.LastIndex(s, "<"); idx >= 0 {
|
||||
s = s[idx+1:]
|
||||
}
|
||||
if idx := strings.LastIndex(s, ">"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// ExtraerNombre saca el nombre de un "Nombre <mail@x.com>" ("" si no trae).
|
||||
func ExtraerNombre(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.Index(s, "<"); idx >= 0 {
|
||||
return strings.TrimSpace(s[:idx])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+13
-13
@@ -1,4 +1,4 @@
|
||||
package controllers
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -18,9 +18,9 @@ func TestExtractEmailSimple(t *testing.T) {
|
||||
{"<onlybrackets>", "onlybrackets"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := extractEmail(tt.input)
|
||||
got := ExtraerEmail(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractEmail(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
t.Errorf("ExtraerEmail(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,9 @@ func TestExtractNameSimple(t *testing.T) {
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := extractName(tt.input)
|
||||
got := ExtraerNombre(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
t.Errorf("ExtraerNombre(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,24 +57,24 @@ func TestExtractEmailRealWorld(t *testing.T) {
|
||||
{"", "", ""},
|
||||
}
|
||||
for _, tt := range inputs {
|
||||
gotMail := extractEmail(tt.full)
|
||||
gotName := extractName(tt.full)
|
||||
gotMail := ExtraerEmail(tt.full)
|
||||
gotName := ExtraerNombre(tt.full)
|
||||
if gotMail != tt.mail {
|
||||
t.Errorf("extractEmail(%q) = %q, want %q", tt.full, gotMail, tt.mail)
|
||||
t.Errorf("ExtraerEmail(%q) = %q, want %q", tt.full, gotMail, tt.mail)
|
||||
}
|
||||
if gotName != tt.name {
|
||||
t.Errorf("extractName(%q) = %q, want %q", tt.full, gotName, tt.name)
|
||||
t.Errorf("ExtraerNombre(%q) = %q, want %q", tt.full, gotName, tt.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmailEdgeCases(t *testing.T) {
|
||||
// Formato RFC 5322 con nombre y ángulos
|
||||
if got := extractEmail("a<b@c.com>"); got != "b@c.com" {
|
||||
t.Errorf("extractEmail('a<b@c.com>') = %q, want 'b@c.com'", got)
|
||||
if got := ExtraerEmail("a<b@c.com>"); got != "b@c.com" {
|
||||
t.Errorf("ExtraerEmail('a<b@c.com>') = %q, want 'b@c.com'", got)
|
||||
}
|
||||
// Múltiples brackets — usa el último par
|
||||
if got := extractEmail("<a><b@c.com>"); got != "b@c.com" {
|
||||
t.Errorf("extractEmail('<a><b@c.com>') = %q, want 'b@c.com'", got)
|
||||
if got := ExtraerEmail("<a><b@c.com>"); got != "b@c.com" {
|
||||
t.Errorf("ExtraerEmail('<a><b@c.com>') = %q, want 'b@c.com'", got)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,24 @@
|
||||
<span class="block mt-1 text-blue-500">Cada tipo de documento pasa campos adicionales propios (ver /api/v2/spec).</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 border border-dashed border-gray-300 rounded p-3 bg-gray-50">
|
||||
<p class="text-xs font-medium text-gray-600 mb-1">¿Ya tenés el documento hecho?</p>
|
||||
<p class="text-xs text-gray-500 mb-2">
|
||||
Subí el archivo (.docx, .html, .txt o una foto/captura del documento) y la IA lo devuelve
|
||||
armado como plantilla, con las variables puestas. Después lo editás acá abajo antes de guardar.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input type="file" x-ref="archivo" accept=".docx,.html,.htm,.txt,.md,image/*"
|
||||
class="text-xs" />
|
||||
<button type="button" @click="importar()" :disabled="importando"
|
||||
class="px-3 py-1.5 border rounded text-xs disabled:opacity-50">
|
||||
<span x-text="importando ? 'Leyendo…' : 'Leer con IA'"></span>
|
||||
</button>
|
||||
<span x-show="avisoImport" x-text="avisoImport" class="text-xs"
|
||||
:class="errorImport ? 'text-red-600' : 'text-green-600'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="save()">
|
||||
<div class="grid grid-cols-3 gap-3 mb-3">
|
||||
<div>
|
||||
@@ -154,6 +172,7 @@ document.addEventListener('alpine:init', () => {
|
||||
datos: [], total: 0, totalPages: 1, page: 1, limit: 20, filtroTipo: '',
|
||||
addModal: false, editModal: false, deleteModal: false,
|
||||
selectedId: null, templateError: '',
|
||||
importando: false, avisoImport: '', errorImport: false,
|
||||
form: { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true },
|
||||
toast: { show:false, msg:'', type:'ok' },
|
||||
|
||||
@@ -179,6 +198,26 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
|
||||
|
||||
async importar() {
|
||||
const f = this.$refs.archivo?.files?.[0];
|
||||
if (!f) { this.errorImport = true; this.avisoImport = 'Elegí un archivo primero'; return; }
|
||||
this.importando = true; this.avisoImport = ''; this.errorImport = false;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('archivo', f);
|
||||
fd.append('tipo', this.form.tipo);
|
||||
const { data } = await axios.post('/app/api/plantillas-documento/importar', fd);
|
||||
this.form.contenido_html = data.contenido_html || '';
|
||||
if (!this.form.nombre) this.form.nombre = f.name.replace(/\.[^.]+$/, '');
|
||||
this.errorImport = !!data.aviso;
|
||||
this.avisoImport = data.aviso || 'Listo, revisá el HTML abajo';
|
||||
} catch(e) {
|
||||
this.errorImport = true;
|
||||
this.avisoImport = e.response?.data?.error || e.message;
|
||||
}
|
||||
this.importando = false;
|
||||
},
|
||||
|
||||
async validar() {
|
||||
this.templateError = '';
|
||||
},
|
||||
@@ -187,6 +226,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.addModal = this.editModal = this.deleteModal = false;
|
||||
this.selectedId = null;
|
||||
this.templateError = '';
|
||||
this.avisoImport = ''; this.errorImport = false;
|
||||
this.form = { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true };
|
||||
},
|
||||
|
||||
|
||||
@@ -110,6 +110,76 @@
|
||||
<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>
|
||||
|
||||
<!-- ─── IMAP entrante ────────────────────────────────────────── -->
|
||||
<div class="border-t border-slate-200 pt-5">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<p class="text-sm font-semibold text-slate-700">Buzón entrante (IMAP)</p>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" x-model="cfg.imap_activo" class="rounded"> Leer el buzón cada 2 minutos
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mb-3">
|
||||
Alternativa al webhook: en vez de esperar que el proveedor nos avise, entramos al buzón y bajamos
|
||||
los correos sin leer. Cada uno abre un ticket (o se agrega al hilo si es respuesta) y queda marcado como leído.
|
||||
</p>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-3">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-xs font-medium text-slate-600 mb-1">Servidor IMAP</label>
|
||||
<input x-model="cfg.imap_host" type="text" placeholder="imap.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">Puerto</label>
|
||||
<input x-model="cfg.imap_port" type="number" placeholder="993"
|
||||
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md: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.imap_username" 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">Contraseña</label>
|
||||
<input x-model="cfg.imap_password" type="password"
|
||||
:placeholder="tieneImapPassword ? 'Guardada — escribí una nueva para cambiarla' : '••••••••'"
|
||||
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-3 mt-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600 mb-1">Encriptación</label>
|
||||
<select x-model="cfg.imap_encryption" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||
<option value="ssl">SSL/TLS (puerto 993)</option>
|
||||
<option value="starttls">STARTTLS (puerto 143)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-600 mb-1">Carpeta</label>
|
||||
<input x-model="cfg.imap_carpeta" type="text" placeholder="INBOX"
|
||||
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-3">
|
||||
<button @click="probarImap()" :disabled="ocupado"
|
||||
class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 disabled:opacity-50">
|
||||
Probar conexión
|
||||
</button>
|
||||
<button @click="revisarAhora()" :disabled="ocupado"
|
||||
class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 disabled:opacity-50">
|
||||
Revisar buzón ahora
|
||||
</button>
|
||||
<span x-show="mensajeImap" x-text="mensajeImap" class="text-sm self-center"
|
||||
:class="errorImap ? 'text-red-600' : 'text-green-600'"></span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-2">Probá la conexión después de guardar: la contraseña se cifra al guardarse.</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<button @click="guardar()"
|
||||
class="px-6 py-2 rounded-xl text-white text-sm font-medium transition-colors"
|
||||
@@ -146,8 +216,19 @@ function soporteWebhook() {
|
||||
smtp_encryption: 'starttls',
|
||||
smtp_from_addr: '',
|
||||
smtp_from_name: '',
|
||||
imap_activo: false,
|
||||
imap_host: '',
|
||||
imap_port: 993,
|
||||
imap_username: '',
|
||||
imap_password: '',
|
||||
imap_encryption: 'ssl',
|
||||
imap_carpeta: 'INBOX',
|
||||
},
|
||||
admins: [],
|
||||
tieneImapPassword: false,
|
||||
ocupado: false,
|
||||
mensajeImap: '',
|
||||
errorImap: false,
|
||||
|
||||
async init() {
|
||||
try {
|
||||
@@ -155,6 +236,11 @@ function soporteWebhook() {
|
||||
if (r.data && r.data.data) {
|
||||
this.cfg = { ...this.cfg, ...r.data.data };
|
||||
this.cfg.asignar_a = r.data.data.asignar_a || '';
|
||||
// gorm.Model serializa la clave primaria como "ID"; sin esto el
|
||||
// guardado creaba una fila nueva cada vez y los cambios no se veían.
|
||||
this.cfg.id = r.data.data.ID || r.data.data.id || 0;
|
||||
this.cfg.imap_password = '';
|
||||
this.tieneImapPassword = !!r.data.data.tiene_imap_password;
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
@@ -193,14 +279,40 @@ function soporteWebhook() {
|
||||
smtp_encryption: this.cfg.smtp_encryption || 'starttls',
|
||||
smtp_from_addr: this.cfg.smtp_from_addr || '',
|
||||
smtp_from_name: this.cfg.smtp_from_name || '',
|
||||
imap_activo: !!this.cfg.imap_activo,
|
||||
imap_host: this.cfg.imap_host || '',
|
||||
imap_port: parseInt(this.cfg.imap_port) || 993,
|
||||
imap_username: this.cfg.imap_username || '',
|
||||
imap_password: this.cfg.imap_password || '',
|
||||
imap_encryption: this.cfg.imap_encryption || 'ssl',
|
||||
imap_carpeta: this.cfg.imap_carpeta || 'INBOX',
|
||||
};
|
||||
try {
|
||||
await axios.post('/app/soporte/webhook', payload);
|
||||
if (payload.imap_password) { this.tieneImapPassword = true; this.cfg.imap_password = ''; }
|
||||
await this.init();
|
||||
alert('Configuración guardada');
|
||||
} catch (e) {
|
||||
alert('Error al guardar: ' + (e.response?.data?.error || e.message));
|
||||
}
|
||||
},
|
||||
|
||||
async llamarImap(url, okPorDefecto) {
|
||||
this.ocupado = true;
|
||||
this.mensajeImap = '';
|
||||
try {
|
||||
const r = await axios.post(url);
|
||||
this.errorImap = false;
|
||||
this.mensajeImap = r.data?.message || okPorDefecto;
|
||||
} catch (e) {
|
||||
this.errorImap = true;
|
||||
this.mensajeImap = e.response?.data?.error || e.message;
|
||||
}
|
||||
this.ocupado = false;
|
||||
},
|
||||
|
||||
probarImap() { return this.llamarImap('/app/soporte/webhook/probar-imap', 'Conexión correcta'); },
|
||||
revisarAhora() { return this.llamarImap('/app/soporte/webhook/revisar-buzon', 'Buzón revisado'); },
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"text/template"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
)
|
||||
|
||||
// ─── Plantillas de documento (cotización, contrato, acta, cuenta de cobro) ────
|
||||
@@ -103,6 +105,48 @@ func DeletePlantillaDocumento(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
|
||||
}
|
||||
|
||||
// ImportarPlantillaDocumento recibe un documento (docx, html, txt o imagen), lo
|
||||
// lee y le pide a la IA que lo devuelva como plantilla HTML con las variables
|
||||
// del generador. NO guarda nada: el HTML vuelve al editor para revisarlo.
|
||||
// POST /app/api/plantillas-documento/importar (multipart: archivo, tipo)
|
||||
func ImportarPlantillaDocumento(c *fiber.Ctx) error {
|
||||
archivo, err := c.FormFile("archivo")
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Subí un archivo en el campo 'archivo'"})
|
||||
}
|
||||
if archivo.Size > 10<<20 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "El archivo supera los 10 MB"})
|
||||
}
|
||||
f, err := archivo.Open()
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "No se pudo leer el archivo"})
|
||||
}
|
||||
defer f.Close()
|
||||
datos, err := io.ReadAll(io.LimitReader(f, 10<<20))
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "No se pudo leer el archivo"})
|
||||
}
|
||||
|
||||
texto, err := services.ExtraerTextoDePlantilla(archivo.Filename, datos)
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
tipo := c.FormValue("tipo", "cotizacion")
|
||||
html, err := services.ConvertirEnPlantilla(tipo, texto)
|
||||
if err != nil {
|
||||
return c.Status(502).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
// Si la IA devolvió algo que no compila, es mejor decirlo acá que al guardar.
|
||||
if _, err := template.New("validate").Parse(html); err != nil {
|
||||
return c.Status(200).JSON(fiber.Map{
|
||||
"contenido_html": html,
|
||||
"aviso": "La IA devolvió HTML con sintaxis de plantilla inválida (" + err.Error() + "). Revisalo antes de guardar.",
|
||||
})
|
||||
}
|
||||
return c.JSON(fiber.Map{"contenido_html": html})
|
||||
}
|
||||
|
||||
// ─── Tarifas ────────────────────────────────────────────────────────────────
|
||||
|
||||
func GetTarifas(c *fiber.Ctx) error {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
@@ -28,7 +27,6 @@ type soporteEmailIn struct {
|
||||
MessageID string `json:"message_id" form:"message_id"`
|
||||
}
|
||||
|
||||
var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`)
|
||||
var messageIDHeaderRe = regexp.MustCompile(`(?im)^Message-ID:\s*(<[^>\r\n]+>)`)
|
||||
|
||||
// validarWebhookKey compara la key recibida (query ?key= o header X-Webhook-Key /
|
||||
@@ -121,111 +119,19 @@ func SoporteWebhook(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
for _, e := range emails {
|
||||
if e.From == "" || e.Subject == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Deduplicación: el proveedor puede reintentar la entrega del mismo correo.
|
||||
if models.EmailMessageIDYaProcesado(e.MessageID) {
|
||||
log.Printf("[SoporteWebhook] Correo duplicado ignorado (message_id=%s)", e.MessageID)
|
||||
continue
|
||||
}
|
||||
|
||||
fromEmail := extractEmail(e.From)
|
||||
fromName := e.FromName
|
||||
if fromName == "" {
|
||||
fromName = extractName(e.From)
|
||||
}
|
||||
if fromName == "" {
|
||||
fromName = fromEmail
|
||||
}
|
||||
|
||||
contenido := e.Text
|
||||
if contenido == "" {
|
||||
contenido = e.Html
|
||||
}
|
||||
contenido = strings.TrimSpace(contenido)
|
||||
if len(contenido) > 5000 {
|
||||
contenido = contenido[:5000]
|
||||
}
|
||||
|
||||
// Enhebrado: si el asunto trae "[Ticket #N]" (lo agregamos nosotros en el
|
||||
// auto-ack) y ese ticket es del mismo remitente, es una respuesta — se
|
||||
// agrega como mensaje en vez de abrir un ticket nuevo. Si no hay token
|
||||
// pero el remitente tiene un ticket abierto reciente, también se enhebra.
|
||||
if hilo := buscarTicketDeHilo(fromEmail, e.Subject); hilo != nil {
|
||||
msg := &models.TicketMensaje{
|
||||
TicketID: hilo.ID,
|
||||
Contenido: contenido,
|
||||
EsAdmin: false,
|
||||
AutorNombre: fromName,
|
||||
MessageID: e.MessageID,
|
||||
}
|
||||
if err := models.CreateTicketMensaje(msg); err != nil {
|
||||
log.Printf("[SoporteWebhook] Error agregando mensaje al ticket #%d: %v", hilo.ID, err)
|
||||
continue
|
||||
}
|
||||
if hilo.Estado == "resuelto" || hilo.Estado == "cerrado" {
|
||||
_ = models.UpdateTicketEstado(hilo.ID, "abierto")
|
||||
}
|
||||
log.Printf("[SoporteWebhook] Respuesta agregada al ticket #%d (%s)", hilo.ID, fromEmail)
|
||||
continue
|
||||
}
|
||||
|
||||
ticket := &models.ProyectoTicket{
|
||||
AutorNombre: fromName,
|
||||
EmailFrom: fromEmail,
|
||||
Titulo: e.Subject,
|
||||
Descripcion: contenido,
|
||||
Estado: "abierto",
|
||||
Origen: "email",
|
||||
MessageID: e.MessageID,
|
||||
}
|
||||
if cfg.AsignarA != nil {
|
||||
ticket.AsignadoA = cfg.AsignarA
|
||||
}
|
||||
if err := models.CreateProyectoTicket(ticket); err != nil {
|
||||
log.Printf("[SoporteWebhook] Error creando ticket: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[SoporteWebhook] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject)
|
||||
|
||||
// Notificar admin
|
||||
services.SendSoporteNotifAdmin(ticket)
|
||||
|
||||
// Auto-responder (el asunto incluye [Ticket #N] para poder enhebrar la respuesta)
|
||||
if cfg.ResponderAuto {
|
||||
services.SendSoporteAutoRespuesta(ticket)
|
||||
}
|
||||
services.IngestarCorreoSoporte(cfg, services.CorreoSoporte{
|
||||
From: e.From, FromName: e.FromName, Subject: e.Subject,
|
||||
Texto: contenido, MessageID: e.MessageID,
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(200).JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta:
|
||||
// primero por el token "[Ticket #N]" en el asunto (verificando que sea del mismo
|
||||
// remitente, para que nadie pueda inyectar mensajes en el ticket de otro
|
||||
// adivinando el número), y si no hay token, por el último ticket abierto de ese
|
||||
// remitente cuando el asunto tiene pinta de respuesta (Re:/RE:/Fwd:).
|
||||
func buscarTicketDeHilo(fromEmail, subject string) *models.ProyectoTicket {
|
||||
if m := ticketRefRe.FindStringSubmatch(subject); m != nil {
|
||||
id, _ := strconv.ParseUint(m[1], 10, 32)
|
||||
if id > 0 {
|
||||
t, err := models.GetTicketByID(uint(id))
|
||||
if err == nil && strings.EqualFold(t.EmailFrom, fromEmail) {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
lower := strings.ToLower(strings.TrimSpace(subject))
|
||||
if strings.HasPrefix(lower, "re:") || strings.HasPrefix(lower, "fwd:") || strings.HasPrefix(lower, "fw:") {
|
||||
if t, err := models.GetUltimoTicketAbiertoPorEmail(fromEmail); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractMessageIDFromHeaders(headers string) string {
|
||||
if headers == "" {
|
||||
return ""
|
||||
@@ -291,6 +197,7 @@ func GetSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.JSON(fiber.Map{"data": nil})
|
||||
}
|
||||
cfg.TieneImapPassword = cfg.ImapPasswordEnc != ""
|
||||
return c.JSON(fiber.Map{"data": cfg})
|
||||
}
|
||||
|
||||
@@ -311,6 +218,13 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
SmtpEncryption string `json:"smtp_encryption"`
|
||||
SmtpFromAddr string `json:"smtp_from_addr"`
|
||||
SmtpFromName string `json:"smtp_from_name"`
|
||||
ImapActivo bool `json:"imap_activo"`
|
||||
ImapHost string `json:"imap_host"`
|
||||
ImapPort int `json:"imap_port"`
|
||||
ImapUsername string `json:"imap_username"`
|
||||
ImapPassword string `json:"imap_password"`
|
||||
ImapEncryption string `json:"imap_encryption"`
|
||||
ImapCarpeta string `json:"imap_carpeta"`
|
||||
}
|
||||
var b body
|
||||
if err := c.BodyParser(&b); err != nil {
|
||||
@@ -324,22 +238,56 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
if enc == "" {
|
||||
enc = "starttls"
|
||||
}
|
||||
imapPort := b.ImapPort
|
||||
if imapPort == 0 {
|
||||
imapPort = 993
|
||||
}
|
||||
imapEnc := b.ImapEncryption
|
||||
if imapEnc == "" {
|
||||
imapEnc = "ssl"
|
||||
}
|
||||
carpeta := b.ImapCarpeta
|
||||
if carpeta == "" {
|
||||
carpeta = "INBOX"
|
||||
}
|
||||
// La contraseña IMAP solo viaja cuando el admin la escribe de nuevo: si el
|
||||
// formulario la manda vacía, se conserva la que ya estaba guardada.
|
||||
passEnc := ""
|
||||
if b.ImapPassword != "" {
|
||||
enc, err := services.CifrarSecretoUmind(b.ImapPassword)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
passEnc = enc
|
||||
} else if b.ID > 0 {
|
||||
if actual, err := models.GetSoporteWebhookActivo(); err == nil && actual != nil && actual.ID == b.ID {
|
||||
passEnc = actual.ImapPasswordEnc
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &models.SoporteWebhookConfig{
|
||||
Nombre: b.Nombre,
|
||||
Provider: b.Provider,
|
||||
ApiKey: b.ApiKey,
|
||||
EmailDestino: b.EmailDestino,
|
||||
ResponderAuto: b.ResponderAuto,
|
||||
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,
|
||||
Nombre: b.Nombre,
|
||||
Provider: b.Provider,
|
||||
ApiKey: b.ApiKey,
|
||||
EmailDestino: b.EmailDestino,
|
||||
ResponderAuto: b.ResponderAuto,
|
||||
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,
|
||||
ImapActivo: b.ImapActivo,
|
||||
ImapHost: b.ImapHost,
|
||||
ImapPort: imapPort,
|
||||
ImapUsername: b.ImapUsername,
|
||||
ImapPasswordEnc: passEnc,
|
||||
ImapEncryption: imapEnc,
|
||||
ImapCarpeta: carpeta,
|
||||
Activo: true,
|
||||
}
|
||||
cfg.ID = b.ID
|
||||
if err := models.SaveSoporteWebhookConfig(cfg); err != nil {
|
||||
@@ -350,21 +298,22 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func extractEmail(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.LastIndex(s, "<"); idx >= 0 {
|
||||
s = s[idx+1:]
|
||||
// ProbarImapSoporte valida las credenciales del buzón contra el servidor.
|
||||
// POST /app/api/soporte-webhook/probar-imap
|
||||
func ProbarImapSoporte(c *fiber.Ctx) error {
|
||||
cfg, err := models.GetSoporteWebhookActivo()
|
||||
if err != nil || cfg == nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Guardá la configuración antes de probar"})
|
||||
}
|
||||
if idx := strings.LastIndex(s, ">"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
if err := services.ProbarConexionImap(cfg); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
return c.JSON(fiber.Map{"ok": true, "message": "Conexión IMAP correcta"})
|
||||
}
|
||||
|
||||
func extractName(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.Index(s, "<"); idx >= 0 {
|
||||
return strings.TrimSpace(s[:idx])
|
||||
}
|
||||
return ""
|
||||
// RevisarBuzonAhora dispara una lectura del buzón sin esperar al cron.
|
||||
// POST /app/api/soporte-webhook/revisar-buzon
|
||||
func RevisarBuzonAhora(c *fiber.Ctx) error {
|
||||
services.RevisarBuzonSoporte()
|
||||
return c.JSON(fiber.Map{"ok": true, "message": "Buzón revisado, mirá la lista de tickets"})
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ func RenovacionesRoutes(protected fiber.Router) {
|
||||
|
||||
// ─── Automatización IA: Plantillas de documento ────────────────────
|
||||
protected.Get("/automatizacion/plantillas", middlewares.MenuMiddleware, controllers.PlantillasDocumentoView)
|
||||
protected.Post("/api/plantillas-documento/importar", controllers.ImportarPlantillaDocumento)
|
||||
protected.Get("/api/plantillas-documento", controllers.GetPlantillasDocumento)
|
||||
protected.Get("/api/plantillas-documento/:id", controllers.GetPlantillaDocumento)
|
||||
protected.Post("/api/plantillas-documento", controllers.CreatePlantillaDocumento)
|
||||
|
||||
@@ -536,6 +536,8 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Get("/soporte/webhook", middlewares.MenuMiddleware, controllers.SoporteWebhookConfigPage)
|
||||
protected.Get("/soporte/webhook/data", controllers.GetSoporteWebhookConfig)
|
||||
protected.Post("/soporte/webhook", controllers.SaveSoporteWebhookConfig)
|
||||
protected.Post("/soporte/webhook/probar-imap", controllers.ProbarImapSoporte)
|
||||
protected.Post("/soporte/webhook/revisar-buzon", controllers.RevisarBuzonAhora)
|
||||
|
||||
// Configuración de notificaciones
|
||||
protected.Get("/notif-config", middlewares.MenuMiddleware, controllers.NotifConfigIndex)
|
||||
|
||||
Reference in New Issue
Block a user