feat(email): PWA login sends code only; drop admin link from alert emails
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s

Two email changes, per the owner:

1. Code-only login from an installed PWA. A login requested while running as a
   standalone PWA now omits the one-tap confirm link from the email — the link
   would open in a separate browser whose minted session cannot reach the PWA,
   stranding the login. The code is typed in the same window instead. The client
   sends a `pwa` flag (isStandalone) on the email-code request (a new FBS field,
   threaded through the gateway); the backend omits the deeplink when it is set,
   reusing the existing deletion-code link-omission path. Non-PWA browser logins
   keep the one-tap link. No polling, no migration.

2. Security: the operator alert digest no longer embeds the admin-console (/_gm)
   URL. An admin link must never travel in an email, where a mail provider could
   cache or index it; the operator opens the console directly.

Tests: an inttest asserting the PWA login email omits the link (and a browser one
keeps it); a codec round-trip of the new pwa field; the alert-digest test flipped
to guard the admin link is absent. Docs: ARCHITECTURE + FUNCTIONAL (+ru).
This commit is contained in:
Ilia Denisov
2026-07-05 22:13:21 +02:00
parent d19609f87d
commit 061366da5a
21 changed files with 139 additions and 57 deletions
+8 -9
View File
@@ -33,21 +33,21 @@ type Notifier struct {
complaints ComplaintCounter
from string
to string
consoleURL string
clock func() time.Time
log *zap.Logger
last time.Time
}
// New constructs a Notifier. from and to are the alert sender and recipient(s); consoleURL,
// when non-empty, is the admin-console link included in the email. log may be nil. The
// watermark starts at "now", so only items arriving after start-up are reported.
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier {
// New constructs a Notifier. from and to are the alert sender and recipient(s); log may be
// nil. The watermark starts at "now", so only items arriving after start-up are reported. The
// digest deliberately carries no admin-console link — an admin URL must never travel in an
// email, where a mail provider could cache or index it.
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier {
if log == nil {
log = zap.NewNop()
}
return &Notifier{
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, consoleURL: consoleURL,
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to,
clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(),
}
}
@@ -103,10 +103,9 @@ func (n *Notifier) digest(fb, cp int) account.Message {
parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp))
}
summary := strings.Join(parts, ", ")
// No admin-console link in the body: an admin URL must never travel in an email (a mail
// provider could cache or index it). The operator opens the console directly.
text := summary + "."
if n.consoleURL != "" {
text += "\n\nOpen the admin console: " + n.consoleURL
}
return account.Message{
From: n.from,
To: n.to,
+6 -4
View File
@@ -28,7 +28,7 @@ func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
func TestNotifierSkipsWhenNothingNew(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", "", nil)
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", nil)
n.tick(context.Background())
if len(mailer.sent) != 0 {
t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent))
@@ -37,7 +37,7 @@ func TestNotifierSkipsWhenNothingNew(t *testing.T) {
func TestNotifierDigestsNewItems(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", "https://erudit-game.ru/_gm", nil)
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", nil)
n.tick(context.Background())
if len(mailer.sent) != 1 {
t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent))
@@ -49,7 +49,9 @@ func TestNotifierDigestsNewItems(t *testing.T) {
if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") {
t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject)
}
if !strings.Contains(msg.Text, "/_gm") {
t.Errorf("digest body = %q, want the console link", msg.Text)
// The digest must never carry an admin-console link — an admin URL in an email is a leak
// (mail providers cache/index it).
if strings.Contains(msg.Text, "/_gm") || strings.Contains(strings.ToLower(msg.Text), "admin console") {
t.Errorf("digest body = %q, must not carry an admin-console link", msg.Text)
}
}