Files
pulse/internal/api/report_schedules.go
T
rcourtman eecf45fafd Email the Patrol weekly summary as a report schedule kind
The customers most likely to lapse are the ones who stopped opening Pulse,
so the in-app "This week" card cannot reach them. Report schedules gain a
kind: the default resources kind is the existing PDF or CSV performance
report, and patrol_digest emails the same weekly "what Patrol did for you"
rollup the Patrol page shows, in plain language, for the whole workspace.
It reuses the existing scheduler, cadence, recipients, tenant email config,
and Pro advanced-reporting entitlement rather than adding a second
scheduler. Digest schedules are weekly and email-only; the server fixes
scope, format, and attachments, and a run with no AI service, no Patrol, or
no email destination records a last_error that names the missing piece
instead of sending an empty week. Nothing is written to disk.

The digest assembly moves from the HTTP handler into
AISettingsHandler.BuildPatrolDigest so the schedule runner and the
endpoint share one code path. docs/MSP.md documents the kind for
providers; the Settings > Reporting "Report type" selector follows in a
separate change once it has a Pro-licensed browser pass.
2026-09-02 11:15:22 +01:00

1032 lines
36 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
"github.com/rs/zerolog/log"
)
const (
reportScheduleAttachmentLimitBytes = 15 * 1024 * 1024
reportScheduleTickInterval = time.Minute
reportScheduleMissedRunGrace = 24 * time.Hour
)
type reportScheduleListResponse struct {
Schedules []config.ReportSchedule `json:"schedules"`
}
type reportScheduleRunResponse struct {
Schedule config.ReportSchedule `json:"schedule"`
Status string `json:"status"`
Path string `json:"path,omitempty"`
Email string `json:"email,omitempty"`
}
func (h *ReportingHandlers) HandleListReportSchedules(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
store, err := h.loadReportScheduleStore(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
return
}
writeReportScheduleJSON(w, http.StatusOK, reportScheduleListResponse{Schedules: store.Schedules})
}
func (h *ReportingHandlers) HandleCreateReportSchedule(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var incoming config.ReportSchedule
if err := decodeReportScheduleBody(w, r, &incoming); err != nil {
return
}
persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
return
}
now := time.Now().UTC()
incoming.ID = uuid.NewString()
incoming.CreatedAt = now
incoming.UpdatedAt = now
schedule, err := h.prepareReportSchedule(r.Context(), incoming, now, false)
if err != nil {
writeReportScheduleValidationError(w, err)
return
}
store.Schedules = append(store.Schedules, schedule)
if err := persistence.SaveReportScheduleStore(*store); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule", nil)
return
}
writeReportScheduleJSON(w, http.StatusCreated, schedule)
}
func (h *ReportingHandlers) HandleUpdateReportSchedule(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
scheduleID := strings.TrimSpace(r.PathValue("id"))
if scheduleID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_schedule_id", "Schedule ID is required", nil)
return
}
var incoming config.ReportSchedule
if err := decodeReportScheduleBody(w, r, &incoming); err != nil {
return
}
persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
return
}
index := findReportScheduleIndex(store.Schedules, scheduleID)
if index < 0 {
writeErrorResponse(w, http.StatusNotFound, "schedule_not_found", "Report schedule not found", nil)
return
}
now := time.Now().UTC()
existing := store.Schedules[index]
incoming.ID = existing.ID
incoming.CreatedAt = existing.CreatedAt
incoming.UpdatedAt = now
incoming.LastRunAt = existing.LastRunAt
incoming.LastRunStatus = existing.LastRunStatus
incoming.LastError = existing.LastError
incoming.LastOccurrenceKey = existing.LastOccurrenceKey
schedule, err := h.prepareReportSchedule(r.Context(), incoming, now, false)
if err != nil {
writeReportScheduleValidationError(w, err)
return
}
store.Schedules[index] = schedule
if err := persistence.SaveReportScheduleStore(*store); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule", nil)
return
}
writeReportScheduleJSON(w, http.StatusOK, schedule)
}
func (h *ReportingHandlers) HandleDeleteReportSchedule(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
scheduleID := strings.TrimSpace(r.PathValue("id"))
if scheduleID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_schedule_id", "Schedule ID is required", nil)
return
}
persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
return
}
index := findReportScheduleIndex(store.Schedules, scheduleID)
if index < 0 {
writeErrorResponse(w, http.StatusNotFound, "schedule_not_found", "Report schedule not found", nil)
return
}
store.Schedules = append(store.Schedules[:index], store.Schedules[index+1:]...)
if err := persistence.SaveReportScheduleStore(*store); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule", nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *ReportingHandlers) HandleRunReportSchedule(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
scheduleID := strings.TrimSpace(r.PathValue("id"))
if scheduleID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_schedule_id", "Schedule ID is required", nil)
return
}
persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
return
}
index := findReportScheduleIndex(store.Schedules, scheduleID)
if index < 0 {
writeErrorResponse(w, http.StatusNotFound, "schedule_not_found", "Report schedule not found", nil)
return
}
result, updated := h.runReportSchedule(r.Context(), persistence, store.Schedules[index], time.Now().UTC(), true, "")
store.Schedules[index] = updated
if err := persistence.SaveReportScheduleStore(*store); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule status", nil)
return
}
status := http.StatusOK
if updated.LastRunStatus == config.ReportScheduleLastRunFailed {
status = http.StatusInternalServerError
}
writeReportScheduleJSON(w, status, reportScheduleRunResponse{
Schedule: updated,
Status: updated.LastRunStatus,
Path: result.path,
Email: result.email,
})
}
func decodeReportScheduleBody(w http.ResponseWriter, r *http.Request, target *config.ReportSchedule) error {
r.Body = http.MaxBytesReader(w, r.Body, reportingMultiReportBodyMax)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_body", "Invalid report schedule body", nil)
return err
}
if err := decoder.Decode(&struct{}{}); err != nil && !errors.Is(err, io.EOF) {
writeErrorResponse(w, http.StatusBadRequest, "invalid_body", "Invalid report schedule body", nil)
return err
}
return nil
}
func writeReportScheduleJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
type reportScheduleValidationError struct {
code string
message string
}
func (e reportScheduleValidationError) Error() string {
return e.message
}
func writeReportScheduleValidationError(w http.ResponseWriter, err error) {
var validationErr reportScheduleValidationError
if errors.As(err, &validationErr) {
writeErrorResponse(w, http.StatusBadRequest, validationErr.code, validationErr.message, nil)
return
}
writeErrorResponse(w, http.StatusBadRequest, "invalid_schedule", err.Error(), nil)
}
func (h *ReportingHandlers) reportSchedulePersistenceAndStore(ctx context.Context) (*config.ConfigPersistence, *config.ReportScheduleStore, error) {
persistence, err := h.reportSchedulePersistence(ctx)
if err != nil {
return nil, nil, err
}
store, err := persistence.LoadReportScheduleStore()
if err != nil {
return nil, nil, err
}
if store == nil {
empty := config.EmptyReportScheduleStore()
store = &empty
}
return persistence, store, nil
}
func (h *ReportingHandlers) loadReportScheduleStore(ctx context.Context) (*config.ReportScheduleStore, error) {
_, store, err := h.reportSchedulePersistenceAndStore(ctx)
return store, err
}
func (h *ReportingHandlers) reportSchedulePersistence(ctx context.Context) (*config.ConfigPersistence, error) {
if h == nil || h.mtMonitor == nil {
return nil, fmt.Errorf("multi-tenant monitor is not configured")
}
orgID := GetOrgID(ctx)
if strings.TrimSpace(orgID) == "" {
orgID = "default"
}
monitor, err := h.mtMonitor.GetMonitor(orgID)
if err != nil {
return nil, err
}
persistence := monitor.GetConfigPersistence()
if persistence == nil {
return nil, fmt.Errorf("config persistence is not configured")
}
return persistence, nil
}
func findReportScheduleIndex(schedules []config.ReportSchedule, id string) int {
for i := range schedules {
if schedules[i].ID == id {
return i
}
}
return -1
}
func (h *ReportingHandlers) prepareReportSchedule(ctx context.Context, schedule config.ReportSchedule, now time.Time, statusOnly bool) (config.ReportSchedule, error) {
schedule = config.NormalizeReportSchedule(schedule)
if schedule.ID == "" {
return schedule, reportScheduleValidationError{code: "invalid_schedule_id", message: "Schedule ID is required"}
}
if statusOnly {
return schedule, nil
}
if schedule.Name == "" || len(schedule.Name) > 80 {
return schedule, reportScheduleValidationError{code: "invalid_name", message: "Schedule name is required and must be 80 characters or fewer"}
}
switch schedule.Kind {
case config.ReportScheduleKindResources:
case config.ReportScheduleKindPatrolDigest:
return preparePatrolDigestSchedule(schedule, now)
default:
return schedule, reportScheduleValidationError{code: "invalid_kind", message: "Schedule kind must be resources or patrol_digest"}
}
if schedule.Format == "" {
schedule.Format = config.ReportScheduleFormatPDF
}
if schedule.Format != config.ReportScheduleFormatPDF && schedule.Format != config.ReportScheduleFormatCSV {
return schedule, reportScheduleValidationError{code: "invalid_format", message: "Schedule format must be pdf or csv"}
}
if schedule.Delivery.Method == "" {
schedule.Delivery.Method = config.ReportScheduleDeliveryEmail
}
if schedule.Delivery.Method != config.ReportScheduleDeliveryEmail && schedule.Delivery.Method != config.ReportScheduleDeliveryDisk {
return schedule, reportScheduleValidationError{code: "invalid_delivery", message: "Delivery method must be email or disk"}
}
if !schedule.Delivery.Attach && !schedule.Delivery.SaveToDisk {
schedule.Delivery.Attach = true
schedule.Delivery.SaveToDisk = true
}
if err := validateReportScheduleCadence(schedule.Cadence); err != nil {
return schedule, err
}
if err := h.validateReportScheduleScope(ctx, schedule.Scope); err != nil {
return schedule, err
}
next, err := nextReportScheduleRunAt(schedule, now)
if err != nil {
return schedule, err
}
schedule.NextRunAt = &next
return schedule, nil
}
func validateReportScheduleCadence(cadence config.ReportScheduleCadence) error {
if cadence.Type != config.ReportScheduleCadenceMonthly && cadence.Type != config.ReportScheduleCadenceWeekly {
return reportScheduleValidationError{code: "invalid_cadence", message: "Cadence must be monthly or weekly"}
}
if cadence.Type == config.ReportScheduleCadenceMonthly && (cadence.DayOfMonth < 1 || cadence.DayOfMonth > 28) {
return reportScheduleValidationError{code: "invalid_day_of_month", message: "Monthly schedules must use a day from 1 to 28"}
}
if cadence.Type == config.ReportScheduleCadenceWeekly {
if _, ok := parseReportScheduleWeekday(cadence.Weekday); !ok {
return reportScheduleValidationError{code: "invalid_weekday", message: "Weekly schedules must include a weekday"}
}
}
if _, err := time.Parse("15:04", cadence.Time); err != nil {
return reportScheduleValidationError{code: "invalid_time", message: "Schedule time must use HH:MM"}
}
if _, err := reportScheduleLocation(cadence.Timezone); err != nil {
return reportScheduleValidationError{code: "invalid_timezone", message: "Schedule timezone must be a valid IANA timezone"}
}
return nil
}
func (h *ReportingHandlers) validateReportScheduleScope(ctx context.Context, scope config.ReportScheduleScope) error {
if len(scope.Resources) == 0 && len(scope.Tags) == 0 {
return reportScheduleValidationError{code: "invalid_scope", message: "Select at least one resource or tag"}
}
definition := performanceReportDefinition()
if len(scope.Resources) > definition.MultiResourceMax {
return reportScheduleValidationError{code: "too_many_resources", message: fmt.Sprintf("Maximum %d resources allowed", definition.MultiResourceMax)}
}
for _, res := range scope.Resources {
if !validResourceID.MatchString(res.ResourceID) || len(res.ResourceID) > 128 {
return reportScheduleValidationError{code: "invalid_resource_id", message: "Resource IDs must match [a-zA-Z0-9._:-]+ and be 128 characters or fewer"}
}
if _, err := normalizeReportResourceType(res.ResourceType); err != nil {
return reportScheduleValidationError{code: "invalid_resource_type", message: err.Error()}
}
}
for _, tag := range scope.Tags {
if len(tag) > 64 || strings.ContainsAny(tag, "\r\n\t") {
return reportScheduleValidationError{code: "invalid_tag", message: "Tags must be 64 characters or fewer and cannot contain control characters"}
}
}
if len(scope.Tags) > 0 {
if _, err := h.resolveReportScheduleResources(ctx, GetOrgID(ctx), scope); err != nil {
return err
}
}
return nil
}
func (h *ReportingHandlers) resolveReportScheduleResources(ctx context.Context, orgID string, scope config.ReportScheduleScope) ([]multiReportResourceEntry, error) {
definition := performanceReportDefinition()
entries := make([]multiReportResourceEntry, 0, len(scope.Resources))
seen := map[string]struct{}{}
appendEntry := func(resourceType, resourceID string) error {
resourceType, err := normalizeReportResourceType(resourceType)
if err != nil {
return err
}
key := resourceType + "\x00" + resourceID
if _, exists := seen[key]; exists {
return nil
}
seen[key] = struct{}{}
entries = append(entries, multiReportResourceEntry{ResourceType: resourceType, ResourceID: resourceID})
return nil
}
for _, res := range scope.Resources {
if err := appendEntry(res.ResourceType, res.ResourceID); err != nil {
return nil, reportScheduleValidationError{code: "invalid_resource_type", message: err.Error()}
}
}
if len(scope.Tags) > 0 {
snapshot, ok := h.getReportingEnrichmentSnapshot(ctx, orgID)
if !ok {
return nil, reportScheduleValidationError{code: "scope_unavailable", message: "Tagged schedule scope cannot be resolved until resource inventory is available"}
}
tags := map[string]struct{}{}
for _, tag := range scope.Tags {
tags[strings.ToLower(strings.TrimSpace(tag))] = struct{}{}
}
for _, resource := range snapshot.Resources {
if !resourceHasAnyReportScheduleTag(resource, tags) {
continue
}
resourceType := reporting.CanonicalResourceType(string(resource.Type))
if resourceType == "" || strings.TrimSpace(resource.ID) == "" {
continue
}
if err := appendEntry(resourceType, resource.ID); err != nil {
continue
}
}
}
if len(entries) == 0 {
return nil, reportScheduleValidationError{code: "empty_scope", message: "Schedule scope did not match any reportable resources"}
}
if len(entries) > definition.MultiResourceMax {
return nil, reportScheduleValidationError{code: "too_many_resources", message: fmt.Sprintf("Maximum %d resources allowed", definition.MultiResourceMax)}
}
return entries, nil
}
func resourceHasAnyReportScheduleTag(resource unifiedresources.Resource, tags map[string]struct{}) bool {
for _, tag := range resource.Tags {
if _, ok := tags[strings.ToLower(strings.TrimSpace(tag))]; ok {
return true
}
}
return false
}
type reportScheduleRunResult struct {
path string
email string
}
func (h *ReportingHandlers) runReportSchedule(ctx context.Context, persistence *config.ConfigPersistence, schedule config.ReportSchedule, now time.Time, manual bool, occurrenceKey string) (reportScheduleRunResult, config.ReportSchedule) {
h.scheduleRunMu.Lock()
defer h.scheduleRunMu.Unlock()
schedule = config.NormalizeReportSchedule(schedule)
result := reportScheduleRunResult{}
schedule.LastRunAt = &now
schedule.UpdatedAt = now
if occurrenceKey != "" {
schedule.LastOccurrenceKey = occurrenceKey
}
if next, err := nextReportScheduleRunAt(schedule, now); err == nil {
schedule.NextRunAt = &next
}
if err := h.requireCommercialFeature(ctx, featureAdvancedReportingValue); err != nil {
return result, markReportScheduleFailed(schedule, now, fmt.Errorf("advanced reporting entitlement required: %w", err))
}
if schedule.Kind == config.ReportScheduleKindPatrolDigest {
return h.runPatrolDigestSchedule(ctx, persistence, schedule, now)
}
orgID := GetOrgID(ctx)
resources, err := h.resolveReportScheduleResources(ctx, orgID, schedule.Scope)
if err != nil {
return result, markReportScheduleFailed(schedule, now, err)
}
start, end, err := reportScheduleWindow(schedule, now, manual)
if err != nil {
return result, markReportScheduleFailed(schedule, now, err)
}
report, err := h.generateMultiReportFromBody(ctx, multiReportRequestBody{
Resources: resources,
Format: schedule.Format,
Start: start.UTC().Format(time.RFC3339),
End: end.UTC().Format(time.RFC3339),
Title: schedule.Name,
}, now)
if err != nil {
return result, markReportScheduleFailed(schedule, now, err)
}
path, err := saveGeneratedReport(persistence, schedule, report)
if err != nil {
return result, markReportScheduleFailed(schedule, now, err)
}
result.path = path
if schedule.Delivery.Method == config.ReportScheduleDeliveryEmail {
emailStatus, err := sendScheduledReportEmail(persistence, schedule, report, path)
if err != nil {
return result, markReportScheduleFailed(schedule, now, err)
}
result.email = emailStatus
}
schedule.LastRunStatus = config.ReportScheduleLastRunOK
schedule.LastError = ""
return result, schedule
}
func markReportScheduleFailed(schedule config.ReportSchedule, now time.Time, err error) config.ReportSchedule {
schedule.LastRunStatus = config.ReportScheduleLastRunFailed
schedule.LastError = strings.TrimSpace(err.Error())
schedule.LastRunAt = &now
schedule.UpdatedAt = now
return schedule
}
func reportScheduleWindow(schedule config.ReportSchedule, now time.Time, manual bool) (time.Time, time.Time, error) {
location, err := reportScheduleLocation(schedule.Cadence.Timezone)
if err != nil {
return time.Time{}, time.Time{}, err
}
end := now.In(location)
if !manual {
occurrence, _, err := lastReportScheduleOccurrenceAt(schedule, now)
if err == nil {
end = occurrence.In(location)
}
}
switch schedule.Cadence.Type {
case config.ReportScheduleCadenceMonthly:
firstThisMonth := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, location)
firstPreviousMonth := firstThisMonth.AddDate(0, -1, 0)
return firstPreviousMonth.UTC(), firstThisMonth.UTC(), nil
case config.ReportScheduleCadenceWeekly:
return end.AddDate(0, 0, -7).UTC(), end.UTC(), nil
default:
return time.Time{}, time.Time{}, fmt.Errorf("unsupported cadence %q", schedule.Cadence.Type)
}
}
func saveGeneratedReport(persistence *config.ConfigPersistence, schedule config.ReportSchedule, report generatedMultiReport) (string, error) {
baseDir, err := securityutil.JoinStorageLeaf(persistence.DataDir(), "reports")
if err != nil {
return "", err
}
generatedDir, err := securityutil.JoinStorageLeaf(baseDir, "generated")
if err != nil {
return "", err
}
scheduleDir, err := securityutil.JoinStorageLeaf(generatedDir, securityutil.HashedStorageName(schedule.ID))
if err != nil {
return "", err
}
if err := os.MkdirAll(scheduleDir, 0o700); err != nil {
return "", fmt.Errorf("create report output directory: %w", err)
}
filename := sanitizeFilename(report.Filename)
if filename == "" {
filename = "report." + string(report.Format)
}
path, err := securityutil.JoinStorageLeaf(scheduleDir, filename)
if err != nil {
return "", err
}
if err := os.WriteFile(path, report.Data, 0o600); err != nil {
return "", fmt.Errorf("write generated report: %w", err)
}
pruneGeneratedReports(scheduleDir, schedule.RetentionCount)
return path, nil
}
func pruneGeneratedReports(dir string, retentionCount int) {
if retentionCount <= 0 {
retentionCount = config.DefaultReportScheduleRetentionCount
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type generatedFile struct {
path string
modTime time.Time
}
files := make([]generatedFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
path, err := securityutil.JoinStorageLeaf(dir, entry.Name())
if err != nil {
continue
}
files = append(files, generatedFile{path: path, modTime: info.ModTime()})
}
sort.Slice(files, func(i, j int) bool {
return files[i].modTime.After(files[j].modTime)
})
for i := retentionCount; i < len(files); i++ {
_ = os.Remove(files[i].path)
}
}
// scheduledReportEmailManager builds the email manager for a schedule from the
// tenant email config. It returns nil, nil when email is not configured or no
// recipient can be resolved, so callers decide how to report that.
func scheduledReportEmailManager(persistence *config.ConfigPersistence, schedule config.ReportSchedule) (*notifications.EnhancedEmailManager, error) {
emailCfg, err := persistence.LoadEmailConfig()
if err != nil {
return nil, fmt.Errorf("load email config: %w", err)
}
if emailCfg == nil || !emailCfg.Enabled {
return nil, nil
}
recipients := schedule.Delivery.To
if len(recipients) == 0 {
recipients = emailCfg.To
}
if len(recipients) == 0 && strings.TrimSpace(emailCfg.From) != "" {
recipients = []string{emailCfg.From}
}
if len(recipients) == 0 {
return nil, nil
}
providerConfig := notifications.EmailProviderConfig{
EmailConfig: notifications.EmailConfig{
Provider: emailCfg.Provider,
From: emailCfg.From,
To: recipients,
SMTPHost: emailCfg.SMTPHost,
SMTPPort: emailCfg.SMTPPort,
Username: emailCfg.Username,
Password: emailCfg.Password,
TLS: emailCfg.TLS,
StartTLS: emailCfg.StartTLS,
RateLimit: emailCfg.RateLimit,
},
Provider: emailCfg.Provider,
MaxRetries: 2,
RetryDelay: 3,
RateLimit: emailCfg.RateLimit,
StartTLS: emailCfg.StartTLS,
AuthRequired: emailCfg.Username != "" && emailCfg.Password != "",
}
return notifications.NewEnhancedEmailManager(providerConfig), nil
}
func sendScheduledReportEmail(persistence *config.ConfigPersistence, schedule config.ReportSchedule, report generatedMultiReport, path string) (string, error) {
manager, err := scheduledReportEmailManager(persistence, schedule)
if err != nil {
return "", err
}
if manager == nil {
return "saved_to_disk_email_unconfigured", nil
}
subject := "Pulse report: " + schedule.Name
htmlBody := "<p>Your scheduled Pulse report is ready.</p>"
textBody := "Your scheduled Pulse report is ready."
attachments := []notifications.EmailAttachment{}
if schedule.Delivery.Attach && len(report.Data) <= reportScheduleAttachmentLimitBytes {
attachments = append(attachments, notifications.EmailAttachment{
Filename: report.Filename,
ContentType: report.ContentType,
Data: report.Data,
})
} else if schedule.Delivery.Attach && len(report.Data) > reportScheduleAttachmentLimitBytes {
htmlBody = "<p>Your scheduled Pulse report was generated but is too large to attach.</p><p>Saved path: " + htmlEscape(path) + "</p>"
textBody = "Your scheduled Pulse report was generated but is too large to attach.\nSaved path: " + path
} else {
htmlBody = "<p>Your scheduled Pulse report was generated and saved to disk.</p><p>Saved path: " + htmlEscape(path) + "</p>"
textBody = "Your scheduled Pulse report was generated and saved to disk.\nSaved path: " + path
}
if err := manager.SendEmailWithAttachments(subject, htmlBody, textBody, attachments); err != nil {
return "", err
}
if len(attachments) > 0 {
return "sent_with_attachment", nil
}
return "sent_saved_path", nil
}
func htmlEscape(value string) string {
replacer := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&#39;")
return replacer.Replace(value)
}
func reportScheduleLocation(name string) (*time.Location, error) {
name = strings.TrimSpace(name)
if name == "" || name == "Local" {
name = "UTC"
}
return time.LoadLocation(name)
}
func parseReportScheduleClock(clock string) (hour, minute int, err error) {
parsed, err := time.Parse("15:04", strings.TrimSpace(clock))
if err != nil {
return 0, 0, err
}
return parsed.Hour(), parsed.Minute(), nil
}
func parseReportScheduleWeekday(value string) (time.Weekday, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "sunday", "sun":
return time.Sunday, true
case "monday", "mon":
return time.Monday, true
case "tuesday", "tue":
return time.Tuesday, true
case "wednesday", "wed":
return time.Wednesday, true
case "thursday", "thu":
return time.Thursday, true
case "friday", "fri":
return time.Friday, true
case "saturday", "sat":
return time.Saturday, true
default:
return time.Sunday, false
}
}
func nextReportScheduleRunAt(schedule config.ReportSchedule, after time.Time) (time.Time, error) {
location, err := reportScheduleLocation(schedule.Cadence.Timezone)
if err != nil {
return time.Time{}, err
}
hour, minute, err := parseReportScheduleClock(schedule.Cadence.Time)
if err != nil {
return time.Time{}, err
}
localAfter := after.In(location)
switch schedule.Cadence.Type {
case config.ReportScheduleCadenceMonthly:
candidate := time.Date(localAfter.Year(), localAfter.Month(), schedule.Cadence.DayOfMonth, hour, minute, 0, 0, location)
if !candidate.After(localAfter) {
candidate = candidate.AddDate(0, 1, 0)
}
return candidate.UTC(), nil
case config.ReportScheduleCadenceWeekly:
weekday, ok := parseReportScheduleWeekday(schedule.Cadence.Weekday)
if !ok {
return time.Time{}, fmt.Errorf("invalid weekday")
}
dayDelta := (int(weekday) - int(localAfter.Weekday()) + 7) % 7
candidateDate := localAfter.AddDate(0, 0, dayDelta)
candidate := time.Date(candidateDate.Year(), candidateDate.Month(), candidateDate.Day(), hour, minute, 0, 0, location)
if !candidate.After(localAfter) {
candidate = candidate.AddDate(0, 0, 7)
}
return candidate.UTC(), nil
default:
return time.Time{}, fmt.Errorf("unsupported cadence %q", schedule.Cadence.Type)
}
}
func lastReportScheduleOccurrenceAt(schedule config.ReportSchedule, now time.Time) (time.Time, string, error) {
location, err := reportScheduleLocation(schedule.Cadence.Timezone)
if err != nil {
return time.Time{}, "", err
}
hour, minute, err := parseReportScheduleClock(schedule.Cadence.Time)
if err != nil {
return time.Time{}, "", err
}
localNow := now.In(location)
var occurrence time.Time
switch schedule.Cadence.Type {
case config.ReportScheduleCadenceMonthly:
occurrence = time.Date(localNow.Year(), localNow.Month(), schedule.Cadence.DayOfMonth, hour, minute, 0, 0, location)
if occurrence.After(localNow) {
occurrence = occurrence.AddDate(0, -1, 0)
}
case config.ReportScheduleCadenceWeekly:
weekday, ok := parseReportScheduleWeekday(schedule.Cadence.Weekday)
if !ok {
return time.Time{}, "", fmt.Errorf("invalid weekday")
}
dayDelta := (int(localNow.Weekday()) - int(weekday) + 7) % 7
occurrenceDate := localNow.AddDate(0, 0, -dayDelta)
occurrence = time.Date(occurrenceDate.Year(), occurrenceDate.Month(), occurrenceDate.Day(), hour, minute, 0, 0, location)
if occurrence.After(localNow) {
occurrence = occurrence.AddDate(0, 0, -7)
}
default:
return time.Time{}, "", fmt.Errorf("unsupported cadence %q", schedule.Cadence.Type)
}
return occurrence.UTC(), occurrenceKey(schedule, occurrence), nil
}
func occurrenceKey(schedule config.ReportSchedule, occurrence time.Time) string {
timezone := strings.TrimSpace(schedule.Cadence.Timezone)
if timezone == "" {
timezone = "UTC"
}
return schedule.Cadence.Type + ":" + occurrence.UTC().Format("2006-01-02T15:04:05Z07:00") + ":" + timezone
}
func (h *ReportingHandlers) RunReportScheduleScheduler(ctx context.Context) {
if h == nil {
return
}
h.runDueReportSchedules(ctx, time.Now().UTC())
ticker := time.NewTicker(reportScheduleTickInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
h.runDueReportSchedules(ctx, now.UTC())
}
}
}
func (h *ReportingHandlers) runDueReportSchedules(ctx context.Context, now time.Time) {
if h == nil || h.mtMonitor == nil {
return
}
orgIDs, err := h.mtMonitor.ListOrganizationIDs()
if err != nil {
log.Warn().Err(err).Msg("report schedules: list organizations")
return
}
for _, orgID := range orgIDs {
orgCtx := context.WithValue(ctx, OrgIDContextKey, orgID)
h.syncCommercialHistoryRetention(orgCtx, orgID, now)
h.runDueReportSchedulesForOrg(orgCtx, orgID, now)
}
}
func (h *ReportingHandlers) syncCommercialHistoryRetention(ctx context.Context, orgID string, now time.Time) {
if h == nil || h.mtMonitor == nil || h.commercialLicenseResolver == nil {
return
}
monitor, err := h.mtMonitor.GetMonitor(orgID)
if err != nil || monitor == nil || monitor.GetMetricsStore() == nil {
return
}
service := h.commercialLicenseResolver(ctx)
if service == nil {
monitor.GetMetricsStore().SetCommercialHistoryRetention(0, time.Time{})
return
}
activation := service.GetActivationState()
if activation == nil || activation.Continuity.DowngradeRetention == nil {
monitor.GetMetricsStore().SetCommercialHistoryRetention(0, time.Time{})
return
}
downgrade := normalizeDowngradeRetentionStateFromLicensing(*activation.Continuity.DowngradeRetention)
monitor.GetMetricsStore().SetCommercialHistoryRetention(
downgrade.CurrentHistoryDays,
time.Unix(downgrade.PurgeEligibleAt, 0).UTC(),
)
if now.UTC().Unix() < downgrade.PurgeEligibleAt || service.RequireFeature(featureAdvancedReportingValue) == nil {
return
}
persistence, err := h.reportSchedulePersistence(ctx)
if err != nil {
return
}
if err := purgeGeneratedReportsAfterDowngrade(persistence); err != nil {
log.Warn().Err(err).Str("org_id", orgID).Msg("report schedules: purge downgrade artifacts")
}
}
func purgeGeneratedReportsAfterDowngrade(persistence *config.ConfigPersistence) error {
if persistence == nil {
return nil
}
baseDir, err := securityutil.JoinStorageLeaf(persistence.DataDir(), "reports")
if err != nil {
return err
}
generatedDir, err := securityutil.JoinStorageLeaf(baseDir, "generated")
if err != nil {
return err
}
scheduleDirs, err := os.ReadDir(generatedDir)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
for _, scheduleDir := range scheduleDirs {
if !scheduleDir.IsDir() || scheduleDir.Type()&os.ModeSymlink != 0 {
continue
}
dir, err := securityutil.JoinStorageLeaf(generatedDir, scheduleDir.Name())
if err != nil {
continue
}
files, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, file := range files {
if file.IsDir() || file.Type()&os.ModeSymlink != 0 {
continue
}
path, err := securityutil.JoinStorageLeaf(dir, file.Name())
if err == nil {
_ = os.Remove(path)
}
}
}
return nil
}
func (h *ReportingHandlers) runDueReportSchedulesForOrg(ctx context.Context, orgID string, now time.Time) {
persistence, store, err := h.reportSchedulePersistenceAndStore(ctx)
if err != nil {
log.Warn().Err(err).Str("org_id", orgID).Msg("report schedules: load store")
return
}
changed := false
for i := range store.Schedules {
schedule := config.NormalizeReportSchedule(store.Schedules[i])
if !schedule.Enabled {
continue
}
occurrence, key, err := lastReportScheduleOccurrenceAt(schedule, now)
if err != nil {
store.Schedules[i] = markReportScheduleFailed(schedule, now, err)
changed = true
continue
}
if key == "" || key == schedule.LastOccurrenceKey || occurrence.After(now) {
continue
}
if now.Sub(occurrence) > reportScheduleMissedRunGrace {
schedule.LastOccurrenceKey = key
if next, err := nextReportScheduleRunAt(schedule, now); err == nil {
schedule.NextRunAt = &next
}
schedule.UpdatedAt = now
store.Schedules[i] = schedule
changed = true
continue
}
_, updated := h.runReportSchedule(ctx, persistence, schedule, now, false, key)
store.Schedules[i] = updated
changed = true
}
if changed {
if err := persistence.SaveReportScheduleStore(*store); err != nil {
log.Warn().Err(err).Str("org_id", orgID).Msg("report schedules: save statuses")
}
}
}
// preparePatrolDigestSchedule validates the Patrol weekly summary kind. The
// summary is fleet-wide and only makes sense as a weekly email, so scope,
// format, attachments, and disk delivery are fixed rather than user choices.
func preparePatrolDigestSchedule(schedule config.ReportSchedule, now time.Time) (config.ReportSchedule, error) {
schedule.Format = config.ReportScheduleFormatEmail
schedule.Scope = config.ReportScheduleScope{Resources: []config.ReportScheduleResource{}, Tags: []string{}}
if schedule.Delivery.Method == "" {
schedule.Delivery.Method = config.ReportScheduleDeliveryEmail
}
if schedule.Delivery.Method != config.ReportScheduleDeliveryEmail {
return schedule, reportScheduleValidationError{code: "invalid_delivery", message: "Patrol weekly summaries are delivered by email"}
}
schedule.Delivery.Attach = false
schedule.Delivery.SaveToDisk = false
if schedule.Cadence.Type != config.ReportScheduleCadenceWeekly {
return schedule, reportScheduleValidationError{code: "invalid_cadence", message: "Patrol weekly summaries use a weekly cadence"}
}
if err := validateReportScheduleCadence(schedule.Cadence); err != nil {
return schedule, err
}
next, err := nextReportScheduleRunAt(schedule, now)
if err != nil {
return schedule, err
}
schedule.NextRunAt = &next
return schedule, nil
}
// runPatrolDigestSchedule emails the last seven days of Patrol work. Nothing is
// written to disk: the digest is a rollup of records Pulse already keeps, so
// the email is the only artifact.
func (h *ReportingHandlers) runPatrolDigestSchedule(ctx context.Context, persistence *config.ConfigPersistence, schedule config.ReportSchedule, now time.Time) (reportScheduleRunResult, config.ReportSchedule) {
result := reportScheduleRunResult{}
if h == nil || h.patrolDigestResolver == nil {
return result, markReportScheduleFailed(schedule, now, errors.New("Patrol weekly summary is unavailable on this server"))
}
digest, ok := h.patrolDigestResolver(ctx, ai.PatrolDigestDefaultDays)
if !ok {
return result, markReportScheduleFailed(schedule, now, errors.New("Patrol is not available for this workspace"))
}
manager, err := scheduledReportEmailManager(persistence, schedule)
if err != nil {
return result, markReportScheduleFailed(schedule, now, err)
}
if manager == nil {
return result, markReportScheduleFailed(schedule, now, errors.New("email notifications are not configured; add an email destination under Settings > Notifications or list recipients on the schedule"))
}
subject, htmlBody, textBody := renderPatrolDigestEmail(digest, h.patrolDigestPublicURL())
if err := manager.SendEmailWithRetry(subject, htmlBody, textBody); err != nil {
return result, markReportScheduleFailed(schedule, now, fmt.Errorf("send Patrol weekly summary: %w", err))
}
result.email = "sent"
schedule.LastRunStatus = config.ReportScheduleLastRunOK
schedule.LastError = ""
return result, schedule
}
func (h *ReportingHandlers) patrolDigestPublicURL() string {
if h == nil || h.settingsStore == nil {
return ""
}
settings, err := h.settingsStore.LoadSystemSettings()
if err != nil || settings == nil {
return ""
}
return strings.TrimRight(strings.TrimSpace(settings.PublicURL), "/")
}