feat: Move mail system to shared
Server Deploy / deploy (push) Successful in 2m46s

This commit is contained in:
2026-07-28 09:50:46 +01:00
parent 5e326335af
commit a232c74990
49 changed files with 1206 additions and 706 deletions
+24 -76
View File
@@ -1,90 +1,38 @@
package notify
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/shared/mail"
)
const smtpTimeout = 15 * time.Second
// dispatchSMTP delivers a state change over one channel's own SMTP settings.
//
// The transport, the envelope and the look of the message all live in
// shared/mail, which admin and sitesvc use too — a Vantage alert and a Vantage
// licence email should not look like they came from different products. This
// function only turns a channel document into a Sender.
func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
host := ch.Config["host"]
port := ch.Config["port"]
from := ch.Config["from"]
to := ch.Config["to"]
if host == "" || port == "" || from == "" || to == "" {
sender := mail.Sender{
Host: ch.Config["host"],
Port: ch.Config["port"],
From: ch.Config["from"],
Username: ch.Config["username"],
Password: ch.Config["password"],
}
if !sender.Enabled() || sender.Port == "" || to == "" {
return fmt.Errorf("smtp: missing host/port/from/to")
}
addr := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", addr, smtpTimeout)
if err != nil {
return fmt.Errorf("smtp: dial %s: %w", addr, err)
}
_ = conn.SetDeadline(time.Now().Add(smtpTimeout))
if port == "465" {
conn = tls.Client(conn, &tls.Config{ServerName: host})
}
c, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: client: %w", err)
}
defer c.Close()
if port != "465" {
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
}
}
}
if user := ch.Config["username"]; user != "" {
if err := c.Auth(smtp.PlainAuth("", user, ch.Config["password"], host)); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
}
}
recipients := strings.Split(to, ",")
for i := range recipients {
recipients[i] = strings.TrimSpace(recipients[i])
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("smtp: mail from: %w", err)
}
for _, rcpt := range recipients {
if rcpt == "" {
continue
}
if err := c.Rcpt(rcpt); err != nil {
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
}
}
msg, err := buildMIME(from, to, ev.title(), textEmail(ev), htmlEmail(ev))
if err != nil {
return fmt.Errorf("smtp: build message: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp: data: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("smtp: write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp: close data: %w", err)
}
return c.Quit()
return sender.SendMonitorAlert(to, mail.MonitorEvent{
MonitorName: ev.MonitorName,
Type: ev.Type,
OldStatus: ev.OldStatus,
NewStatus: ev.NewStatus,
Message: ev.Message,
Time: ev.Time,
Down: ev.NewStatus == models.StatusDown,
})
}
-161
View File
@@ -1,161 +0,0 @@
package notify
import (
"fmt"
"html"
"mime/multipart"
"net/textproto"
"strings"
"github.com/mrhid6/vantage/server/internal/models"
)
const (
colBg = "#0f1117"
colSurface = "#1a1d27"
colSurface2 = "#232635"
colBorder = "#2e3147"
colText = "#e8eaf0"
colTextMuted = "#9095a8"
colAccent = "#6366f1"
colSuccess = "#22c55e"
colDanger = "#ef4444"
)
func statusColor(status string) string {
switch status {
case models.StatusUp:
return colSuccess
case models.StatusDown:
return colDanger
default:
return colTextMuted
}
}
func buildMIME(from, to, subject, text, htmlBody string) ([]byte, error) {
var buf strings.Builder
w := multipart.NewWriter(&buf)
var head strings.Builder
head.WriteString("From: " + from + "\r\n")
head.WriteString("To: " + to + "\r\n")
head.WriteString("Subject: " + subject + "\r\n")
head.WriteString("MIME-Version: 1.0\r\n")
head.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n\r\n", w.Boundary()))
textPart, err := w.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=UTF-8"}})
if err != nil {
return nil, err
}
textPart.Write([]byte(text))
htmlPart, err := w.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/html; charset=UTF-8"}})
if err != nil {
return nil, err
}
htmlPart.Write([]byte(htmlBody))
if err := w.Close(); err != nil {
return nil, err
}
return []byte(head.String() + buf.String()), nil
}
func htmlEmail(ev Event) string {
accent := statusColor(ev.NewStatus)
label := "Recovered"
if ev.NewStatus == models.StatusDown {
label = "Down"
}
esc := html.EscapeString
row := func(k, v string) string {
if v == "" {
v = ""
}
return fmt.Sprintf(
`<tr>`+
`<td style="padding:8px 0;color:%s;font-size:13px;width:120px;">%s</td>`+
`<td style="padding:8px 0;color:%s;font-size:13px;font-weight:500;">%s</td>`+
`</tr>`,
colTextMuted, k, colText, esc(v))
}
message := ""
if ev.Message != "" {
message = fmt.Sprintf(
`<p style="margin:0 0 20px;padding:12px 14px;background:%s;border:1px solid %s;border-radius:8px;color:%s;font-size:13px;">%s</p>`,
colSurface2, colBorder, colText, esc(ev.Message))
}
transition := esc(ev.OldStatus) + " → " + esc(ev.NewStatus)
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:%s;">
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:%s;padding:32px 0;">
<tr>
<td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="max-width:480px;background:%s;border:1px solid %s;border-radius:12px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<tr><td style="height:4px;background:%s;"></td></tr>
<tr>
<td style="padding:28px 28px 20px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="margin-bottom:18px;">
<tr>
<td style="font-size:18px;font-weight:700;color:%s;">Vantage</td>
</tr>
</table>
<span style="display:inline-block;padding:4px 12px;border-radius:9999px;background:%s22;color:%s;font-size:12px;font-weight:600;letter-spacing:.02em;">%s</span>
<h1 style="margin:14px 0 6px;font-size:20px;font-weight:700;color:%s;">%s</h1>
<p style="margin:0 0 20px;color:%s;font-size:13px;">%s check</p>
%s
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="border-top:1px solid %s;">
%s
%s
%s
</table>
</td>
</tr>
<tr>
<td style="padding:16px 28px;border-top:1px solid %s;">
<p style="margin:0;color:%s;font-size:12px;">Sent by Vantage · self-hosted service monitoring</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`,
colBg,
colBg,
colSurface, colBorder,
accent,
colText,
accent, accent, label,
colText, esc(ev.MonitorName),
colTextMuted, esc(ev.Type),
message,
colBorder,
row("Status", transition),
row("Type", ev.Type),
row("Time", ev.Time.Format("2006-01-02 15:04:05 MST")),
colBorder,
colTextMuted,
)
}
func textEmail(ev Event) string {
return strings.Join([]string{
ev.title(),
"",
"Monitor: " + ev.MonitorName,
"Type: " + ev.Type,
"Status: " + ev.OldStatus + " -> " + ev.NewStatus,
"Message: " + ev.Message,
"Time: " + ev.Time.Format("2006-01-02 15:04:05 MST"),
"",
"Sent by Vantage · self-hosted service monitoring",
}, "\r\n")
}