Files
patrickandClaude Sonnet 5.5 4eb300c252 feat(PROJ-94): Postfach-Zuordnung pro IMAP-Konto, imap-link
Tabelle imap_account_mails, Importer schreibt die Zuordnung auch bei Duplikaten,
Zugriffsregel und Index-Tokens nutzen sie, archivmail imap-link befüllt den
Bestand (Trockenlauf Standard, --apply schreibt nur die Zuordnung).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 14:38:27 +01:00

153 lines
3.7 KiB
Go

package storage
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
)
const imapAccountTokenPrefix = "imapacct"
type MailSource struct {
Type string
ID *int64
// PROJ-94: accounts from imap_account_mails, loaded separately because
// a failed link lookup must not drop the source-based rules.
Linked []int64
}
func ImapAccountToken(accountID int64) string {
if accountID <= 0 {
return ""
}
return imapAccountTokenPrefix + strconv.FormatInt(accountID, 10)
}
func (m MailSource) ImapAccountID() (int64, bool) {
if m.Type != "imap" || m.ID == nil || *m.ID <= 0 {
return 0, false
}
return *m.ID, true
}
func (m MailSource) AccountToken() string {
id, ok := m.ImapAccountID()
if !ok {
return ""
}
return ImapAccountToken(id)
}
func (m MailSource) AccountIDs() []int64 {
var ids []int64
if id, ok := m.ImapAccountID(); ok {
ids = append(ids, id)
}
for _, id := range m.Linked {
if id > 0 {
ids = append(ids, id)
}
}
slices.Sort(ids)
return slices.Compact(ids)
}
func (m MailSource) AccountTokens() []string {
ids := m.AccountIDs()
out := make([]string, 0, len(ids))
for _, id := range ids {
out = append(out, ImapAccountToken(id))
}
return out
}
// Unknown IDs and legacy mails yield an empty MailSource without error.
func (s *Store) GetMailSource(ctx context.Context, id string) (MailSource, error) {
if s.db == nil {
return MailSource{}, nil
}
var st *string
var sid *int64
err := s.db.QueryRow(ctx, `SELECT source_type, source_id FROM emails WHERE id = $1`, id).Scan(&st, &sid)
if errors.Is(err, pgx.ErrNoRows) {
return MailSource{}, nil
}
if err != nil {
return MailSource{}, fmt.Errorf("storage: get mail source: %w", err)
}
out := MailSource{ID: sid}
if st != nil {
out.Type = *st
}
return out, nil
}
func (s *Store) GetMailSources(ctx context.Context, ids []string) (map[string]MailSource, error) {
out := make(map[string]MailSource, len(ids))
if s.db == nil || len(ids) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx,
`SELECT id, source_type, source_id FROM emails WHERE id = ANY($1) AND source_type IS NOT NULL`, ids)
if err != nil {
return nil, fmt.Errorf("storage: get mail sources: %w", err)
}
defer rows.Close()
for rows.Next() {
var id, st string
var sid *int64
if err := rows.Scan(&id, &st, &sid); err != nil {
return nil, fmt.Errorf("storage: get mail sources scan: %w", err)
}
out[id] = MailSource{Type: st, ID: sid}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("storage: get mail sources rows: %w", err)
}
return out, nil
}
func ownerIdentities(username, email string) []string {
var out []string
for _, v := range []string{username, email} {
v = strings.ToLower(strings.TrimSpace(v))
if v != "" && !slices.Contains(out, v) {
out = append(out, v)
}
}
return out
}
// IS NOT DISTINCT FROM: a user without tenant only owns accounts without tenant.
func (s *Store) ImapAccountIDsForOwner(ctx context.Context, username, email string, tenantID *int64) ([]int64, error) {
idents := ownerIdentities(username, email)
if s.db == nil || len(idents) == 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, `
SELECT id FROM imap_accounts
WHERE lower(owner) = ANY($1) AND tenant_id IS NOT DISTINCT FROM $2
ORDER BY id`, idents, tenantID)
if err != nil {
return nil, fmt.Errorf("storage: imap accounts for owner: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("storage: imap accounts for owner scan: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("storage: imap accounts for owner rows: %w", err)
}
return ids, nil
}