diff --git a/mail/templates/vuln_digest.html.tmpl b/mail/templates/vuln_digest.html.tmpl
new file mode 100644
index 0000000..9d2679e
--- /dev/null
+++ b/mail/templates/vuln_digest.html.tmpl
@@ -0,0 +1,13 @@
+{{define "title"}}New vulnerabilities detected{{end}}
+{{define "pill"}}{{template "chip" (dict "label" (upper .TopSeverity) "tone" "down")}}{{end}}
+{{define "body"}}
+{{template "lead" .Summary}}
+{{template "rows" (list
+ (dict "k" "Instance" "v" .InstanceName)
+ (dict "k" "New findings" "v" .Count))}}
+{{range .Rows}}
+{{if .FixedIn}}{{template "well" (printf "%s (%s) — %s on %s, fixed in %s" .CVEID .Severity .PackageName .ServerName .FixedIn)}}{{else}}{{template "well" (printf "%s (%s) — %s on %s, no fix published" .CVEID .Severity .PackageName .ServerName)}}{{end}}
+{{end}}
+{{if .More}}{{template "p" (printf "…and %d more." .More)}}{{end}}
+{{template "note" (printf "Scanned against a vulnerability database pulled %s ago." .DBAge)}}
+{{end}}
diff --git a/mail/templates/vuln_digest.txt.tmpl b/mail/templates/vuln_digest.txt.tmpl
new file mode 100644
index 0000000..086c803
--- /dev/null
+++ b/mail/templates/vuln_digest.txt.tmpl
@@ -0,0 +1,12 @@
+{{define "subject"}}{{.Count}} new {{if eq .Count 1}}vulnerability{{else}}vulnerabilities{{end}} on {{.InstanceName}}{{end}}
+{{define "title"}}New vulnerabilities detected{{end}}
+{{define "pill"}}{{.TopSeverity}}{{end}}
+{{define "body"}}
+{{template "lead" .Summary}}
+
+{{range .Rows}}- {{.CVEID}} ({{.Severity}}) — {{.PackageName}} on {{.ServerName}}{{if .FixedIn}}, fixed in {{.FixedIn}}{{else}}, no fix published{{end}}
+{{end}}
+{{if .More}}...and {{.More}} more.{{end}}
+
+Scanned against vulnerability database pulled {{.DBAge}} ago.
+{{end}}
\ No newline at end of file
diff --git a/mail/vuln.go b/mail/vuln.go
new file mode 100644
index 0000000..a82215d
--- /dev/null
+++ b/mail/vuln.go
@@ -0,0 +1,41 @@
+package mail
+
+// VulnDigestRow is one newly opened finding as the digest shows it.
+//
+// It lives here rather than in server/ so the templates and the caller agree on
+// the fields without server's model package leaking into shared.
+type VulnDigestRow struct {
+ CVEID string
+ Severity string
+ PackageName string
+ ServerName string
+ // FixedIn empty means no vendor fix has been published, which the template
+ // says explicitly rather than leaving blank — it is a real state, not
+ // missing data.
+ FixedIn string
+}
+
+// VulnDigest is one batch of newly opened findings.
+//
+// One message per rule per scan, never one per finding: a database refresh can
+// open several hundred at once, and one message each would rate-limit the
+// webhook or get the channel muted.
+type VulnDigest struct {
+ InstanceName string
+ // Count is every newly opened finding in the batch, which may exceed
+ // len(Rows) — Rows is capped and More carries the remainder.
+ Count int
+ TopSeverity string
+ Summary string
+ Rows []VulnDigestRow
+ More int
+ // DBAge is pre-formatted by the caller. A digest scanned against a
+ // three-week-old database must say so rather than quietly imply freshness.
+ DBAge string
+}
+
+// SendVulnDigest delivers one digest to an SMTP notification channel's
+// recipients, which may be a comma-separated list.
+func (s Sender) SendVulnDigest(to string, d VulnDigest) error {
+ return s.sendTemplate(to, "", "vuln_digest", d)
+}
diff --git a/models/settings.go b/models/settings.go
index 9ea06b2..4b2dc75 100644
--- a/models/settings.go
+++ b/models/settings.go
@@ -34,6 +34,11 @@ type Settings struct {
// absent as disabled — turning off password login for the entire fleet at
// upgrade. Nil means enabled.
LocalLoginEnabled *bool `bson:"local_login_enabled,omitempty" json:"local_login_enabled,omitempty"`
+
+ // VulnFindingRetentionDays is a pointer for the same reason
+ // WorkflowLogRetentionDays is: absent must mean the default, not zero.
+ // Nil is 90 days, 0 is forever. Only "fixed" findings are ever swept.
+ VulnFindingRetentionDays *int `bson:"vuln_finding_retention_days,omitempty" json:"vuln_finding_retention_days,omitempty"`
}
// LocalLoginEnabled reads the setting with its absent-means-on default. Every