first commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
type AdminUser struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
MustChangePassword bool
|
||||
TOTPSecret string
|
||||
TOTPEnabled bool
|
||||
IsGlobalAdmin bool
|
||||
CreatedBy *int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type AdminSession struct {
|
||||
Token string
|
||||
UserID int64
|
||||
MFAVerified bool
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type WebAuthnCredential struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Name string
|
||||
CredentialID string
|
||||
CredentialData string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// GetDomainByNameExact looks up a domain by exact (case-sensitive) name, regardless of
|
||||
// is_active, mirroring the raw `filter_by(domain_name=domain_name)` query used inside
|
||||
// DKIMManager.generate_dkim_keypair (unlike get_domain_by_name, which is case-insensitive
|
||||
// and active-only).
|
||||
func (d *DB) GetDomainByNameExact(name string) (*Domain, error) {
|
||||
row := d.QueryRow(`SELECT `+domainColumns+` FROM esrv_domains WHERE domain_name = ?`, name)
|
||||
return scanDomain(row)
|
||||
}
|
||||
|
||||
// GetDomainByID looks up a domain by primary key, regardless of is_active — used by the
|
||||
// admin web UI's edit/delete/toggle actions, which operate on a specific row by id.
|
||||
func (d *DB) GetDomainByID(id int64) (*Domain, error) {
|
||||
row := d.QueryRow(`SELECT `+domainColumns+` FROM esrv_domains WHERE id = ?`, id)
|
||||
return scanDomain(row)
|
||||
}
|
||||
|
||||
func (d *DB) GetDKIMKeyByDomainAndSelector(domainID int64, selector string) (*DKIMKey, error) {
|
||||
row := d.QueryRow(`SELECT id, domain_id, selector, private_key, public_key, is_active, created_at, replaced_at
|
||||
FROM esrv_dkim_keys WHERE domain_id = ? AND selector = ?`, domainID, selector)
|
||||
return scanDKIMKey(row)
|
||||
}
|
||||
|
||||
func (d *DB) GetActiveDKIMKeyByDomainID(domainID int64) (*DKIMKey, error) {
|
||||
row := d.QueryRow(`SELECT id, domain_id, selector, private_key, public_key, is_active, created_at, replaced_at
|
||||
FROM esrv_dkim_keys WHERE domain_id = ? AND is_active = 1`, domainID)
|
||||
return scanDKIMKey(row)
|
||||
}
|
||||
|
||||
func (d *DB) GetDKIMKeyByID(id int64) (*DKIMKey, error) {
|
||||
row := d.QueryRow(`SELECT id, domain_id, selector, private_key, public_key, is_active, created_at, replaced_at
|
||||
FROM esrv_dkim_keys WHERE id = ?`, id)
|
||||
return scanDKIMKey(row)
|
||||
}
|
||||
|
||||
func scanDKIMKey(row *sql.Row) (*DKIMKey, error) {
|
||||
var k DKIMKey
|
||||
var createdAt string
|
||||
var replacedAt sql.NullString
|
||||
if err := row.Scan(&k.ID, &k.DomainID, &k.Selector, &k.PrivateKey, &k.PublicKey, &k.IsActive, &createdAt, &replacedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
k.CreatedAt, _ = parseTime(createdAt)
|
||||
if replacedAt.Valid {
|
||||
t, _ := parseTime(replacedAt.String)
|
||||
k.ReplacedAt = &t
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const adminUserColumns = `id, username, password_hash, must_change_password, totp_secret, totp_enabled, is_global_admin, created_by, created_at`
|
||||
|
||||
func scanAdminUser(row *sql.Row) (*AdminUser, error) {
|
||||
var u AdminUser
|
||||
var createdAt string
|
||||
var createdBy sql.NullInt64
|
||||
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
u.CreatedAt, _ = parseTime(createdAt)
|
||||
if createdBy.Valid {
|
||||
u.CreatedBy = &createdBy.Int64
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (d *DB) CountAdminUsers() (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_admin_users`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// DefaultAdminUsername/Password are the seeded first-run credentials — the admin is
|
||||
// forced to change both before they can use the rest of the dashboard (see
|
||||
// AdminUser.MustChangePassword and the login flow).
|
||||
const (
|
||||
DefaultAdminUsername = "admin"
|
||||
DefaultAdminPassword = "Password123!"
|
||||
)
|
||||
|
||||
// SeedDefaultAdminIfEmpty creates the default admin account on a brand-new install
|
||||
// (no admin users yet at all) with must_change_password set, so the default
|
||||
// credentials can never be left in place silently.
|
||||
func (d *DB) SeedDefaultAdminIfEmpty() error {
|
||||
n, err := d.CountAdminUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
hash, err := HashPassword(DefaultAdminPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = d.CreateAdminUser(DefaultAdminUsername, hash, true)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateAdminUser inserts a new global-admin account (full access, no domain
|
||||
// restriction). mustChangePassword should be true for the seeded default account so
|
||||
// it can't keep running on default credentials.
|
||||
func (d *DB) CreateAdminUser(username, passwordHash string, mustChangePassword bool) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, is_global_admin) VALUES (?, ?, ?, 1)`,
|
||||
username, passwordHash, mustChangePassword)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// CreateScopedAdminUser inserts a new domain-scoped admin (delegated access), owned by
|
||||
// createdBy, and grants it access to exactly domainIDs — mirrors the delegation flow:
|
||||
// a scoped admin can create other scoped admins limited to domains within their own.
|
||||
func (d *DB) CreateScopedAdminUser(username, passwordHash string, createdBy int64, domainIDs []int64) (int64, error) {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, is_global_admin, created_by) VALUES (?, ?, 1, 0, ?)`,
|
||||
username, passwordHash, createdBy)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, domainID := range domainIDs {
|
||||
if _, err := tx.Exec(`INSERT INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, id, domainID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return id, tx.Commit()
|
||||
}
|
||||
|
||||
// ListAllAdminUsers returns every admin account — for a global admin's user-management
|
||||
// view.
|
||||
func (d *DB) ListAllAdminUsers() ([]AdminUser, error) {
|
||||
rows, err := d.Query(`SELECT ` + adminUserColumns + ` FROM esrv_admin_users ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanAdminUsers(rows)
|
||||
}
|
||||
|
||||
// ListScopedAdminUsers returns every non-global admin. Combined with AccessibleDomainIDs
|
||||
// per user, this lets the caller compute "which of these can I (a scoped admin)
|
||||
// manage" — the subset check happens in Go since the admin counts here are always
|
||||
// small (a handful of delegated accounts, not enterprise scale).
|
||||
func (d *DB) ListScopedAdminUsers() ([]AdminUser, error) {
|
||||
rows, err := d.Query(`SELECT ` + adminUserColumns + ` FROM esrv_admin_users WHERE is_global_admin = 0 ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanAdminUsers(rows)
|
||||
}
|
||||
|
||||
func scanAdminUsers(rows *sql.Rows) ([]AdminUser, error) {
|
||||
defer rows.Close()
|
||||
var out []AdminUser
|
||||
for rows.Next() {
|
||||
var u AdminUser
|
||||
var createdAt string
|
||||
var createdBy sql.NullInt64
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.CreatedAt, _ = parseTime(createdAt)
|
||||
if createdBy.Valid {
|
||||
u.CreatedBy = &createdBy.Int64
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AccessibleDomainIDs returns the domains a scoped admin can see/manage. Meaningless
|
||||
// for a global admin (they can access everything regardless of this table).
|
||||
func (d *DB) AccessibleDomainIDs(userID int64) ([]int64, error) {
|
||||
rows, err := d.Query(`SELECT domain_id FROM esrv_admin_domain_access WHERE admin_user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GrantDomainAccess mirrors auto-assigning a newly-created domain to the scoped admin
|
||||
// who created it.
|
||||
func (d *DB) GrantDomainAccess(userID, domainID int64) error {
|
||||
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, userID, domainID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetAdminDomainAccess replaces a scoped admin's entire domain assignment set.
|
||||
func (d *DB) SetAdminDomainAccess(userID int64, domainIDs []int64) error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM esrv_admin_domain_access WHERE admin_user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range domainIDs {
|
||||
if _, err := tx.Exec(`INSERT INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, userID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteAdminUser removes an admin account and everything tied to it.
|
||||
func (d *DB) DeleteAdminUser(id int64) error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, stmt := range []string{
|
||||
`DELETE FROM esrv_admin_domain_access WHERE admin_user_id = ?`,
|
||||
`DELETE FROM esrv_admin_sessions WHERE user_id = ?`,
|
||||
`DELETE FROM esrv_webauthn_credentials WHERE user_id = ?`,
|
||||
`DELETE FROM esrv_admin_users WHERE id = ?`,
|
||||
} {
|
||||
if _, err := tx.Exec(stmt, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *DB) GetAdminUserByUsername(username string) (*AdminUser, error) {
|
||||
row := d.QueryRow(`SELECT `+adminUserColumns+` FROM esrv_admin_users WHERE lower(username) = lower(?)`, username)
|
||||
return scanAdminUser(row)
|
||||
}
|
||||
|
||||
func (d *DB) GetAdminUserByID(id int64) (*AdminUser, error) {
|
||||
row := d.QueryRow(`SELECT `+adminUserColumns+` FROM esrv_admin_users WHERE id = ?`, id)
|
||||
return scanAdminUser(row)
|
||||
}
|
||||
|
||||
// UpdateAdminCredentials mirrors the forced first-login change: new username,
|
||||
// password hash, and clears must_change_password in one step.
|
||||
func (d *DB) UpdateAdminCredentials(id int64, username, passwordHash string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_admin_users SET username = ?, password_hash = ?, must_change_password = 0 WHERE id = ?`, username, passwordHash, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) UpdateAdminPassword(id int64, passwordHash string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_admin_users SET password_hash = ? WHERE id = ?`, passwordHash, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetAdminTOTPSecret(id int64, secret string, enabled bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_admin_users SET totp_secret = ?, totp_enabled = ? WHERE id = ?`, secret, enabled, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DisableAdminTOTP(id int64) error {
|
||||
_, err := d.Exec(`UPDATE esrv_admin_users SET totp_secret = '', totp_enabled = 0 WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Sessions ---
|
||||
|
||||
func newSessionToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// CreateSession mirrors starting a new login session; mfaVerified should be true only
|
||||
// when the account has no second factor enabled (nothing left to verify) or the second
|
||||
// factor was just satisfied.
|
||||
func (d *DB) CreateSession(userID int64, mfaVerified bool, ttl time.Duration) (string, error) {
|
||||
token := newSessionToken()
|
||||
_, err := d.Exec(`INSERT INTO esrv_admin_sessions (token, user_id, mfa_verified, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
token, userID, mfaVerified, time.Now().Add(ttl))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetSession(token string) (*AdminSession, error) {
|
||||
row := d.QueryRow(`SELECT token, user_id, mfa_verified, created_at, expires_at FROM esrv_admin_sessions WHERE token = ?`, token)
|
||||
var s AdminSession
|
||||
var createdAt, expiresAt string
|
||||
if err := row.Scan(&s.Token, &s.UserID, &s.MFAVerified, &createdAt, &expiresAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.CreatedAt, _ = parseTime(createdAt)
|
||||
s.ExpiresAt, _ = parseTime(expiresAt)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (d *DB) MarkSessionMFAVerified(token string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_admin_sessions SET mfa_verified = 1 WHERE token = ?`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteSession(token string) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_admin_sessions WHERE token = ?`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteExpiredSessions is a lightweight best-effort sweep, called opportunistically
|
||||
// rather than on a schedule — this admin UI has at most a handful of sessions ever.
|
||||
func (d *DB) DeleteExpiredSessions() error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_admin_sessions WHERE expires_at < ?`, time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
// --- WebAuthn credentials ---
|
||||
|
||||
func (d *DB) ListWebAuthnCredentials(userID int64) ([]WebAuthnCredential, error) {
|
||||
rows, err := d.Query(`SELECT id, user_id, name, credential_id, credential_data, created_at FROM esrv_webauthn_credentials WHERE user_id = ? ORDER BY created_at`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []WebAuthnCredential
|
||||
for rows.Next() {
|
||||
var c WebAuthnCredential
|
||||
var createdAt string
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.CredentialID, &c.CredentialData, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) CreateWebAuthnCredential(userID int64, name, credentialID, credentialData string) error {
|
||||
_, err := d.Exec(`INSERT INTO esrv_webauthn_credentials (user_id, name, credential_id, credential_data) VALUES (?, ?, ?, ?)`,
|
||||
userID, name, credentialID, credentialData)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteWebAuthnCredential(id, userID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_webauthn_credentials WHERE id = ? AND user_id = ?`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) CountWebAuthnCredentials(userID int64) (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_webauthn_credentials WHERE user_id = ?`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package db
|
||||
|
||||
func (d *DB) CountSendersForDomain(domainID int64) (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_senders WHERE domain_id = ?`, domainID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (d *DB) HasActiveDKIMForDomain(domainID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_dkim_keys WHERE domain_id = ? AND is_active = 1`, domainID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (d *DB) HasAnyDKIMForDomain(domainID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_dkim_keys WHERE domain_id = ?`, domainID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package db
|
||||
|
||||
type DKIMKeyWithDomain struct {
|
||||
DKIMKey
|
||||
DomainName string
|
||||
}
|
||||
|
||||
func (d *DB) ListActiveDKIMKeysWithDomain() ([]DKIMKeyWithDomain, error) {
|
||||
rows, err := d.Query(`SELECT k.id, k.domain_id, k.selector, k.private_key, k.public_key, k.is_active, k.created_at, k.replaced_at, dm.domain_name
|
||||
FROM esrv_dkim_keys k JOIN esrv_domains dm ON dm.id = k.domain_id WHERE k.is_active = 1 ORDER BY dm.domain_name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanDKIMKeysWithDomain(rows)
|
||||
}
|
||||
|
||||
func (d *DB) ListInactiveDKIMKeysWithDomain() ([]DKIMKeyWithDomain, error) {
|
||||
rows, err := d.Query(`SELECT k.id, k.domain_id, k.selector, k.private_key, k.public_key, k.is_active, k.created_at, k.replaced_at, dm.domain_name
|
||||
FROM esrv_dkim_keys k JOIN esrv_domains dm ON dm.id = k.domain_id WHERE k.is_active = 0
|
||||
ORDER BY dm.domain_name, (k.replaced_at IS NULL), k.replaced_at DESC, k.created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanDKIMKeysWithDomain(rows)
|
||||
}
|
||||
|
||||
func scanDKIMKeysWithDomain(rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
}) ([]DKIMKeyWithDomain, error) {
|
||||
var out []DKIMKeyWithDomain
|
||||
for rows.Next() {
|
||||
var k DKIMKeyWithDomain
|
||||
var createdAt string
|
||||
var replacedAt *string
|
||||
if err := rows.Scan(&k.ID, &k.DomainID, &k.Selector, &k.PrivateKey, &k.PublicKey, &k.IsActive, &createdAt, &replacedAt, &k.DomainName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.CreatedAt, _ = parseTime(createdAt)
|
||||
if replacedAt != nil {
|
||||
t, _ := parseTime(*replacedAt)
|
||||
k.ReplacedAt = &t
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) CountActiveDKIMKeys() (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_dkim_keys WHERE is_active = 1`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (d *DB) DeactivateActiveDKIMKeysForDomain(domainID int64, replacedAt any) error {
|
||||
_, err := d.Exec(`UPDATE esrv_dkim_keys SET is_active = 0, replaced_at = ? WHERE domain_id = ? AND is_active = 1`, replacedAt, domainID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetDKIMKeyActive(id int64, active bool, replacedAt any) error {
|
||||
if active {
|
||||
_, err := d.Exec(`UPDATE esrv_dkim_keys SET is_active = 1, replaced_at = NULL WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
_, err := d.Exec(`UPDATE esrv_dkim_keys SET is_active = 0, replaced_at = ? WHERE id = ?`, replacedAt, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) UpdateDKIMKeySelector(id int64, selector string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_dkim_keys SET selector = ? WHERE id = ?`, selector, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SelectorExistsForDomain(domainID int64, selector string, excludeID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_dkim_keys WHERE domain_id = ? AND selector = ? AND is_active = 1 AND id != ?`, domainID, selector, excludeID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (d *DB) RemoveDKIMKey(id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_dkim_keys WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *DB) ListDomains() ([]Domain, error) {
|
||||
rows, err := d.Query(`SELECT ` + domainColumns + ` FROM esrv_domains ORDER BY domain_name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Domain
|
||||
for rows.Next() {
|
||||
var dm Domain
|
||||
var createdAt string
|
||||
var verifiedAt *string
|
||||
if err := rows.Scan(&dm.ID, &dm.DomainName, &dm.IsActive, &createdAt, &dm.VerificationToken, &dm.IsVerified, &verifiedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dm.CreatedAt, _ = parseTime(createdAt)
|
||||
if verifiedAt != nil {
|
||||
t, _ := parseTime(*verifiedAt)
|
||||
dm.VerifiedAt = &t
|
||||
}
|
||||
out = append(out, dm)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListActiveDomains mirrors the `domains` query used to populate <select> lists on the
|
||||
// add/edit sender and IP forms.
|
||||
func (d *DB) ListActiveDomains() ([]Domain, error) {
|
||||
all, err := d.ListDomains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Domain
|
||||
for _, dm := range all {
|
||||
if dm.IsActive {
|
||||
out = append(out, dm)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *DB) CountActiveDomains() (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_domains WHERE is_active = 1`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// generateVerificationToken returns a random 32-hex-char token for the DNS TXT
|
||||
// ownership check, mirroring the randomness quality already used for DKIM selectors.
|
||||
func generateVerificationToken() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// CreateDomain inserts a new, unverified domain with a freshly generated DNS
|
||||
// verification token.
|
||||
func (d *DB) CreateDomain(name string) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_domains (domain_name, is_active, verification_token, is_verified) VALUES (?, 1, ?, 0)`,
|
||||
name, generateVerificationToken())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) UpdateDomain(id int64, name string, requiresAuth bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_domains SET domain_name = ? WHERE id = ?`, name, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetDomainActive(id int64, active bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_domains SET is_active = ? WHERE id = ?`, active, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDomainVerified mirrors marking a domain as DNS-ownership-verified (or reverting
|
||||
// it, e.g. if an admin wants to force re-verification).
|
||||
func (d *DB) SetDomainVerified(id int64, verified bool) error {
|
||||
if verified {
|
||||
_, err := d.Exec(`UPDATE esrv_domains SET is_verified = 1, verified_at = ? WHERE id = ?`, time.Now(), id)
|
||||
return err
|
||||
}
|
||||
_, err := d.Exec(`UPDATE esrv_domains SET is_verified = 0, verified_at = NULL WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// RegenerateVerificationToken mirrors resetting a domain back to a fresh, unverified
|
||||
// token — used if an admin wants a new TXT value (e.g. suspected leak, or restarting
|
||||
// the ownership proof).
|
||||
func (d *DB) RegenerateVerificationToken(id int64) (string, error) {
|
||||
token := generateVerificationToken()
|
||||
_, err := d.Exec(`UPDATE esrv_domains SET verification_token = ?, is_verified = 0, verified_at = NULL WHERE id = ?`, token, id)
|
||||
return token, err
|
||||
}
|
||||
|
||||
// RemoveDomainCascade hard-deletes a domain and every row that references it, mirroring
|
||||
// domains.remove_domain. Returns counts removed for the flash message.
|
||||
func (d *DB) RemoveDomainCascade(id int64) (senders, ips, dkimKeys, headers int, err error) {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for table, count := range map[string]*int{
|
||||
"esrv_senders": &senders,
|
||||
"esrv_whitelisted_ips": &ips,
|
||||
"esrv_dkim_keys": &dkimKeys,
|
||||
"esrv_custom_headers": &headers,
|
||||
} {
|
||||
row := tx.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE domain_id = ?`, id)
|
||||
if err = row.Scan(count); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err = tx.Exec(`DELETE FROM `+table+` WHERE domain_id = ?`, id); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err = tx.Exec(`DELETE FROM esrv_domains WHERE id = ?`, id); err != nil {
|
||||
return
|
||||
}
|
||||
err = tx.Commit()
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type WhitelistedIPWithDomain struct {
|
||||
WhitelistedIP
|
||||
DomainName string
|
||||
}
|
||||
|
||||
func (d *DB) ListWhitelistedIPs() ([]WhitelistedIPWithDomain, error) {
|
||||
rows, err := d.Query(`SELECT w.id, w.ip_address, w.domain_id, w.is_active, w.created_at, w.store_message_content, dm.domain_name
|
||||
FROM esrv_whitelisted_ips w JOIN esrv_domains dm ON dm.id = w.domain_id ORDER BY w.ip_address`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []WhitelistedIPWithDomain
|
||||
for rows.Next() {
|
||||
var w WhitelistedIPWithDomain
|
||||
var createdAt string
|
||||
if err := rows.Scan(&w.ID, &w.IPAddress, &w.DomainID, &w.IsActive, &createdAt, &w.StoreMessageContent, &w.DomainName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetWhitelistedIPByID(id int64) (*WhitelistedIP, error) {
|
||||
row := d.QueryRow(`SELECT id, ip_address, domain_id, is_active, created_at, store_message_content FROM esrv_whitelisted_ips WHERE id = ?`, id)
|
||||
var w WhitelistedIP
|
||||
var createdAt string
|
||||
if err := row.Scan(&w.ID, &w.IPAddress, &w.DomainID, &w.IsActive, &createdAt, &w.StoreMessageContent); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
w.CreatedAt, _ = parseTime(createdAt)
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func (d *DB) IPPairExists(ip string, domainID, excludeID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_whitelisted_ips WHERE ip_address = ? AND domain_id = ? AND id != ?`, ip, domainID, excludeID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (d *DB) CreateWhitelistedIP(ip string, domainID int64, storeMessageContent bool) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_whitelisted_ips (ip_address, domain_id, is_active, store_message_content) VALUES (?, ?, 1, ?)`, ip, domainID, storeMessageContent)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) UpdateWhitelistedIP(id int64, ip string, domainID int64, storeMessageContent bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_whitelisted_ips SET ip_address = ?, domain_id = ?, store_message_content = ? WHERE id = ?`, ip, domainID, storeMessageContent, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetWhitelistedIPActive(id int64, active bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_whitelisted_ips SET is_active = ? WHERE id = ?`, active, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) RemoveWhitelistedIP(id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_whitelisted_ips WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (d *DB) GetEmailLogByID(id int64) (*EmailLog, error) {
|
||||
row := d.QueryRow(`SELECT id, message_id, timestamp, peer_ip, mail_from, to_address, cc_addresses, bcc_addresses, subject, email_headers, message_body, status, dkim_signed, username, created_at
|
||||
FROM esrv_email_logs WHERE id = ?`, id)
|
||||
return scanEmailLog(row)
|
||||
}
|
||||
|
||||
func scanEmailLog(row *sql.Row) (*EmailLog, error) {
|
||||
var l EmailLog
|
||||
var ts, createdAt string
|
||||
if err := row.Scan(&l.ID, &l.MessageID, &ts, &l.PeerIP, &l.MailFrom, &l.ToAddress, &l.CcAddresses, &l.BccAddresses, &l.Subject, &l.EmailHeaders, &l.MessageBody, &l.Status, &l.DKIMSigned, &l.Username, &createdAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
l.Timestamp, _ = parseTime(ts)
|
||||
l.CreatedAt, _ = parseTime(createdAt)
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func (d *DB) ListEmailLogsPage(offset, limit int) ([]EmailLog, error) {
|
||||
rows, err := d.Query(`SELECT id, message_id, timestamp, peer_ip, mail_from, to_address, cc_addresses, bcc_addresses, subject, email_headers, message_body, status, dkim_signed, username, created_at
|
||||
FROM esrv_email_logs ORDER BY created_at DESC LIMIT ? OFFSET ?`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []EmailLog
|
||||
for rows.Next() {
|
||||
var l EmailLog
|
||||
var ts, createdAt string
|
||||
if err := rows.Scan(&l.ID, &l.MessageID, &ts, &l.PeerIP, &l.MailFrom, &l.ToAddress, &l.CcAddresses, &l.BccAddresses, &l.Subject, &l.EmailHeaders, &l.MessageBody, &l.Status, &l.DKIMSigned, &l.Username, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.Timestamp, _ = parseTime(ts)
|
||||
l.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) ListAuthLogsPage(offset, limit int) ([]AuthLog, error) {
|
||||
rows, err := d.Query(`SELECT id, auth_type, identifier, ip_address, success, message, created_at FROM esrv_auth_logs ORDER BY created_at DESC LIMIT ? OFFSET ?`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AuthLog
|
||||
for rows.Next() {
|
||||
var a AuthLog
|
||||
var createdAt string
|
||||
if err := rows.Scan(&a.ID, &a.AuthType, &a.Identifier, &a.IPAddress, &a.Success, &a.Message, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) ListRecentAuthLogs(limit int) ([]AuthLog, error) {
|
||||
return d.ListAuthLogsPage(0, limit)
|
||||
}
|
||||
|
||||
func (d *DB) ListRecipientLogsForEmail(emailLogID int64) ([]EmailRecipientLog, error) {
|
||||
rows, err := d.Query(`SELECT id, email_log_id, recipient, recipient_type, status, error_code, error_message, server_response FROM esrv_email_recipient_logs WHERE email_log_id = ?`, emailLogID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []EmailRecipientLog
|
||||
for rows.Next() {
|
||||
var r EmailRecipientLog
|
||||
if err := rows.Scan(&r.ID, &r.EmailLogID, &r.Recipient, &r.RecipientType, &r.Status, &r.ErrorCode, &r.ErrorMessage, &r.ServerResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) ListAttachmentsForEmail(emailLogID int64) ([]EmailAttachment, error) {
|
||||
rows, err := d.Query(`SELECT id, email_log_id, filename, content_type, file_path, size, uploaded_at FROM esrv_email_attachments WHERE email_log_id = ?`, emailLogID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []EmailAttachment
|
||||
for rows.Next() {
|
||||
var a EmailAttachment
|
||||
var uploadedAt string
|
||||
if err := rows.Scan(&a.ID, &a.EmailLogID, &a.Filename, &a.ContentType, &a.FilePath, &a.Size, &uploadedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.UploadedAt, _ = parseTime(uploadedAt)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetAttachmentByID(id int64) (*EmailAttachment, error) {
|
||||
row := d.QueryRow(`SELECT id, email_log_id, filename, content_type, file_path, size, uploaded_at FROM esrv_email_attachments WHERE id = ?`, id)
|
||||
var a EmailAttachment
|
||||
var uploadedAt string
|
||||
if err := row.Scan(&a.ID, &a.EmailLogID, &a.Filename, &a.ContentType, &a.FilePath, &a.Size, &uploadedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
a.UploadedAt, _ = parseTime(uploadedAt)
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (d *DB) RemoveAttachment(id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_email_attachments WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// SenderWithDomain joins a Sender with its Domain's name, mirroring the
|
||||
// Sender+Domain join used by senders.py's list view.
|
||||
type SenderWithDomain struct {
|
||||
Sender
|
||||
DomainName string
|
||||
}
|
||||
|
||||
func (d *DB) ListSenders() ([]SenderWithDomain, error) {
|
||||
rows, err := d.Query(`SELECT s.id, s.email, s.password_hash, s.domain_id, s.can_send_as_domain, s.is_active, s.created_at, s.store_message_content, dm.domain_name
|
||||
FROM esrv_senders s JOIN esrv_domains dm ON dm.id = s.domain_id ORDER BY s.email`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SenderWithDomain
|
||||
for rows.Next() {
|
||||
var s SenderWithDomain
|
||||
var createdAt string
|
||||
if err := rows.Scan(&s.ID, &s.Email, &s.PasswordHash, &s.DomainID, &s.CanSendAsDomain, &s.IsActive, &createdAt, &s.StoreMessageContent, &s.DomainName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetSenderByID(id int64) (*Sender, error) {
|
||||
row := d.QueryRow(`SELECT id, email, password_hash, domain_id, can_send_as_domain, is_active, created_at, store_message_content
|
||||
FROM esrv_senders WHERE id = ?`, id)
|
||||
var s Sender
|
||||
var createdAt string
|
||||
if err := row.Scan(&s.ID, &s.Email, &s.PasswordHash, &s.DomainID, &s.CanSendAsDomain, &s.IsActive, &createdAt, &s.StoreMessageContent); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.CreatedAt, _ = parseTime(createdAt)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (d *DB) CountActiveSenders() (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_senders WHERE is_active = 1`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (d *DB) EmailExists(email string, excludeID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_senders WHERE lower(email) = lower(?) AND id != ?`, email, excludeID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (d *DB) CreateSender(email, passwordHash string, domainID int64, canSendAsDomain, storeMessageContent bool) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_senders (email, password_hash, domain_id, can_send_as_domain, is_active, store_message_content)
|
||||
VALUES (?, ?, ?, ?, 1, ?)`, email, passwordHash, domainID, canSendAsDomain, storeMessageContent)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) UpdateSender(id int64, email, passwordHash string, domainID int64, canSendAsDomain, storeMessageContent bool) error {
|
||||
if passwordHash == "" {
|
||||
_, err := d.Exec(`UPDATE esrv_senders SET email = ?, domain_id = ?, can_send_as_domain = ?, store_message_content = ? WHERE id = ?`,
|
||||
email, domainID, canSendAsDomain, storeMessageContent, id)
|
||||
return err
|
||||
}
|
||||
_, err := d.Exec(`UPDATE esrv_senders SET email = ?, password_hash = ?, domain_id = ?, can_send_as_domain = ?, store_message_content = ? WHERE id = ?`,
|
||||
email, passwordHash, domainID, canSendAsDomain, storeMessageContent, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetSenderActive(id int64, active bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_senders SET is_active = ? WHERE id = ?`, active, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) RemoveSender(id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_senders WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
// InsertEmailLog mirrors the EmailLog row creation in EmailRelay.log_email. Returns the
|
||||
// new row's id (needed before recipient/attachment child rows can be inserted).
|
||||
func (d *DB) InsertEmailLog(l EmailLog) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_email_logs
|
||||
(message_id, timestamp, peer_ip, mail_from, to_address, cc_addresses, bcc_addresses, subject, email_headers, message_body, status, dkim_signed, username)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
l.MessageID, l.Timestamp, l.PeerIP, l.MailFrom, l.ToAddress, l.CcAddresses, l.BccAddresses, l.Subject, l.EmailHeaders, l.MessageBody, l.Status, l.DKIMSigned, l.Username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// InsertEmailRecipientLog mirrors one EmailRecipientLog row creation.
|
||||
func (d *DB) InsertEmailRecipientLog(l EmailRecipientLog) error {
|
||||
_, err := d.Exec(`INSERT INTO esrv_email_recipient_logs
|
||||
(email_log_id, recipient, recipient_type, status, error_code, error_message, server_response)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
l.EmailLogID, l.Recipient, l.RecipientType, l.Status, l.ErrorCode, l.ErrorMessage, l.ServerResponse)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertEmailAttachment mirrors one EmailAttachment row creation.
|
||||
func (d *DB) InsertEmailAttachment(a EmailAttachment) error {
|
||||
_, err := d.Exec(`INSERT INTO esrv_email_attachments
|
||||
(email_log_id, filename, content_type, file_path, size, uploaded_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
a.EmailLogID, a.Filename, a.ContentType, a.FilePath, a.Size, time.Now())
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
type Domain struct {
|
||||
ID int64
|
||||
DomainName string
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
VerificationToken string
|
||||
IsVerified bool
|
||||
VerifiedAt *time.Time
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
ID int64
|
||||
Email string
|
||||
PasswordHash string
|
||||
DomainID int64
|
||||
CanSendAsDomain bool
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
StoreMessageContent bool
|
||||
}
|
||||
|
||||
// CanSendAs mirrors Sender.can_send_as in models.py.
|
||||
func (s Sender) CanSendAs(fromAddress string) bool {
|
||||
if equalFold(fromAddress, s.Email) {
|
||||
return true
|
||||
}
|
||||
if !s.CanSendAsDomain {
|
||||
return false
|
||||
}
|
||||
senderDomain := domainPart(s.Email)
|
||||
fromDomain := domainPart(fromAddress)
|
||||
return senderDomain != "" && senderDomain == fromDomain
|
||||
}
|
||||
|
||||
type WhitelistedIP struct {
|
||||
ID int64
|
||||
IPAddress string
|
||||
DomainID int64
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
StoreMessageContent bool
|
||||
}
|
||||
|
||||
type EmailLog struct {
|
||||
ID int64
|
||||
MessageID string
|
||||
Timestamp time.Time
|
||||
PeerIP string
|
||||
MailFrom string
|
||||
ToAddress string
|
||||
CcAddresses string
|
||||
BccAddresses string
|
||||
Subject string
|
||||
EmailHeaders string
|
||||
MessageBody string
|
||||
Status string
|
||||
DKIMSigned bool
|
||||
Username string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type EmailRecipientLog struct {
|
||||
ID int64
|
||||
EmailLogID int64
|
||||
Recipient string
|
||||
RecipientType string
|
||||
Status string
|
||||
ErrorCode string
|
||||
ErrorMessage string
|
||||
ServerResponse string
|
||||
}
|
||||
|
||||
type AuthLog struct {
|
||||
ID int64
|
||||
AuthType string
|
||||
Identifier string
|
||||
IPAddress string
|
||||
Success bool
|
||||
Message string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type DKIMKey struct {
|
||||
ID int64
|
||||
DomainID int64
|
||||
Selector string
|
||||
PrivateKey string
|
||||
PublicKey string
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
ReplacedAt *time.Time
|
||||
}
|
||||
|
||||
type CustomHeader struct {
|
||||
ID int64
|
||||
DomainID int64
|
||||
HeaderName string
|
||||
HeaderValue string
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type EmailAttachment struct {
|
||||
ID int64
|
||||
EmailLogID int64
|
||||
Filename string
|
||||
ContentType string
|
||||
FilePath string
|
||||
Size int64
|
||||
UploadedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func equalFold(a, b string) bool { return strings.EqualFold(a, b) }
|
||||
|
||||
func domainPart(address string) string {
|
||||
i := strings.LastIndex(address, "@")
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(address[i+1:])
|
||||
}
|
||||
|
||||
// bcryptCost is pinned to 12 to match Python's bcrypt.gensalt() default, since Go's
|
||||
// bcrypt.DefaultCost is 10 and would otherwise silently produce weaker hashes.
|
||||
const bcryptCost = 12
|
||||
|
||||
// HashPassword mirrors models.hash_password.
|
||||
func HashPassword(password string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// CheckPassword mirrors models.check_password.
|
||||
func CheckPassword(password, hash string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// GetSenderByEmail mirrors models.get_sender_by_email: case-insensitive match against
|
||||
// the lower-cased stored email, active senders only.
|
||||
func (d *DB) GetSenderByEmail(email string) (*Sender, error) {
|
||||
row := d.QueryRow(`SELECT id, email, password_hash, domain_id, can_send_as_domain, is_active, created_at, store_message_content
|
||||
FROM esrv_senders WHERE lower(email) = lower(?) AND is_active = 1`, email)
|
||||
var s Sender
|
||||
var createdAt string
|
||||
if err := row.Scan(&s.ID, &s.Email, &s.PasswordHash, &s.DomainID, &s.CanSendAsDomain, &s.IsActive, &createdAt, &s.StoreMessageContent); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.CreatedAt, _ = parseTime(createdAt)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
const domainColumns = `id, domain_name, is_active, created_at, verification_token, is_verified, verified_at`
|
||||
|
||||
// scanDomain scans a row selected with domainColumns, in that order.
|
||||
func scanDomain(row *sql.Row) (*Domain, error) {
|
||||
var dom Domain
|
||||
var createdAt string
|
||||
var verifiedAt sql.NullString
|
||||
if err := row.Scan(&dom.ID, &dom.DomainName, &dom.IsActive, &createdAt, &dom.VerificationToken, &dom.IsVerified, &verifiedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
dom.CreatedAt, _ = parseTime(createdAt)
|
||||
if verifiedAt.Valid {
|
||||
t, _ := parseTime(verifiedAt.String)
|
||||
dom.VerifiedAt = &t
|
||||
}
|
||||
return &dom, nil
|
||||
}
|
||||
|
||||
// GetDomainByName mirrors models.get_domain_by_name.
|
||||
func (d *DB) GetDomainByName(name string) (*Domain, error) {
|
||||
row := d.QueryRow(`SELECT `+domainColumns+` FROM esrv_domains
|
||||
WHERE lower(domain_name) = lower(?) AND is_active = 1`, name)
|
||||
return scanDomain(row)
|
||||
}
|
||||
|
||||
// GetWhitelistedIP mirrors models.get_whitelisted_ip. domainName == "" means no domain
|
||||
// filter, matching the Python default parameter.
|
||||
func (d *DB) GetWhitelistedIP(ipAddress, domainName string) (*WhitelistedIP, error) {
|
||||
var row *sql.Row
|
||||
if domainName == "" {
|
||||
row = d.QueryRow(`SELECT id, ip_address, domain_id, is_active, created_at, store_message_content
|
||||
FROM esrv_whitelisted_ips WHERE ip_address = ? AND is_active = 1`, ipAddress)
|
||||
} else {
|
||||
dom, err := d.GetDomainByName(domainName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dom == nil {
|
||||
return nil, nil
|
||||
}
|
||||
row = d.QueryRow(`SELECT id, ip_address, domain_id, is_active, created_at, store_message_content
|
||||
FROM esrv_whitelisted_ips WHERE ip_address = ? AND is_active = 1 AND domain_id = ?`, ipAddress, dom.ID)
|
||||
}
|
||||
var w WhitelistedIP
|
||||
var createdAt string
|
||||
if err := row.Scan(&w.ID, &w.IPAddress, &w.DomainID, &w.IsActive, &createdAt, &w.StoreMessageContent); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
w.CreatedAt, _ = parseTime(createdAt)
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
// CanSendForDomain mirrors WhitelistedIP.can_send_for_domain. Not called from the live
|
||||
// auth path (models.py's own equivalent isn't either) — kept for interface parity.
|
||||
func (w WhitelistedIP) CanSendForDomain(d *DB, domainName string) (bool, error) {
|
||||
if !w.IsActive {
|
||||
return false, nil
|
||||
}
|
||||
dom, err := d.GetDomainByName(domainName)
|
||||
if err != nil || dom == nil {
|
||||
return false, err
|
||||
}
|
||||
return w.DomainID == dom.ID, nil
|
||||
}
|
||||
|
||||
// LogAuthAttempt mirrors models.log_auth_attempt.
|
||||
func (d *DB) LogAuthAttempt(authType, identifier, ipAddress string, success bool, message string) error {
|
||||
_, err := d.Exec(`INSERT INTO esrv_auth_logs (auth_type, identifier, ip_address, success, message)
|
||||
VALUES (?, ?, ?, ?, ?)`, authType, identifier, ipAddress, success, message)
|
||||
return err
|
||||
}
|
||||
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
for _, layout := range []string{"2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05", time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, errors.New("unparseable time: " + s)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Package db is the SQLite data layer, mirroring email_server/models.py. It uses plain
|
||||
// database/sql + hand-written SQL rather than an ORM — the schema is small and fixed,
|
||||
// so an ORM would be an unrequested abstraction.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// schema creates all esrv_* tables if missing. There is no migration framework here,
|
||||
// matching the Python precedent (its own migrations/ directory is a single manual SQL
|
||||
// patch file, never auto-applied) — CREATE TABLE IF NOT EXISTS covers the whole surface.
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS esrv_domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_name TEXT NOT NULL UNIQUE,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
verification_token TEXT NOT NULL DEFAULT '',
|
||||
is_verified INTEGER NOT NULL DEFAULT 0,
|
||||
verified_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_senders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
can_send_as_domain INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
store_message_content INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_whitelisted_ips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address TEXT NOT NULL,
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
store_message_content INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_email_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id TEXT NOT NULL UNIQUE,
|
||||
timestamp DATETIME NOT NULL,
|
||||
peer_ip TEXT NOT NULL,
|
||||
mail_from TEXT NOT NULL,
|
||||
to_address TEXT NOT NULL DEFAULT '',
|
||||
cc_addresses TEXT DEFAULT '',
|
||||
bcc_addresses TEXT DEFAULT '',
|
||||
subject TEXT,
|
||||
email_headers TEXT NOT NULL,
|
||||
message_body TEXT,
|
||||
status TEXT NOT NULL,
|
||||
dkim_signed INTEGER NOT NULL DEFAULT 0,
|
||||
username TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_email_recipient_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email_log_id INTEGER NOT NULL REFERENCES esrv_email_logs(id),
|
||||
recipient TEXT NOT NULL,
|
||||
recipient_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
server_response TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_auth_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
auth_type TEXT NOT NULL,
|
||||
identifier TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
success INTEGER NOT NULL,
|
||||
message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_dkim_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
selector TEXT NOT NULL DEFAULT 'default',
|
||||
private_key TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
replaced_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_custom_headers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
header_name TEXT NOT NULL,
|
||||
header_value TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_email_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email_log_id INTEGER NOT NULL REFERENCES esrv_email_logs(id),
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
size INTEGER,
|
||||
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_admin_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
totp_secret TEXT NOT NULL DEFAULT '',
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
is_global_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Which domains a non-global admin is allowed to see/manage. Global admins have no
|
||||
-- rows here at all — their access is implicit (AdminUser.IsGlobalAdmin).
|
||||
CREATE TABLE IF NOT EXISTS esrv_admin_domain_access (
|
||||
admin_user_id INTEGER NOT NULL REFERENCES esrv_admin_users(id),
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
PRIMARY KEY (admin_user_id, domain_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_admin_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES esrv_admin_users(id),
|
||||
mfa_verified INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES esrv_admin_users(id),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
credential_data TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`
|
||||
|
||||
// migrateAddedColumns best-effort ALTER TABLEs the columns added to esrv_domains
|
||||
// after its first release, for dev DBs created before this feature existed.
|
||||
// CREATE TABLE IF NOT EXISTS doesn't retrofit columns onto an existing table, and
|
||||
// there's no migration framework here (see the schema comment above) — errors are
|
||||
// ignored since SQLite has no "ADD COLUMN IF NOT EXISTS" and a duplicate-column
|
||||
// error just means the column is already there.
|
||||
func migrateAddedColumns(db *sql.DB) {
|
||||
stmts := []string{
|
||||
`ALTER TABLE esrv_domains ADD COLUMN verification_token TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE esrv_domains ADD COLUMN is_verified INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE esrv_domains ADD COLUMN verified_at DATETIME`,
|
||||
`ALTER TABLE esrv_admin_users ADD COLUMN is_global_admin INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE esrv_admin_users ADD COLUMN created_by INTEGER`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
db.Exec(stmt)
|
||||
}
|
||||
}
|
||||
|
||||
// DB wraps *sql.DB with the query helpers below.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the SQLite file at path and ensures the schema exists.
|
||||
func Open(path string) (*DB, error) {
|
||||
sqlDB, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
if _, err := sqlDB.Exec(schema); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("create tables: %w", err)
|
||||
}
|
||||
migrateAddedColumns(sqlDB)
|
||||
return &DB{sqlDB}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user