diff --git a/cmd/msgvault/cmd/deletions.go b/cmd/msgvault/cmd/deletions.go index f53decda..39ca0fa3 100644 --- a/cmd/msgvault/cmd/deletions.go +++ b/cmd/msgvault/cmd/deletions.go @@ -11,10 +11,8 @@ import ( "syscall" "time" - "github.com/mattn/go-isatty" "github.com/spf13/cobra" "github.com/wesm/msgvault/internal/deletion" - "github.com/wesm/msgvault/internal/gmail" "github.com/wesm/msgvault/internal/oauth" "github.com/wesm/msgvault/internal/store" ) @@ -320,9 +318,16 @@ Examples: } } - // Validate config - if !cfg.OAuth.HasAnyConfig() { - return errOAuthNotConfigured() + // Open database early so we can resolve account identifiers. + dbPath := cfg.DatabaseDSN() + s, err := store.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer func() { _ = s.Close() }() + + if err := s.InitSchema(); err != nil { + return fmt.Errorf("init schema: %w", err) } // Collect unique accounts from manifests @@ -349,7 +354,35 @@ Examples: return fmt.Errorf("multiple accounts in pending batches (%v) - use --account flag to specify which account", accounts) } } else { - // Verify all manifests match the specified account + // Resolve the user-supplied value to a source. + // IMAP identifiers are URLs (imaps://user@host:port) + // but the user may pass the email/display name. + resolved, err := s.GetSourcesByIdentifierOrDisplayName(account) + if err != nil { + return fmt.Errorf("look up source for %s: %w", account, err) + } + var syncable []*store.Source + for _, c := range resolved { + if c.SourceType == "gmail" || c.SourceType == "imap" { + syncable = append(syncable, c) + } + } + if len(syncable) == 0 { + return fmt.Errorf("no gmail or imap source found for %s", account) + } + if len(syncable) > 1 { + var types []string + for _, c := range syncable { + types = append(types, fmt.Sprintf("%s (%s)", c.Identifier, c.SourceType)) + } + return fmt.Errorf("multiple accounts match %q: %s\nUse the full identifier with --account to disambiguate", account, strings.Join(types, ", ")) + } + found := syncable[0] + // Canonicalize to stored identifier so manifest + // comparisons work for IMAP display-name lookups. + account = found.Identifier + + // Verify all manifests match the resolved account for _, m := range manifests { if m.Filters.Account != "" && m.Filters.Account != account { return fmt.Errorf("batch %s is for account %s, not %s - filter batches by account or execute separately", m.ID, m.Filters.Account, account) @@ -357,33 +390,6 @@ Examples: } } - // Open database - dbPath := cfg.DatabaseDSN() - s, err := store.Open(dbPath) - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer func() { _ = s.Close() }() - - // Ensure schema is up to date (creates new indexes, etc.) - if err := s.InitSchema(); err != nil { - return fmt.Errorf("init schema: %w", err) - } - - // Resolve OAuth credentials for this account - appName := "" - src, srcErr := findGmailSource(s, account) - if srcErr != nil { - return fmt.Errorf("look up source for %s: %w", account, srcErr) - } - if src != nil { - appName = sourceOAuthApp(src) - } - clientSecretsPath, err := cfg.OAuth.ClientSecretsFor(appName) - if err != nil { - return err - } - // Set up context with cancellation ctx, cancel := context.WithCancel(cmd.Context()) defer cancel() @@ -397,59 +403,73 @@ Examples: cancel() }() - // Determine which scopes we need - needsBatchDelete := !deleteTrash - var requiredScopes []string - if needsBatchDelete { - requiredScopes = oauth.ScopesDeletion - } else { - requiredScopes = oauth.Scopes + // Look up the source to determine account type (gmail vs imap). + sources, err := s.GetSourcesByIdentifier(account) + if err != nil { + return fmt.Errorf("look up source for %s: %w", account, err) + } + var src *store.Source + for _, candidate := range sources { + if candidate.SourceType == "gmail" || candidate.SourceType == "imap" { + src = candidate + break + } + } + if src == nil { + return fmt.Errorf("no gmail or imap source found for %s", account) } - // Create OAuth manager with appropriate scopes - oauthMgr, err := oauth.NewManagerWithScopes(clientSecretsPath, cfg.TokensDir(), logger, requiredScopes) - if err != nil { - return wrapOAuthError(fmt.Errorf("create oauth manager: %w", err)) - } - - // Proactively check if we need scope escalation before making API calls. - // Legacy tokens (saved before scope tracking) won't have scope metadata, - // so we only trigger proactive escalation when we positively know the - // token lacks the required scope. - if needsBatchDelete && !oauthMgr.HasScope(account, "https://mail.google.com/") { - // Only trigger proactive escalation when we have scope metadata. - // Legacy tokens (saved before scope tracking) fall through to - // reactive detection on the first API call. - if oauthMgr.HasScopeMetadata(account) { - // Token has scope metadata but lacks deletion scope — escalate now - if err := promptScopeEscalation(ctx, oauthMgr, account, needsBatchDelete, clientSecretsPath); err != nil { - if errors.Is(err, errUserCanceled) { - return nil - } - return err - } - // Re-create OAuth manager with new token - oauthMgr, err = oauth.NewManagerWithScopes(clientSecretsPath, cfg.TokensDir(), logger, requiredScopes) + // For Gmail, handle scope escalation before building the client. + // buildAPIClient uses standard scopes; deletion may need elevated ones. + var clientSecretsPath string + if src.SourceType == "gmail" { + if !cfg.OAuth.HasAnyConfig() { + return errOAuthNotConfigured() + } + appName := sourceOAuthApp(src) + clientSecretsPath, err = cfg.OAuth.ClientSecretsFor(appName) + if err != nil { + return err + } + + needsBatchDelete := !deleteTrash + if needsBatchDelete { + requiredScopes := oauth.ScopesDeletion + oauthMgr, err := oauth.NewManagerWithScopes(clientSecretsPath, cfg.TokensDir(), logger, requiredScopes) if err != nil { return wrapOAuthError(fmt.Errorf("create oauth manager: %w", err)) } + if !oauthMgr.HasScope(account, "https://mail.google.com/") && oauthMgr.HasScopeMetadata(account) { + if err := promptScopeEscalation(ctx, oauthMgr, account, needsBatchDelete, clientSecretsPath); err != nil { + if errors.Is(err, errUserCanceled) { + return nil + } + return err + } + } } - // If no scope metadata at all (legacy token), fall through to reactive detection } - interactive := isatty.IsTerminal(os.Stdin.Fd()) || - isatty.IsCygwinTerminal(os.Stdin.Fd()) - tokenSource, err := getTokenSourceWithReauth(ctx, oauthMgr, account, interactive) + // Build API client — reuses the same factory as sync. + getOAuthMgr := func(appName string) (*oauth.Manager, error) { + secretsPath := clientSecretsPath + if secretsPath == "" { + var err error + secretsPath, err = cfg.OAuth.ClientSecretsFor(appName) + if err != nil { + return nil, err + } + } + scopes := oauth.Scopes + if !deleteTrash { + scopes = oauth.ScopesDeletion + } + return oauth.NewManagerWithScopes(secretsPath, cfg.TokensDir(), logger, scopes) + } + client, err := buildAPIClient(ctx, src, getOAuthMgr) if err != nil { return err } - - // Create Gmail client - rateLimiter := gmail.NewRateLimiter(float64(cfg.Sync.RateLimitQPS)) - client := gmail.NewClient(tokenSource, - gmail.WithLogger(logger), - gmail.WithRateLimiter(rateLimiter), - ) defer func() { _ = client.Close() }() // Create executor @@ -488,8 +508,12 @@ Examples: return nil } - // Check if this is a scope error - offer to re-authorize - if isInsufficientScopeError(execErr) { + // Check if this is a scope error - offer to re-authorize (Gmail only) + if src.SourceType == "gmail" && isInsufficientScopeError(execErr) { + oauthMgr, mgrErr := getOAuthMgr(sourceOAuthApp(src)) + if mgrErr != nil { + return mgrErr + } if err := promptScopeEscalation(ctx, oauthMgr, account, !useTrash, clientSecretsPath); err != nil { if errors.Is(err, errUserCanceled) { return nil @@ -715,7 +739,7 @@ func init() { deleteStagedCmd.Flags().BoolVarP(&deleteYes, "yes", "y", false, "Skip confirmation") deleteStagedCmd.Flags().BoolVar(&deleteDryRun, "dry-run", false, "Show what would be deleted") deleteStagedCmd.Flags().BoolVarP(&deleteList, "list", "l", false, "List staged batches without executing") - deleteStagedCmd.Flags().StringVar(&deleteAccount, "account", "", "Gmail account to use") + deleteStagedCmd.Flags().StringVar(&deleteAccount, "account", "", "Account to use (Gmail or IMAP)") rootCmd.AddCommand(listDeletionsCmd) rootCmd.AddCommand(showDeletionCmd)