// Copyright 2026 certctl LLC. All rights reserved. // SPDX-License-Identifier: BUSL-1.1 package service import ( "bytes" "context" "fmt" "html/template" "log/slog" "time" "github.com/certctl-io/certctl/internal/repository" ) // DigestService generates and sends periodic certificate digest emails. // It aggregates statistics from StatsService and sends HTML-formatted // summary emails to configured recipients. type DigestService struct { statsService *StatsService certRepo repository.CertificateRepository ownerRepo repository.OwnerRepository emailSender HTMLEmailSender recipients []string logger *slog.Logger } // HTMLEmailSender defines the interface for sending HTML emails. // Implemented by the email notifier adapter. type HTMLEmailSender interface { SendHTML(ctx context.Context, recipient string, subject string, htmlBody string) error } // DigestData holds the aggregated data for a digest email. type DigestData struct { GeneratedAt time.Time `json:"generated_at"` TotalCertificates int64 `json:"total_certificates"` ExpiringCertificates int64 `json:"expiring_certificates"` ExpiredCertificates int64 `json:"expired_certificates"` RevokedCertificates int64 `json:"revoked_certificates"` ActiveAgents int64 `json:"active_agents"` OfflineAgents int64 `json:"offline_agents"` TotalAgents int64 `json:"total_agents"` PendingJobs int64 `json:"pending_jobs"` FailedJobs int64 `json:"failed_jobs"` CompletedJobs int64 `json:"completed_jobs"` ExpiringCerts []DigestCertEntry `json:"expiring_certs"` RecentFailures []DigestJobEntry `json:"recent_failures"` StatusCounts []DigestStatusCount `json:"status_counts"` } // DigestCertEntry represents a certificate entry in the digest. type DigestCertEntry struct { ID string `json:"id"` CommonName string `json:"common_name"` ExpiresAt time.Time `json:"expires_at"` DaysLeft int `json:"days_left"` OwnerID string `json:"owner_id"` } // DigestJobEntry represents a failed job entry in the digest. type DigestJobEntry struct { ID string `json:"id"` CertificateID string `json:"certificate_id"` Type string `json:"type"` Error string `json:"error"` } // DigestStatusCount represents certificate counts by status for the digest. type DigestStatusCount struct { Status string `json:"status"` Count int64 `json:"count"` } // NewDigestService creates a new digest service. func NewDigestService( statsService *StatsService, certRepo repository.CertificateRepository, ownerRepo repository.OwnerRepository, emailSender HTMLEmailSender, recipients []string, logger *slog.Logger, ) *DigestService { if logger == nil { logger = slog.Default() } return &DigestService{ statsService: statsService, certRepo: certRepo, ownerRepo: ownerRepo, emailSender: emailSender, recipients: recipients, logger: logger, } } // GenerateDigest aggregates current system statistics into a DigestData struct. func (s *DigestService) GenerateDigest(ctx context.Context) (*DigestData, error) { digest := &DigestData{ GeneratedAt: time.Now(), } // Get dashboard summary summaryRaw, err := s.statsService.GetDashboardSummary(ctx) if err != nil { return nil, fmt.Errorf("failed to get dashboard summary: %w", err) } if summary, ok := summaryRaw.(*DashboardSummary); ok { digest.TotalCertificates = summary.TotalCertificates digest.ExpiringCertificates = summary.ExpiringCertificates digest.ExpiredCertificates = summary.ExpiredCertificates digest.RevokedCertificates = summary.RevokedCertificates digest.ActiveAgents = summary.ActiveAgents digest.OfflineAgents = summary.OfflineAgents digest.TotalAgents = summary.TotalAgents digest.PendingJobs = summary.PendingJobs digest.FailedJobs = summary.FailedJobs digest.CompletedJobs = summary.CompleteJobs } // Get certificates by status statusRaw, err := s.statsService.GetCertificatesByStatus(ctx) if err != nil { s.logger.Warn("failed to get status counts for digest", "error", err) } else if counts, ok := statusRaw.([]CertificateStatusCount); ok { for _, c := range counts { digest.StatusCounts = append(digest.StatusCounts, DigestStatusCount(c)) } } // Get expiring certificates (next 30 days) now := time.Now() thirtyDaysFromNow := now.AddDate(0, 0, 30) allCerts, _, err := s.certRepo.List(ctx, &repository.CertificateFilter{Page: 1, PerPage: 10000}) if err != nil { s.logger.Warn("failed to list certs for digest", "error", err) } else { for _, cert := range allCerts { if !cert.ExpiresAt.IsZero() && cert.ExpiresAt.After(now) && cert.ExpiresAt.Before(thirtyDaysFromNow) { daysLeft := int(time.Until(cert.ExpiresAt).Hours() / 24) digest.ExpiringCerts = append(digest.ExpiringCerts, DigestCertEntry{ ID: cert.ID, CommonName: cert.CommonName, ExpiresAt: cert.ExpiresAt, DaysLeft: daysLeft, OwnerID: cert.OwnerID, }) } } } return digest, nil } // SendDigest generates a digest and sends it to all configured recipients. func (s *DigestService) SendDigest(ctx context.Context) error { if s.emailSender == nil { return fmt.Errorf("email sender not configured — set CERTCTL_SMTP_HOST and CERTCTL_SMTP_FROM_ADDRESS") } digest, err := s.GenerateDigest(ctx) if err != nil { return fmt.Errorf("failed to generate digest: %w", err) } htmlBody, err := s.RenderDigestHTML(digest) if err != nil { return fmt.Errorf("failed to render digest HTML: %w", err) } subject := fmt.Sprintf("certctl Certificate Digest — %s", digest.GeneratedAt.Format("2006-01-02")) recipients := s.recipients if len(recipients) == 0 { // Fall back to owner emails recipients = s.resolveOwnerEmails(ctx) } if len(recipients) == 0 { s.logger.Warn("no digest recipients configured and no owner emails found") return nil } var sendErrors int for _, recipient := range recipients { if err := s.emailSender.SendHTML(ctx, recipient, subject, htmlBody); err != nil { s.logger.Error("failed to send digest to recipient", "recipient", recipient, "error", err) sendErrors++ } else { s.logger.Info("digest email sent", "recipient", recipient) } } if sendErrors > 0 { return fmt.Errorf("failed to send digest to %d of %d recipients", sendErrors, len(recipients)) } return nil } // ProcessDigest is the scheduler-facing method. It generates and sends the digest, // logging errors rather than propagating them to match the scheduler pattern. func (s *DigestService) ProcessDigest(ctx context.Context) error { return s.SendDigest(ctx) } // RenderDigestHTML renders the digest data into an HTML email body. func (s *DigestService) RenderDigestHTML(data *DigestData) (string, error) { tmpl, err := template.New("digest").Parse(digestHTMLTemplate) if err != nil { return "", fmt.Errorf("failed to parse digest template: %w", err) } var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { return "", fmt.Errorf("failed to execute digest template: %w", err) } return buf.String(), nil } // PreviewDigest generates and renders a digest without sending it. // Used by the API handler for preview endpoints. func (s *DigestService) PreviewDigest(ctx context.Context) (string, error) { digest, err := s.GenerateDigest(ctx) if err != nil { return "", fmt.Errorf("failed to generate digest: %w", err) } return s.RenderDigestHTML(digest) } // resolveOwnerEmails collects unique email addresses from all certificate owners. func (s *DigestService) resolveOwnerEmails(ctx context.Context) []string { if s.ownerRepo == nil { return nil } owners, err := s.ownerRepo.List(ctx) if err != nil { s.logger.Warn("failed to list owners for digest recipients", "error", err) return nil } seen := make(map[string]bool) var emails []string for _, owner := range owners { if owner.Email != "" && !seen[owner.Email] { seen[owner.Email] = true emails = append(emails, owner.Email) } } return emails } // digestHTMLTemplate is the HTML template for the certificate digest email. const digestHTMLTemplate = `
| Common Name | Expires | Days Left |
|---|---|---|
| {{.CommonName}} | {{.ExpiresAt.Format "Jan 2, 2006"}} | {{if le .DaysLeft 7}}{{.DaysLeft}} days {{else if le .DaysLeft 14}}{{.DaysLeft}} days {{else}}{{.DaysLeft}} days {{end}} |