Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions cmd/msgvault/cmd/account_identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package cmd

import (
"fmt"
"io"
"strings"

"github.com/wesm/msgvault/internal/store"
)

// noDefaultIdentityHelp is the flag help text for --no-default-identity.
// Each ingest command registers its own bool variable and reuses this constant.
const noDefaultIdentityHelp = "Suppress auto-default-identity at account creation. " +
"Note: a one-time legacy [identity] config migration may still write confirmed " +
"identifiers to the account on first post-upgrade startup."

// confirmDefaultIdentity writes one confirmed identifier to a freshly
// created source's identity. Best-effort: any error is logged and swallowed
// so a partially failed identity write never breaks ingest. Empty identifiers
// are a silent no-op.
//
// Skips the write when the source already has at least one identity row.
// add-account / add-imap / add-o365 / import-* commands all call this on
// every invocation (including reruns and rebinds), so without this guard
// an identity the user explicitly removed via `identity remove` would be
// re-added on the next ingest re-run, silently affecting dedup sent-copy
// detection. The guard preserves the documented "freshly created source"
// intent while degrading gracefully if the user has removed every
// identity (in which case the default is restored, which is desirable).
//
// **Ordering note:** ingest commands MUST call confirmDefaultIdentity
// BEFORE runPostSourceCreateMigrations on the same invocation. The
// legacy [identity] migration uses set-semantics merge, so calling the
// default-identity write first and the migration second produces the
// correct merged state. Calling them in the other order populates
// account_identities with the legacy addresses first, then the
// `len(existing) > 0` guard suppresses the source's own account
// identifier entirely (regression caught in iter15). See the per-ingest
// command order in addaccount.go etc.
//
// account is the user-facing account name shown in the confirmation message.
// Callers should gate this behind the per-command --no-default-identity flag.
func confirmDefaultIdentity(out io.Writer, s *store.Store, sourceID int64, account, identifier, signal string) {
id := strings.TrimSpace(identifier)
if id == "" {
return
}
existing, err := s.ListAccountIdentities(sourceID)
if err != nil {
logger.Warn("auto-default-identity precheck failed",
"source_id", sourceID,
"account", account,
"error", err.Error())
return
}
if len(existing) > 0 {
return
}
if err := s.AddAccountIdentity(sourceID, id, signal); err != nil {
logger.Warn("auto-default-identity write failed",
"source_id", sourceID,
"account", account,
"identifier", id,
"signal", signal,
"error", err.Error())
return
}
_, _ = fmt.Fprintf(out, "Confirmed identity %s on %s (signal: %s).\n", id, account, signal)
}
122 changes: 122 additions & 0 deletions cmd/msgvault/cmd/account_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package cmd

import (
"io"
"log/slog"
"path/filepath"
"testing"

"github.com/wesm/msgvault/internal/store"
)

func TestConfirmDefaultIdentity_HappyPath(t *testing.T) {
tmpDir := t.TempDir()
s, err := store.Open(filepath.Join(tmpDir, "msgvault.db"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = s.Close() }()
if err := s.InitSchema(); err != nil {
t.Fatal(err)
}

src, err := s.GetOrCreateSource("gmail", "alice@example.com")
if err != nil {
t.Fatal(err)
}
confirmDefaultIdentity(io.Discard, s, src.ID, "alice@example.com", "alice@example.com", "account-identifier")
rows, err := s.ListAccountIdentities(src.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Address != "alice@example.com" {
t.Fatalf("got %+v", rows)
}
if rows[0].SourceSignal != "account-identifier" {
t.Errorf("signal=%q", rows[0].SourceSignal)
}
}

func TestConfirmDefaultIdentity_EmptyIdentifierIsNoOp(t *testing.T) {
tmpDir := t.TempDir()
s, err := store.Open(filepath.Join(tmpDir, "msgvault.db"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = s.Close() }()
if err := s.InitSchema(); err != nil {
t.Fatal(err)
}

src, err := s.GetOrCreateSource("gmail", "alice@example.com")
if err != nil {
t.Fatal(err)
}
confirmDefaultIdentity(io.Discard, s, src.ID, "alice@example.com", "", "account-identifier")
rows, _ := s.ListAccountIdentities(src.ID)
if len(rows) != 0 {
t.Errorf("want empty, got %+v", rows)
}
}

func TestConfirmDefaultIdentity_StoreErrorDoesNotPanic(t *testing.T) {
tmpDir := t.TempDir()
s, err := store.Open(filepath.Join(tmpDir, "msgvault.db"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = s.Close() }()
if err := s.InitSchema(); err != nil {
t.Fatal(err)
}

savedLogger := logger
defer func() { logger = savedLogger }()
logger = slog.New(slog.NewTextHandler(io.Discard, nil))

prevDefault := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
t.Cleanup(func() { slog.SetDefault(prevDefault) })

// sourceID 99999 does not exist; FK violation returns an error
// from AddAccountIdentity. The helper must swallow it.
confirmDefaultIdentity(io.Discard, s, 99999, "ghost@example.com", "ghost@example.com", "account-identifier")
}

// TestConfirmDefaultIdentity_LegacyMigrationOverridesNoDefault pins the
// documented behavior: skipping confirmDefaultIdentity (simulating
// --no-default-identity) does NOT prevent MigrateLegacyIdentityConfig from
// writing the address.
func TestConfirmDefaultIdentity_LegacyMigrationOverridesNoDefault(t *testing.T) {
tmpDir := t.TempDir()
s, err := store.Open(filepath.Join(tmpDir, "msgvault.db"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = s.Close() }()
if err := s.InitSchema(); err != nil {
t.Fatal(err)
}

_, err = s.GetOrCreateSource("gmail", "alice@example.com")
if err != nil {
t.Fatal(err)
}
// Simulate --no-default-identity: do not call confirmDefaultIdentity.
// Then run startup migrations with a non-empty legacy address list.
applied, _, _, _, err := s.MigrateLegacyIdentityConfig([]string{"alice@example.com"})
if err != nil {
t.Fatal(err)
}
if !applied {
t.Fatal("migration did not apply")
}
src, err := s.GetOrCreateSource("gmail", "alice@example.com")
if err != nil {
t.Fatal(err)
}
rows, _ := s.ListAccountIdentities(src.ID)
if len(rows) != 1 {
t.Fatalf("legacy migration should have written, got %+v", rows)
}
}
138 changes: 138 additions & 0 deletions cmd/msgvault/cmd/account_scope.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package cmd

import (
"errors"
"fmt"

"github.com/wesm/msgvault/internal/store"
)

// Scope is the result of resolving a user-supplied --account or
// --collection flag against the store.
type Scope struct {
Input string
Source *store.Source
Collection *store.CollectionWithSources
}

// IsEmpty reports whether the scope resolved to nothing.
func (s Scope) IsEmpty() bool {
return s.Source == nil && s.Collection == nil
}

// IsCollection reports whether the scope refers to a collection.
func (s Scope) IsCollection() bool {
return s.Collection != nil
}

// SourceIDs returns the source IDs that this scope expands to.
func (s Scope) SourceIDs() []int64 {
switch {
case s.Collection != nil:
return append([]int64(nil), s.Collection.SourceIDs...)
case s.Source != nil:
return []int64{s.Source.ID}
}
return nil
}

// DisplayName returns a human-readable label for the scope.
func (s Scope) DisplayName() string {
switch {
case s.Collection != nil:
return s.Collection.Name
case s.Source != nil:
return s.Source.Identifier
}
return ""
}

// ResolveAccountFlag resolves the value of an --account flag.
// It rejects collection names with a hint to use --collection.
func ResolveAccountFlag(st *store.Store, input string) (Scope, error) {
scope := Scope{Input: input}
if input == "" {
return scope, nil
}

// Try source resolution first.
sources, err := st.GetSourcesByIdentifierOrDisplayName(input)
if err != nil {
return scope, fmt.Errorf("look up source for %q: %w", input, err)
}
if len(sources) > 1 {
names := make([]string, 0, len(sources))
for _, s := range sources {
names = append(names, fmt.Sprintf(
"%s (%s, id=%d)",
s.Identifier, s.SourceType, s.ID,
))
}
return scope, fmt.Errorf(
"ambiguous account %q matches multiple sources: %v",
input, names,
)
}
if len(sources) == 1 {
scope.Source = sources[0]
return scope, nil
}

// No source match — check whether a collection exists with this name and
// reject with a helpful hint.
_, cerr := st.GetCollectionByName(input)
switch {
case cerr == nil:
return scope, fmt.Errorf(
"%q is a collection, not an account; use --collection %s",
input, input,
)
case errors.Is(cerr, store.ErrCollectionNotFound):
// Neither a source nor a collection.
default:
return scope, fmt.Errorf("look up collection %q: %w", input, cerr)
}

return scope, fmt.Errorf(
"no account found for %q (try 'msgvault list-accounts')",
input,
)
}

// ResolveCollectionFlag resolves the value of a --collection flag.
// It rejects account identifiers with a hint to use --account.
func ResolveCollectionFlag(st *store.Store, input string) (Scope, error) {
scope := Scope{Input: input}
if input == "" {
return scope, nil
}

// Try collection resolution first.
coll, err := st.GetCollectionByName(input)
switch {
case err == nil:
scope.Collection = coll
return scope, nil
case errors.Is(err, store.ErrCollectionNotFound):
// Fall through to source check.
default:
return scope, fmt.Errorf("look up collection %q: %w", input, err)
}

// No collection found — check whether any source matches and reject with a hint.
sources, serr := st.GetSourcesByIdentifierOrDisplayName(input)
if serr != nil {
return scope, fmt.Errorf("look up source for %q: %w", input, serr)
}
if len(sources) >= 1 {
return scope, fmt.Errorf(
"%q is an account, not a collection; use --account %s",
input, input,
)
}

return scope, fmt.Errorf(
"no collection named %q (try 'msgvault collection list')",
input,
)
}
Loading
Loading