Files
selfpost/internal/store/notify.go
T
mixandClaude Opus 5.5 a618ffb0e4 send log: rows belong to a domain by id (audit L12, 0008)
A domain user's log, the message page, a domain's statistics, the level-2
count and the notifier's refusal count picked send-log rows by domain name,
so a domain deleted and re-added under its old name showed its earlier mail
to the next assignee and went on counting it.

Migration 0008 (not run on the live database yet, so extended here):
send_log.domain_id and inbound_spam_log.inbound_domain_id, REFERENCES ...
ON DELETE SET NULL, filled once by name from the registry as it is at
migration time, each with an (id, created_at) index.

- Writes: InsertQueued / InsertRejected set domain_id to the domain of the
  row's login (loginDomainID) - the journal milter and the sender check's
  refusal rows both go through them; the inbound journal sets
  inbound_domain_id from the inbound domain's name at insert.
- Reads: SendLogFilter.DomainIDs (was Domains, by name) and
  InboundSpamFilter.DomainIDs are the scope; the global role still reads
  everything. The message page checks row.DomainID against the user's
  domains. DomainSendStats and CountMessages keep their name argument and
  select domain_id = (SELECT id FROM domains WHERE name = ?). The notifier
  joins send_log.domain_id to the domain and its application.
- DMARC reports stay by name on purpose (a report is about the DNS name).

Tests: store - scope by ids; a re-added domain's log, level-2 count and
statistics hold its own row only, the global log both; inbound scope by
ids. Handlers - the new assignee gets 404 for the old row's page and does
not see it in the log, the global role sees both. Tests that wrote rows for
a login that is no application now register it (a row is a domain's through
its application). Schema at 0008 with the two columns and indexes.

schema-migrations row 8, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 19/62.
Evidence: go vet ./... clean; go test ./... ok locally but for the two
Windows-only tests; the server run follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:37:26 +03:00

222 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// settingNotifications is the instance switch for notification e-mails. A
// missing value means on: the switch exists to turn them off.
const settingNotifications = "notifications"
// NotificationsEnabled reports whether the instance sends notification e-mails
// at all (Server › Settings).
func (s *Store) NotificationsEnabled() (bool, error) {
v, err := s.GetSetting(settingNotifications)
if err != nil {
return false, err
}
return v != "off", nil
}
// SetNotificationsEnabled turns notification e-mails on or off for everyone.
func (s *Store) SetNotificationsEnabled(on bool) error {
v := "on"
if !on {
v = "off"
}
return s.SetSetting(settingNotifications, v)
}
// SetUserNotify sets a user's own switch (Account).
func (s *Store) SetUserNotify(id int64, on bool) error {
res, err := s.db.Exec("UPDATE users SET notify = ? WHERE id = ?", on, id)
if err != nil {
return fmt.Errorf("set user notify: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrUserNotFound
}
return nil
}
// NotifyRecipients returns the addresses a notification goes to: every global
// user, and for a subject that is a sending domain's (domainName not empty)
// also the domain users it is assigned to - by name on their list, or by
// "all domains". Only users with an e-mail and their own switch on; an address
// two users share is returned once.
func (s *Store) NotifyRecipients(domainName string) ([]string, error) {
query := `SELECT DISTINCT email FROM users
WHERE notify = 1 AND email <> '' AND role = ?`
args := []any{RoleGlobal}
if domainName != "" {
query = `SELECT DISTINCT email FROM users
WHERE notify = 1 AND email <> '' AND (
role = ?
OR all_domains = 1
OR EXISTS (SELECT 1 FROM user_domains ud JOIN domains d ON d.id = ud.domain_id
WHERE ud.user_id = users.id AND d.name = ?))`
args = append(args, domainName)
}
rows, err := s.db.Query(query+" ORDER BY email", args...)
if err != nil {
return nil, fmt.Errorf("notify recipients: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var email string
if err := rows.Scan(&email); err != nil {
return nil, fmt.Errorf("notify recipients scan: %w", err)
}
out = append(out, email)
}
return out, rows.Err()
}
// NotifyState is what the panel remembers about one subject of notification
// (migration 0004 describes the keys and the fields).
type NotifyState struct {
Key string
Phase string
Count int
Mark string
Since time.Time
SentAt time.Time // zero when nothing was sent yet
}
// GetNotifyState returns the state kept under key; ok is false when there is
// none, which is the state of a subject nothing is wrong with.
func (s *Store) GetNotifyState(key string) (NotifyState, bool, error) {
st := NotifyState{Key: key}
var since, sentAt string
err := s.db.QueryRow(
"SELECT phase, count, mark, since, sent_at FROM notify_state WHERE key = ?", key,
).Scan(&st.Phase, &st.Count, &st.Mark, &since, &sentAt)
if errors.Is(err, sql.ErrNoRows) {
return NotifyState{}, false, nil
}
if err != nil {
return NotifyState{}, false, fmt.Errorf("get notify state %s: %w", key, err)
}
st.Since, _ = time.Parse(time.RFC3339, since)
if sentAt != "" {
st.SentAt, _ = time.Parse(time.RFC3339, sentAt)
}
return st, true, nil
}
// SetNotifyState writes the state of one subject, replacing what was there.
func (s *Store) SetNotifyState(st NotifyState) error {
sentAt := ""
if !st.SentAt.IsZero() {
sentAt = st.SentAt.UTC().Format(time.RFC3339)
}
_, err := s.db.Exec(
`INSERT INTO notify_state (key, phase, count, mark, since, sent_at) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET phase = excluded.phase, count = excluded.count,
mark = excluded.mark, since = excluded.since, sent_at = excluded.sent_at`,
st.Key, st.Phase, st.Count, st.Mark, st.Since.UTC().Format(time.RFC3339), sentAt,
)
if err != nil {
return fmt.Errorf("set notify state %s: %w", st.Key, err)
}
return nil
}
// DeleteNotifyState forgets a subject: nothing is wrong with it any more, or
// it no longer exists.
func (s *Store) DeleteNotifyState(key string) error {
if _, err := s.db.Exec("DELETE FROM notify_state WHERE key = ?", key); err != nil {
return fmt.Errorf("delete notify state %s: %w", key, err)
}
return nil
}
// ListNotifyStateKeys returns the keys that begin with prefix, so that the
// states of subjects that are gone - a deleted domain - can be found.
func (s *Store) ListNotifyStateKeys(prefix string) ([]string, error) {
rows, err := s.db.Query(
"SELECT key FROM notify_state WHERE substr(key, 1, ?) = ? ORDER BY key", len(prefix), prefix)
if err != nil {
return nil, fmt.Errorf("list notify state: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, fmt.Errorf("list notify state scan: %w", err)
}
out = append(out, key)
}
return out, rows.Err()
}
// LimitRefusal is the mail the applications of one sending domain were refused
// under a level-2 rate limit in a span of time.
type LimitRefusal struct {
Domain string
// Apps are the logins that were refused, in order.
Apps []string
Count int
First time.Time
}
// LimitRefusalsSince returns, per sending domain, the messages the journal
// milter refused under a level-2 limit at or after since.
//
// A refusal is a send-log row with status "rejected", and what such a row
// says about itself is not to be believed whole. Its domain is the one in
// MAIL FROM, which the client typed and Postfix has not yet held against the
// login when the milter refuses; so the domain here is the one the login is
// registered under, and a row whose login is no application's is not counted.
// The same status is written when an application is refused for its client
// address (applications.auth_ip_restrict), which is no limit at all; so a
// login counts only while a level-2 limit is set on it or on its domain.
// Since 2.4.0 a row says why it was refused, and one refused for its client
// address or its From: line is left out by that; a row from before has no
// reason and is still judged the old way.
func (s *Store) LimitRefusalsSince(since time.Time) ([]LimitRefusal, error) {
// The status is a literal so that the partial index of migration 0005
// answers the question.
rows, err := s.db.Query(
`SELECT d.name, a.login, COUNT(*), MIN(l.created_at)
FROM send_log l
JOIN domains d ON d.id = l.domain_id
JOIN applications a ON a.login = l.app_login AND a.domain_id = d.id
WHERE l.status = 'rejected' AND l.created_at >= ? AND l.reason IN ('', 'limit')
AND (EXISTS (SELECT 1 FROM rate_limits r WHERE r.scope = 'application' AND r.ref_id = a.id)
OR EXISTS (SELECT 1 FROM rate_limits r WHERE r.scope = 'domain' AND r.ref_id = d.id))
GROUP BY d.name, a.login ORDER BY d.name, a.login`,
since.UTC().Format(time.RFC3339),
)
if err != nil {
return nil, fmt.Errorf("limit refusals: %w", err)
}
defer rows.Close()
var out []LimitRefusal
for rows.Next() {
var (
domain, login, first string
count int
)
if err := rows.Scan(&domain, &login, &count, &first); err != nil {
return nil, fmt.Errorf("limit refusals scan: %w", err)
}
at, _ := time.Parse(time.RFC3339, first)
if n := len(out); n > 0 && out[n-1].Domain == domain {
out[n-1].Apps = append(out[n-1].Apps, login)
out[n-1].Count += count
if at.Before(out[n-1].First) {
out[n-1].First = at
}
continue
}
out = append(out, LimitRefusal{Domain: domain, Apps: []string{login}, Count: count, First: at})
}
return out, rows.Err()
}