Skip to content
Merged
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
180 changes: 102 additions & 78 deletions cmd/msgvault/cmd/deletions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand All @@ -349,41 +354,42 @@ 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)
}
}
}

// 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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down