feat(notifications): grouped email digest, new music as a daily summary (#5346)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m47s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m47s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Nothing is emailed per event. New music (request_completed) goes out at most once a day, at the summary hour in each user's own timezone, grouped by artist. Everything else is batched: one email a window after the first un-emailed item, holding whatever accumulated. - Migration 0074: notification_email_settings (summary hour, batch window, admin-configurable) and user_notification_email_state (batch start, last sent, failures and retry_after per user and group). Existing rows are stamped emailed so the upgrade sends no backlog. - The Notifier stamps emailed_at at write time when the recipient's email channel is off, so turning email on later doesn't send old items. - Read rows are never selected. A row is stamped only after the mailer accepts, in one transaction with the state, against the read's clock, so a coalesced row updated mid-send stays pending. - A failed send backs off 5m doubling to 6h; SMTP not configured just waits. - Links come from the public address; without one the email has none. - The mailer now RFC 2047-encodes subjects and strips line breaks from them. - Admin → Integrations gains a Notification emails card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,9 +12,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -151,7 +153,7 @@ func composeMessage(cfg dbq.SmtpConfig, to, subject, textBody, htmlBody string)
|
||||
}
|
||||
fmt.Fprintf(&buf, "From: %s\r\n", from)
|
||||
fmt.Fprintf(&buf, "To: %s\r\n", to)
|
||||
fmt.Fprintf(&buf, "Subject: %s\r\n", subject)
|
||||
fmt.Fprintf(&buf, "Subject: %s\r\n", encodeSubject(subject))
|
||||
fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n")
|
||||
fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)
|
||||
|
||||
@@ -169,6 +171,15 @@ func composeMessage(cfg dbq.SmtpConfig, to, subject, textBody, htmlBody string)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// encodeSubject makes subject safe as a header value. Line breaks are
|
||||
// replaced, so a name carried into a subject cannot start a header of its
|
||||
// own, and non-ASCII text ("Moe Shop – WWW") is RFC 2047 encoded, which
|
||||
// mime.QEncoding leaves alone when there is nothing to encode.
|
||||
func encodeSubject(subject string) string {
|
||||
subject = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(subject)
|
||||
return mime.QEncoding.Encode("utf-8", subject)
|
||||
}
|
||||
|
||||
// SentEmail is the in-memory record FakeSender keeps. Tests assert
|
||||
// against these.
|
||||
type SentEmail struct {
|
||||
|
||||
Reference in New Issue
Block a user