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(`

🔐 证书到期提醒

以下证书即将到期或已过期,请及时处理:

`) 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(` `, bgColor, color, cert.Domain, statusStr, color, cert.DaysLeft, expiresStr)) } sb.WriteString(`
域名 状态 剩余天数 过期时间
%s %s %d 天 %s

此邮件由 AutoSSL 证书管理系统自动发送,请勿回复。

`) 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 } }