Files
paliad/internal/services/reminder_service.go
m 9184e9b0ef feat(t-paliad-148) commit 4/6: reminder + deadline + derivation cleanup — pt.role → pt.responsibility
reminder_service.go: BuildDigest audience predicate switches the
"project lead anywhere on the path" branch from `pt.role = 'lead'` to
`pt.responsibility = 'lead'`. Two SQL sites + comment updated.

deadline_service.go: assertCanAdminProject (Reopen permission) switches
from `pt.role IN ('admin','lead')` to `pt.responsibility = 'lead'`.
The legacy 'admin' was already dead since t-paliad-051 — never present
in project_teams.role to begin with — so this also drops a slow leak.
Doc comments + error message updated.

derivation_service.go: ListDescendantStaffed SELECTs both `pt.role` and
`pt.responsibility`, returns the new column to the team-tab "from
descendants" subsection (so the firm-tier badge + responsibility pill
both render). ORDER BY switches to responsibility.

Build + vet clean. Pure-Go tests pass.
2026-05-07 21:50:31 +02:00

663 lines
25 KiB
Go

// Package services — ReminderService — hourly bundled-digest reminder mail.
//
// Runs one goroutine for the process lifetime. Every hour it walks the user
// list, and for each user inside their morning or evening slot it builds a
// single bundled email summarising every pending deadline that matters to
// that user, grouped by category. One email per (user, slot, local-date)
// — no per-deadline mail, no Mondays-only digest.
//
// Three deadline categories drive the email layout (computed in the user's
// local timezone on each tick):
//
// * overdue — due_date < today (status=pending). System-failure
// framing — we engineer this away. Audience: owner +
// escalation channel. The escalation channel is the
// owner's user.escalation_contact_id when set, else the
// firm's global_admins (fallback). Settings → Notifications
// exposes the override (t-paliad-066).
// * due_today — due_date == today (status=pending). Day-of awareness in
// the morning slot; DRINGEND escalation framing in the
// evening slot. Audience: owner + project leads (+
// escalation channel on the evening slot — same
// contact-or-admins fallback as overdue).
// * due_warning — due_date == today + user.reminder_warning_offset_days
// (default 7), status=pending. Heads-up section. Audience:
// owner + project leads. Morning slot only.
//
// Per-(user,slot,local-date) dedup uses paliad.reminder_log.slot/slot_date
// (migration 025). Legacy rows (slot IS NULL) coexist and are ignored.
//
// t-paliad-064 redesign — replaces the prior per-deadline kinds (overdue /
// tomorrow / due_today_evening / weekly) and per-mail templates with one
// bundled deadline_digest.html.
package services
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
// Embed Go's IANA tz database. Mirrors the import in cmd/server/main.go
// so the services test binary also has tz data available — without this,
// `go test ./internal/services` would pass on a dev host (which has OS
// tzdata) but the prod alpine binary would still be broken, and the tz
// regression test in this package would be vacuous.
_ "time/tzdata"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// ReminderService wires the hourly reminder job. Construct with NewReminderService,
// start with Start(ctx), stop by cancelling the parent context.
type ReminderService struct {
db *sqlx.DB
mail *MailService
users *UserService
// baseURL is the frontend origin used in email links. Defaults to
// https://paliad.de; override via PALIAD_BASE_URL for staging/preview.
baseURL string
// clock returns the current time. Exposed for tests so they can pin
// "today" without having to freeze time globally.
clock func() time.Time
}
// NewReminderService wires the service. The MailService may be disabled
// (Enabled() == false) — in that case Start still runs so logs show what
// would have gone out, but every Send is a no-op.
func NewReminderService(db *sqlx.DB, mail *MailService, users *UserService, baseURL string) *ReminderService {
if baseURL == "" {
baseURL = "https://paliad.de"
}
return &ReminderService{
db: db,
mail: mail,
users: users,
baseURL: baseURL,
clock: func() time.Time { return time.Now() },
}
}
// Start spawns the boundary-aligned scanner goroutine. Returns immediately;
// the loop exits when ctx is cancelled.
func (s *ReminderService) Start(ctx context.Context) {
go s.loop(ctx)
}
// nextTopOfHour returns the duration from now until the next HH:00:00 in
// absolute time. Used to align the scanner's wake-up to natural hour
// boundaries instead of the container-start offset (t-paliad-069).
//
// Pre-fix the loop used `time.NewTicker(time.Hour)` directly: a deploy at
// 13:27:50 produced ticks at HH:27:50 forever, drifting the user-visible
// arrival of a 09:00-Berlin digest anywhere in the 09:xx hour and — worse —
// completely missing slots when redeploys clustered inside the slot hour.
// time.Truncate operates on absolute time, so the boundary is HH:00:00 UTC;
// for whole-hour-offset zones (e.g. Europe/Berlin = UTC±N) that's also
// HH:00:00 wall-clock locally, which is what users care about.
func nextTopOfHour(now time.Time) time.Duration {
next := now.Truncate(time.Hour).Add(time.Hour)
return next.Sub(now)
}
func (s *ReminderService) loop(ctx context.Context) {
slog.Info("reminder: starting boundary-aligned scanner",
"mail_enabled", s.mail.Enabled())
// Startup catch-up: fire any user/slot whose configured hour has already
// arrived today but no log row exists yet. Covers redeploys during or
// after a slot hour — without this, a single mistimed deploy can lose a
// day for affected users (the regular tick filter requires
// local.Hour() == slot_hour, which is only true for one hour per day).
// The slot_date dedup makes re-firing safe: if the previous container
// already logged the slot, this is a no-op.
s.runStartupCatchUp(ctx)
// Aligned wait loop. nextTopOfHour is recomputed every iteration so any
// clock skew or RunOnce duration self-corrects rather than accumulating.
for {
timer := time.NewTimer(nextTopOfHour(s.clock()))
select {
case <-ctx.Done():
timer.Stop()
slog.Info("reminder: shutdown")
return
case <-timer.C:
}
s.RunOnce(ctx)
}
}
// RunOnce performs one scan+send pass for the regular hourly tick — fires
// only the slots whose configured hour matches the current local hour for
// each user. Exposed so tests (and, later, an admin trigger endpoint) can
// exercise the path without waiting for the ticker. Errors on individual
// users are logged and swallowed so one bad row doesn't block the scan.
func (s *ReminderService) RunOnce(ctx context.Context) {
s.scanForSlots(ctx, "tick", func(now time.Time, u models.User, slot string) bool {
return inSlot(now, u.ReminderTimezone, u.ReminderMorningTime, u.ReminderEveningTime, slot)
})
}
// runStartupCatchUp fires any user/slot whose configured hour has already
// arrived today (regardless of the current hour) but has no log row yet —
// see loop() for the rationale. Goes through the same runSlotForUser path
// as RunOnce, so the slot_date dedup, audience filter, and email shape all
// match the regular tick.
func (s *ReminderService) runStartupCatchUp(ctx context.Context) {
s.scanForSlots(ctx, "startup-catchup", func(now time.Time, u models.User, slot string) bool {
return slotPastDueToday(now, u.ReminderTimezone, u.ReminderMorningTime, u.ReminderEveningTime, slot)
})
}
// scanForSlots is the shared scan body: load all users, walk morning+evening,
// and call runSlotForUser for each that passes filterFn. label distinguishes
// the two callers in logs.
func (s *ReminderService) scanForSlots(
ctx context.Context,
label string,
filterFn func(now time.Time, u models.User, slot string) bool,
) {
now := s.clock()
if s.users == nil {
slog.Warn("reminder: UserService not wired, skipping scan", "label", label)
return
}
users, err := s.users.List(ctx)
if err != nil {
slog.Warn("reminder: list users failed", "label", label, "error", err)
return
}
for _, u := range users {
for _, slot := range []string{"morning", "evening"} {
if !filterFn(now, u, slot) {
continue
}
if !reminderEnabled(u.EmailPreferences, "deadline_reminders") {
continue
}
if err := s.runSlotForUser(ctx, now, u, slot); err != nil {
slog.Warn("reminder: slot run failed",
"label", label, "user_id", u.ID, "slot", slot, "error", err)
}
}
}
}
// runSlotForUser emits at most one digest email for (user, slot, local-date).
// Returns nil on send-skipped (empty digest, dedup hit) and on success.
// Errors signal infrastructure problems (DB / mail) the caller can log.
func (s *ReminderService) runSlotForUser(ctx context.Context, now time.Time, u models.User, slot string) error {
loc, err := time.LoadLocation(u.ReminderTimezone)
if err != nil {
// Defense in depth — inSlot already screens this, but if we get here
// don't try to send with a bogus tz.
return fmt.Errorf("load tz %q: %w", u.ReminderTimezone, err)
}
local := now.In(loc)
today := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc)
already, err := s.hasDigestSent(ctx, u.ID, slot, today)
if err != nil {
return fmt.Errorf("dedup check: %w", err)
}
if already {
return nil
}
rows, err := s.fetchSlotDeadlines(ctx, u, today, slot)
if err != nil {
return fmt.Errorf("fetch deadlines: %w", err)
}
if len(rows) == 0 {
// Nothing for this user in this slot — no email, no log row. The
// next slot will check again. Per the design we deliberately do
// NOT send "everything is quiet" ack mail.
return nil
}
if err := s.deliverDigest(u, slot, rows); err != nil {
return fmt.Errorf("deliver: %w", err)
}
return s.logDigestSend(ctx, u.ID, slot, today)
}
// digestRow is one deadline as it will appear in a digest email. Categories
// are computed in Go from due_date so we don't have to encode the offset
// twice (SQL + template).
type digestRow struct {
DeadlineID uuid.UUID `db:"deadline_id"`
Title string `db:"title"`
DueDate time.Time `db:"due_date"`
OwnerID uuid.UUID `db:"owner_id"`
OwnerName string `db:"owner_name"`
ProjectReference string `db:"project_reference"`
ProjectTitle string `db:"project_title"`
IsLead bool `db:"is_lead"`
// ApprovalStatus (t-paliad-138). When 'pending', the digest renders
// the row with a "[PENDING] " title prefix so the user can't miss
// that the deadline is unverified — silence on a pending change is
// the worst outcome.
ApprovalStatus string `db:"approval_status"`
// OwnerEscalationContactID is the owner's optional escalation override:
// non-NULL diverts overdue/DRINGEND escalation away from global_admins
// to the named user. Used by visibleForCategory to decide whether the
// global-admin fallback applies for this row.
OwnerEscalationContactID *uuid.UUID `db:"owner_escalation_contact_id"`
// Filled in Go after the SELECT.
Category string
}
// fetchSlotDeadlines pulls every pending deadline that matters to u in this
// slot, applies the per-category audience filter, and returns the rows the
// digest should render.
//
// SQL fans out across the three audience predicates (owner, project lead
// along path, global admin). The per-category recipient rules (e.g. leads
// don't get overdue) are applied in Go after the rows return — keeps the
// SQL portable and the rule-table readable.
func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User, today time.Time, slot string) ([]digestRow, error) {
isGlobalAdmin := u.GlobalRole == "global_admin"
offset := u.ReminderWarningOffsetDays
if offset < 1 {
offset = 7
}
// Build the date predicate per slot. Positional placeholders only —
// sqlx.Named can't be used because the query body contains PostgreSQL
// `::TYPE` cast operators and sqlx eats the second `:` as a named-arg
// prefix.
// $1 = today
// $2 = userid
// $3 = is_global_admin
// `offset` is interpolated as a literal int (clamped ≥1 above) — keeping
// it as a parameter would force every slot's query to declare it even
// when unused (evening), and Postgres can't infer the type of an
// unreferenced parameter.
// morning: overdue OR due_today OR due_warning(today+offset)
// evening: overdue OR due_today (no +offset heads-up in the evening)
var dateCond string
if slot == "evening" {
dateCond = `(f.due_date < $1 OR f.due_date = $1)`
} else {
dateCond = fmt.Sprintf(`(f.due_date < $1
OR f.due_date = $1
OR f.due_date = ($1::date + '%d days'::interval)::date)`, offset)
}
// Audience predicates:
// * owner of the deadline — f.created_by = U
// * project lead anywhere on the path — pt.responsibility = 'lead'
// * owner's escalation contact (override) — own.escalation_contact_id = U
// * global admin AND owner has no override — fallback channel
// Per-category recipient rules (e.g. leads don't get overdue) are applied
// in Go by visibleForCategory so the rule table stays readable.
query := `
SELECT f.id AS deadline_id,
f.title AS title,
f.due_date AS due_date,
f.approval_status AS approval_status,
f.created_by AS owner_id,
COALESCE(own.display_name, '') AS owner_name,
own.escalation_contact_id AS owner_escalation_contact_id,
COALESCE(p.reference, '') AS project_reference,
p.title AS project_title,
EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $2
AND pt.responsibility = 'lead'
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
) AS is_lead
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
LEFT JOIN paliad.users own ON own.id = f.created_by
WHERE f.status = 'pending'
AND ` + dateCond + `
AND (
f.created_by = $2
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $2
AND pt.responsibility = 'lead'
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
)
OR own.escalation_contact_id = $2
OR ($3 = TRUE AND own.escalation_contact_id IS NULL)
)
ORDER BY f.due_date ASC, f.id ASC`
rows := []digestRow{}
if err := s.db.SelectContext(ctx, &rows, query, today, u.ID, isGlobalAdmin); err != nil {
return nil, fmt.Errorf("select deadlines: %w", err)
}
out := make([]digestRow, 0, len(rows))
for _, r := range rows {
cat := categorize(r.DueDate, today, offset)
if cat == "" {
continue
}
isOwner := r.OwnerID == u.ID
ownerHasOverride := r.OwnerEscalationContactID != nil
isEscalationContact := ownerHasOverride && *r.OwnerEscalationContactID == u.ID
if !visibleForCategory(cat, slot, isOwner, r.IsLead, isGlobalAdmin, isEscalationContact, ownerHasOverride) {
continue
}
// Honour the per-category email_preferences toggle if the user
// opted out of that specific kind.
if !reminderEnabled(u.EmailPreferences, "deadline_reminders."+cat) {
continue
}
r.Category = cat
out = append(out, r)
}
return out, nil
}
// categorize buckets a deadline into "overdue" / "due_today" / "due_warning"
// based on how its due_date relates to today + the user's warning offset,
// all interpreted in the user's local timezone. Anything that isn't one of
// the three returns "" (skip).
//
// Postgres DATE columns scan as time.Time at UTC midnight. We compare on
// y/m/d only to avoid offset artefacts.
func categorize(dueDate, today time.Time, warningOffsetDays int) string {
d := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, today.Location())
t := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())
switch {
case d.Before(t):
return "overdue"
case d.Equal(t):
return "due_today"
case d.Equal(t.AddDate(0, 0, warningOffsetDays)):
return "due_warning"
}
return ""
}
// visibleForCategory enforces the per-category recipient rules from the
// design doc:
//
// * due_warning: owner OR lead (morning only — caller already filtered
// by slot in the SQL date predicate).
// * due_today, morning: owner OR lead.
// * due_today, evening (DRINGEND): owner OR lead OR escalation channel.
// * overdue: owner OR escalation channel (NOT lead — system failure,
// escalate past the team).
//
// The "escalation channel" is the owner's escalation_contact_id when
// non-NULL, else the firm's global_admins (fallback). When a contact is
// set, global_admins are *deliberately excluded* so escalation does not
// fan out to the whole admin team — that's the whole point of the
// override (t-paliad-066).
func visibleForCategory(
category, slot string,
isOwner, isLead, isGlobalAdmin, isEscalationContact, ownerHasEscalationOverride bool,
) bool {
// When the owner set a specific escalation contact, the global-admin
// fallback is suppressed — only the named contact (and the owner /
// leads, who get notified for their own reasons) are on the escalation
// channel.
escalationVisible := func() bool {
if ownerHasEscalationOverride {
return isEscalationContact
}
return isGlobalAdmin
}
switch category {
case "due_warning":
return isOwner || isLead
case "due_today":
if slot == "evening" {
return isOwner || isLead || escalationVisible()
}
return isOwner || isLead
case "overdue":
return isOwner || escalationVisible()
}
return false
}
// reminderEnabled reports whether the user's email_preferences allow a given
// reminder key. A missing key or empty object means on (opt-out) — that
// preserves the behaviour users had before the settings page shipped.
//
// Two paths share this helper:
// * the master switch ("deadline_reminders") — if explicitly false, no
// digest email goes out at all.
// * per-category keys ("deadline_reminders.overdue", "…due_today",
// "…due_warning") — if explicitly false, that category's rows are
// dropped from the digest. The legacy keys ("…tomorrow", "…weekly")
// are no longer queried.
func reminderEnabled(raw json.RawMessage, key string) bool {
if len(raw) == 0 {
return true
}
prefs := map[string]any{}
if err := json.Unmarshal(raw, &prefs); err != nil {
// Corrupt JSON in the DB shouldn't silence reminders — err on the
// side of sending so users aren't dropped because of bad data.
return true
}
// Master gate: an explicit false on "deadline_reminders" wins.
if v, ok := prefs["deadline_reminders"]; ok {
if b, ok := v.(bool); ok && !b {
return false
}
}
// Caller-specific gate.
if key != "deadline_reminders" {
if v, ok := prefs[key]; ok {
if b, ok := v.(bool); ok && !b {
return false
}
}
}
return true
}
// inSlot reports whether the user's local hour-of-day matches the configured
// hour for the given slot. The minute is ignored — the ticker fires hourly,
// so we only compare hour granularity.
//
// A bad/empty timezone returns false (skip the user this tick) and logs an
// error — historically we silently fell back to UTC, which masked the
// alpine-container tzdata bug (t-paliad-064): in production
// time.LoadLocation("Europe/Berlin") errored because the runtime image
// shipped no /usr/share/zoneinfo, the silent UTC fallback fired, and
// reminder_morning_time=09:00 matched at 09:00 UTC = 11:00 Berlin. The
// `_ "time/tzdata"` import in cmd/server/main.go makes lookups work without
// OS tzdata; this function now refuses to send rather than guessing.
//
// An unparseable HH:MM string still falls back to the column default
// (09:00 / 16:00) — that's a separate, recoverable input and dropping the
// user for it would be punitive.
func inSlot(now time.Time, tz, morning, evening, slot string) bool {
loc, err := time.LoadLocation(tz)
if err != nil {
slog.Error("reminder: cannot load timezone, skipping user this tick",
"tz", tz, "slot", slot, "error", err)
return false
}
local := now.In(loc)
target := morning
if slot == "evening" {
target = evening
}
hour, ok := parseHour(target)
if !ok {
// Fall back to defaults rather than dropping the user entirely.
if slot == "evening" {
hour = 16
} else {
hour = 9
}
}
return local.Hour() == hour
}
// slotPastDueToday reports whether the user's slot hour for today has
// already arrived (or is currently in progress) in the user's timezone.
// It's the relaxed sibling of inSlot: inSlot uses ==, slotPastDueToday
// uses >=. Used by runStartupCatchUp to redeliver slots that the regular
// tick missed because a redeploy moved the tick out of the slot hour.
//
// The slot_date dedup (paliad.reminder_log.slot/slot_date with partial
// UNIQUE INDEX) prevents this from double-firing if the slot already ran
// earlier today, so it's safe to run this opportunistically on every boot.
//
// A bad/empty timezone returns false (skip this user) — same defensive
// stance inSlot took for the same reason (t-paliad-064: alpine tzdata).
func slotPastDueToday(now time.Time, tz, morning, evening, slot string) bool {
loc, err := time.LoadLocation(tz)
if err != nil {
slog.Error("reminder: catch-up cannot load timezone, skipping user",
"tz", tz, "slot", slot, "error", err)
return false
}
local := now.In(loc)
target := morning
if slot == "evening" {
target = evening
}
hour, ok := parseHour(target)
if !ok {
if slot == "evening" {
hour = 16
} else {
hour = 9
}
}
return local.Hour() >= hour
}
// parseHour pulls the hour out of an "HH:MM" or "HH:MM:SS" string. Returns
// (0, false) on malformed input — callers fall back to the column defaults.
func parseHour(s string) (int, bool) {
for _, layout := range []string{"15:04:05", "15:04"} {
if t, err := time.Parse(layout, s); err == nil {
return t.Hour(), true
}
}
return 0, false
}
// hasDigestSent checks the slot-level dedup row (migration 025). The
// partial unique index on (user_id, slot, slot_date) WHERE slot IS NOT NULL
// makes this a single index lookup.
func (s *ReminderService) hasDigestSent(ctx context.Context, userID uuid.UUID, slot string, slotDate time.Time) (bool, error) {
var exists bool
err := s.db.GetContext(ctx, &exists,
`SELECT EXISTS (
SELECT 1 FROM paliad.reminder_log
WHERE user_id = $1
AND slot = $2
AND slot_date = $3
)`, userID, slot, slotDate)
return exists, err
}
// logDigestSend writes one row per (user, slot, local-date). The unique
// index serialises concurrent ticks for the same user/slot — if a stray
// double-tick raced past the dedup check, the second insert errors and the
// caller drops the second send.
func (s *ReminderService) logDigestSend(ctx context.Context, userID uuid.UUID, slot string, slotDate time.Time) error {
reminderType := slot + "_digest"
if _, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.reminder_log (user_id, reminder_type, slot, slot_date)
VALUES ($1, $2, $3, $4)`,
userID, reminderType, slot, slotDate,
); err != nil {
return fmt.Errorf("insert reminder_log: %w", err)
}
return nil
}
// deliverDigest renders deadline_digest.html for u and sends one email.
// Caller is responsible for the dedup row (we want it to land only on
// successful send).
func (s *ReminderService) deliverDigest(u models.User, slot string, rows []digestRow) error {
lang := "de"
if u.Lang == "en" {
lang = "en"
}
// Bucket rows by category for the template. Within a category, rows
// arrive sorted by due_date already (SQL ORDER BY).
//
// Pending-approval rows (t-paliad-138) get a "[PENDING] " title prefix
// so the recipient can't miss that the deadline is unverified — silence
// on a pending change is the worst outcome for a 4-eye system.
var overdue, dueToday, dueWarning []map[string]any
pendingCount := 0
for _, r := range rows {
title := r.Title
isPending := r.ApprovalStatus == ApprovalStatusPending
if isPending {
title = "[PENDING] " + title
pendingCount++
}
item := map[string]any{
"DueDate": r.DueDate.Format("2006-01-02"),
"Title": title,
"IsPending": isPending,
"ProjectReference": r.ProjectReference,
"ProjectTitle": r.ProjectTitle,
"OwnerName": r.OwnerName,
"IsOtherOwner": r.OwnerID != u.ID,
"URL": fmt.Sprintf("%s/deadlines/%s", s.baseURL, r.DeadlineID),
}
switch r.Category {
case "overdue":
overdue = append(overdue, item)
case "due_today":
dueToday = append(dueToday, item)
case "due_warning":
dueWarning = append(dueWarning, item)
}
}
// Subject is rendered from the (deadline_digest, lang) template row by
// MailService — see internal/services/email_template_service.go for the
// SYSTEMAUSFALL/SYSTEM FAILURE conditional logic and the rationale for
// keeping that framing in place. OpenTotal is precomputed for the
// "Frist-Erinnerung: N offen" pluralisation in the subject template.
data := map[string]any{
"Slot": slot,
"IsEvening": slot == "evening",
"Overdue": overdue,
"DueToday": dueToday,
"DueWarning": dueWarning,
"OverdueCount": len(overdue),
"DueTodayCount": len(dueToday),
"DueWarningCount": len(dueWarning),
"OpenTotal": len(dueToday) + len(dueWarning),
"DeadlinesURL": fmt.Sprintf("%s/deadlines", s.baseURL),
// PendingCount > 0 → templates can render a banner like
// "Hinweis: N Frist(en) wartet auf 4-Augen-Genehmigung —
// /inbox" above the digest body. Available even when the
// template doesn't currently use it (forward-compat, no
// existing-template breakage).
"PendingCount": pendingCount,
"InboxURL": fmt.Sprintf("%s/inbox", s.baseURL),
}
return s.mail.SendTemplate(TemplateData{
To: u.Email,
Lang: lang,
Name: "deadline_digest",
Data: data,
})
}