package notify import ( "bytes" "encoding/json" "fmt" "log" "net/http" "net/smtp" "os" "strings" "time" "github.com/jordan-wright/email" ) // Config holds notification configuration type Config struct { // Feishu webhook FeishuWebhookURL string // SMTP SMTPHost string SMTPPort string SMTPUser string SMTPPassword string SMTPFrom string NotifyTo string // comma-separated email recipients } // LoadNotifyConfig reads notification config from environment 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 { // Build card content 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"}, ) } // Summary expiringCount := 0 for _, cert := range certs { if cert.DaysLeft <= 7 { expiringCount++ } } // Determine template color 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 { // Build HTML email var sb strings.Builder sb.WriteString(`
以下证书即将到期或已过期,请及时处理:
| 域名 | 状态 | 剩余天数 | 过期时间 |
|---|---|---|---|
| %s | %s | %d 天 | %s |
此邮件由 AutoSSL 证书管理系统自动发送,请勿回复。