Files
Auto-ssl/backend/notify/notify.go
T
cnbugs bbf09fbc8d feat: add Web UI for notification config (feishu + email)
- New config/notify_store.go: persistent notification config (notify.json)
- New API: GET/PUT /api/notify/config, POST /api/notify/test-feishu, POST /api/notify/test-email
- New Vue page: NotifyConfig.vue with feishu webhook and SMTP settings
- Updated cron/init to read from persistent store instead of env vars
- Password masked as ******** in API responses, preserved on updates
2026-07-23 16:31:04 +08:00

314 lines
8.0 KiB
Go

package notify
import (
"auto-ssl/config"
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"net/smtp"
"os"
"strings"
"time"
"github.com/jordan-wright/email"
)
// Config holds notification configuration
type Config struct {
FeishuWebhookURL string
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPassword string
SMTPFrom string
NotifyTo string
ExpiryDays int
}
// ConfigFromStore converts store config to notify config
func ConfigFromStore(nc *config.NotifyConfig) *Config {
return &Config{
FeishuWebhookURL: nc.FeishuURL,
SMTPHost: nc.SMTPHost,
SMTPPort: nc.SMTPPort,
SMTPUser: nc.SMTPUser,
SMTPPassword: nc.SMTPPassword,
SMTPFrom: nc.SMTPFrom,
NotifyTo: nc.NotifyTo,
ExpiryDays: nc.ExpiryDays,
}
}
// LoadNotifyConfig reads notification config from environment (legacy fallback)
func LoadNotifyConfig() *Config {
return &Config{
FeishuWebhookURL: os.Getenv("FEISHU_WEBHOOK_URL"),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: os.Getenv("SMTP_PORT"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
SMTPFrom: os.Getenv("SMTP_FROM"),
NotifyTo: os.Getenv("NOTIFY_TO"),
}
}
// IsConfigured returns true if at least one notification channel is configured
func (c *Config) IsConfigured() bool {
return c.FeishuWebhookURL != "" || (c.SMTPHost != "" && c.NotifyTo != "")
}
// CertInfo holds certificate information for notifications
type CertInfo struct {
Domain string `json:"domain"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expires_at"`
DaysLeft int `json:"days_left"`
Error string `json:"error,omitempty"`
}
// NotifyCertExpiring sends notifications for expiring certificates
func (c *Config) NotifyCertExpiring(certs []CertInfo) error {
if len(certs) == 0 {
return nil
}
if !c.IsConfigured() {
log.Println("Notification not configured, skipping")
return nil
}
var errs []string
if c.FeishuWebhookURL != "" {
if err := c.sendFeishu(certs); err != nil {
errs = append(errs, fmt.Sprintf("feishu: %v", err))
}
}
if c.SMTPHost != "" && c.NotifyTo != "" {
if err := c.sendEmail(certs); err != nil {
errs = append(errs, fmt.Sprintf("email: %v", err))
}
}
if len(errs) > 0 {
return fmt.Errorf("notification errors: %s", strings.Join(errs, "; "))
}
return nil
}
// Feishu message format
type feishuCard struct {
MsgType string `json:"msg_type"`
Card *feishuCardContent `json:"card"`
}
type feishuCardContent struct {
Header feishuHeader `json:"header"`
Elems []feishuElement `json:"elements"`
}
type feishuHeader struct {
Title feishuText `json:"title"`
Template string `json:"template"`
}
type feishuText struct {
Tag string `json:"tag"`
Content string `json:"content"`
}
type feishuElement struct {
Tag string `json:"tag"`
Text *feishuText `json:"text,omitempty"`
Fields *[]feishuField `json:"fields,omitempty"`
}
type feishuField struct {
IsShort bool `json:"is_short"`
Text feishuText `json:"text"`
}
func (c *Config) sendFeishu(certs []CertInfo) error {
elements := []feishuElement{}
for _, cert := range certs {
domainText := fmt.Sprintf("**域名:** %s", cert.Domain)
statusText := fmt.Sprintf("**状态:** %s", certStatusLabel(cert.Status))
daysText := fmt.Sprintf("**剩余天数:** %d", cert.DaysLeft)
expiresText := "**过期时间:** "
if cert.ExpiresAt != nil {
expiresText += cert.ExpiresAt.Format("2006-01-02 15:04")
} else {
expiresText += "未知"
}
elements = append(elements,
feishuElement{
Tag: "div",
Fields: &[]feishuField{
{IsShort: true, Text: feishuText{Tag: "lark_md", Content: domainText}},
{IsShort: true, Text: feishuText{Tag: "lark_md", Content: statusText}},
},
},
feishuElement{
Tag: "div",
Fields: &[]feishuField{
{IsShort: true, Text: feishuText{Tag: "lark_md", Content: daysText}},
{IsShort: true, Text: feishuText{Tag: "lark_md", Content: expiresText}},
},
},
feishuElement{Tag: "hr"},
)
}
expiringCount := 0
for _, cert := range certs {
if cert.DaysLeft <= 7 {
expiringCount++
}
}
template := "blue"
if expiringCount > 0 {
template = "red"
}
card := &feishuCardContent{
Header: feishuHeader{
Title: feishuText{
Tag: "lark_md",
Content: fmt.Sprintf("🔐 证书到期提醒 (%d 个证书即将到期)", len(certs)),
},
Template: template,
},
Elems: elements,
}
payload := feishuCard{
MsgType: "interactive",
Card: card,
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal feishu message failed: %v", err)
}
resp, err := http.Post(c.FeishuWebhookURL, "application/json", bytes.NewReader(data))
if err != nil {
return fmt.Errorf("send feishu message failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
return fmt.Errorf("feishu webhook returned %d: %v", resp.StatusCode, result)
}
log.Printf("Feishu notification sent for %d certificates", len(certs))
return nil
}
func (c *Config) sendEmail(certs []CertInfo) error {
var sb strings.Builder
sb.WriteString(`
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; padding: 20px; background: #f5f5f5;">
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h2 style="color: #1a1a2e;">🔐 证书到期提醒</h2>
<p style="color: #666;">以下证书即将到期或已过期,请及时处理:</p>
<table style="width: 100%; border-collapse: collapse; margin-top: 16px;">
<tr style="background: #1890ff; color: white;">
<th style="padding: 10px; text-align: left;">域名</th>
<th style="padding: 10px; text-align: left;">状态</th>
<th style="padding: 10px; text-align: left;">剩余天数</th>
<th style="padding: 10px; text-align: left;">过期时间</th>
</tr>
`)
for i, cert := range certs {
bgColor := "#fff"
if i%2 == 0 {
bgColor = "#f8f9fa"
}
color := "#333"
if cert.DaysLeft <= 7 {
color = "#f5222d"
} else if cert.DaysLeft <= 30 {
color = "#fa8c16"
}
expiresStr := "未知"
if cert.ExpiresAt != nil {
expiresStr = cert.ExpiresAt.Format("2006-01-02 15:04")
}
statusStr := certStatusLabel(cert.Status)
sb.WriteString(fmt.Sprintf(`
<tr style="background: %s;">
<td style="padding: 10px; border-bottom: 1px solid #eee; font-weight: 600; color: %s;">%s</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">%s</td>
<td style="padding: 10px; border-bottom: 1px solid #eee; color: %s; font-weight: 700;">%d 天</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">%s</td>
</tr>
`, bgColor, color, cert.Domain, statusStr, color, cert.DaysLeft, expiresStr))
}
sb.WriteString(`
</table>
<p style="color: #999; font-size: 12px; margin-top: 24px;">
此邮件由 AutoSSL 证书管理系统自动发送,请勿回复。
</p>
</div>
</body>
</html>
`)
htmlContent := sb.String()
subject := fmt.Sprintf("🔐 [AutoSSL] %d 个证书即将到期 - 请及时处理", len(certs))
e := email.NewEmail()
e.From = c.SMTPFrom
if e.From == "" {
e.From = c.SMTPUser
}
e.To = strings.Split(c.NotifyTo, ",")
e.Subject = subject
e.HTML = []byte(htmlContent)
addr := fmt.Sprintf("%s:%s", c.SMTPHost, c.SMTPPort)
var auth smtp.Auth
if c.SMTPUser != "" && c.SMTPPassword != "" {
auth = smtp.PlainAuth("", c.SMTPUser, c.SMTPPassword, c.SMTPHost)
}
if err := e.Send(addr, auth); err != nil {
return fmt.Errorf("send email failed: %v", err)
}
log.Printf("Email notification sent to %s for %d certificates", c.NotifyTo, len(certs))
return nil
}
func certStatusLabel(status string) string {
switch status {
case "active":
return "有效"
case "expired":
return "已过期"
case "error":
return "错误"
case "renewing":
return "续期中"
case "pending":
return "申请中"
default:
return status
}
}