Migration 0004_notifications.sql, DDL only: users.notify, a user's own
switch, on by default; notify_state, one row per subject the panel has
written about (dns:<domain>, cert, queue, limit:<domain>:<login>), so that
each thing is said once and a restart says nothing again.
Store: the instance switch as a settings key, on unless turned off;
SetUserNotify; NotifyRecipients - global users for everything, domain users
for the domains on their list or all of them, only with an e-mail and their
switch on, a shared mailbox once; the state's get, set, delete and list by
prefix; LimitRefusalsSince, the mail refused under a level-2 limit per domain
and application.
Nothing is sent yet. Checked on Windows: go vet clean, go test
./internal/store passes with the schema test at user_version 4. The page
guards fail from the design commit 87fa0a4 until the pages are built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
6.1 KiB
Go
195 lines
6.1 KiB
Go
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 one application was refused under a level-2 rate
|
||
// limit in a span of time: how many messages and when the first.
|
||
type LimitRefusal struct {
|
||
Domain string
|
||
AppLogin string
|
||
Count int
|
||
First time.Time
|
||
}
|
||
|
||
// LimitRefusalsSince returns, per domain and application, the messages the
|
||
// journal milter refused under a level-2 limit at or after since (send-log
|
||
// rows with status "rejected").
|
||
func (s *Store) LimitRefusalsSince(since time.Time) ([]LimitRefusal, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT COALESCE(domain, ''), COALESCE(app_login, ''), COUNT(*), MIN(created_at) FROM send_log
|
||
WHERE status = ? AND created_at >= ?
|
||
GROUP BY domain, app_login ORDER BY domain, app_login`,
|
||
StatusRejected, 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 (
|
||
r LimitRefusal
|
||
first string
|
||
)
|
||
if err := rows.Scan(&r.Domain, &r.AppLogin, &r.Count, &first); err != nil {
|
||
return nil, fmt.Errorf("limit refusals scan: %w", err)
|
||
}
|
||
r.First, _ = time.Parse(time.RFC3339, first)
|
||
out = append(out, r)
|
||
}
|
||
return out, rows.Err()
|
||
}
|