@@ -11,10 +11,8 @@ import (
1111 "syscall"
1212 "time"
1313
14- "github.com/mattn/go-isatty"
1514 "github.com/spf13/cobra"
1615 "github.com/wesm/msgvault/internal/deletion"
17- "github.com/wesm/msgvault/internal/gmail"
1816 "github.com/wesm/msgvault/internal/oauth"
1917 "github.com/wesm/msgvault/internal/store"
2018)
@@ -320,9 +318,16 @@ Examples:
320318 }
321319 }
322320
323- // Validate config
324- if ! cfg .OAuth .HasAnyConfig () {
325- return errOAuthNotConfigured ()
321+ // Open database early so we can resolve account identifiers.
322+ dbPath := cfg .DatabaseDSN ()
323+ s , err := store .Open (dbPath )
324+ if err != nil {
325+ return fmt .Errorf ("open database: %w" , err )
326+ }
327+ defer func () { _ = s .Close () }()
328+
329+ if err := s .InitSchema (); err != nil {
330+ return fmt .Errorf ("init schema: %w" , err )
326331 }
327332
328333 // Collect unique accounts from manifests
@@ -349,41 +354,42 @@ Examples:
349354 return fmt .Errorf ("multiple accounts in pending batches (%v) - use --account flag to specify which account" , accounts )
350355 }
351356 } else {
352- // Verify all manifests match the specified account
357+ // Resolve the user-supplied value to a source.
358+ // IMAP identifiers are URLs (imaps://user@host:port)
359+ // but the user may pass the email/display name.
360+ resolved , err := s .GetSourcesByIdentifierOrDisplayName (account )
361+ if err != nil {
362+ return fmt .Errorf ("look up source for %s: %w" , account , err )
363+ }
364+ var syncable []* store.Source
365+ for _ , c := range resolved {
366+ if c .SourceType == "gmail" || c .SourceType == "imap" {
367+ syncable = append (syncable , c )
368+ }
369+ }
370+ if len (syncable ) == 0 {
371+ return fmt .Errorf ("no gmail or imap source found for %s" , account )
372+ }
373+ if len (syncable ) > 1 {
374+ var types []string
375+ for _ , c := range syncable {
376+ types = append (types , fmt .Sprintf ("%s (%s)" , c .Identifier , c .SourceType ))
377+ }
378+ return fmt .Errorf ("multiple accounts match %q: %s\n Use the full identifier with --account to disambiguate" , account , strings .Join (types , ", " ))
379+ }
380+ found := syncable [0 ]
381+ // Canonicalize to stored identifier so manifest
382+ // comparisons work for IMAP display-name lookups.
383+ account = found .Identifier
384+
385+ // Verify all manifests match the resolved account
353386 for _ , m := range manifests {
354387 if m .Filters .Account != "" && m .Filters .Account != account {
355388 return fmt .Errorf ("batch %s is for account %s, not %s - filter batches by account or execute separately" , m .ID , m .Filters .Account , account )
356389 }
357390 }
358391 }
359392
360- // Open database
361- dbPath := cfg .DatabaseDSN ()
362- s , err := store .Open (dbPath )
363- if err != nil {
364- return fmt .Errorf ("open database: %w" , err )
365- }
366- defer func () { _ = s .Close () }()
367-
368- // Ensure schema is up to date (creates new indexes, etc.)
369- if err := s .InitSchema (); err != nil {
370- return fmt .Errorf ("init schema: %w" , err )
371- }
372-
373- // Resolve OAuth credentials for this account
374- appName := ""
375- src , srcErr := findGmailSource (s , account )
376- if srcErr != nil {
377- return fmt .Errorf ("look up source for %s: %w" , account , srcErr )
378- }
379- if src != nil {
380- appName = sourceOAuthApp (src )
381- }
382- clientSecretsPath , err := cfg .OAuth .ClientSecretsFor (appName )
383- if err != nil {
384- return err
385- }
386-
387393 // Set up context with cancellation
388394 ctx , cancel := context .WithCancel (cmd .Context ())
389395 defer cancel ()
@@ -397,59 +403,73 @@ Examples:
397403 cancel ()
398404 }()
399405
400- // Determine which scopes we need
401- needsBatchDelete := ! deleteTrash
402- var requiredScopes []string
403- if needsBatchDelete {
404- requiredScopes = oauth .ScopesDeletion
405- } else {
406- requiredScopes = oauth .Scopes
406+ // Look up the source to determine account type (gmail vs imap).
407+ sources , err := s .GetSourcesByIdentifier (account )
408+ if err != nil {
409+ return fmt .Errorf ("look up source for %s: %w" , account , err )
410+ }
411+ var src * store.Source
412+ for _ , candidate := range sources {
413+ if candidate .SourceType == "gmail" || candidate .SourceType == "imap" {
414+ src = candidate
415+ break
416+ }
417+ }
418+ if src == nil {
419+ return fmt .Errorf ("no gmail or imap source found for %s" , account )
407420 }
408421
409- // Create OAuth manager with appropriate scopes
410- oauthMgr , err := oauth .NewManagerWithScopes (clientSecretsPath , cfg .TokensDir (), logger , requiredScopes )
411- if err != nil {
412- return wrapOAuthError (fmt .Errorf ("create oauth manager: %w" , err ))
413- }
414-
415- // Proactively check if we need scope escalation before making API calls.
416- // Legacy tokens (saved before scope tracking) won't have scope metadata,
417- // so we only trigger proactive escalation when we positively know the
418- // token lacks the required scope.
419- if needsBatchDelete && ! oauthMgr .HasScope (account , "https://mail.google.com/" ) {
420- // Only trigger proactive escalation when we have scope metadata.
421- // Legacy tokens (saved before scope tracking) fall through to
422- // reactive detection on the first API call.
423- if oauthMgr .HasScopeMetadata (account ) {
424- // Token has scope metadata but lacks deletion scope — escalate now
425- if err := promptScopeEscalation (ctx , oauthMgr , account , needsBatchDelete , clientSecretsPath ); err != nil {
426- if errors .Is (err , errUserCanceled ) {
427- return nil
428- }
429- return err
430- }
431- // Re-create OAuth manager with new token
432- oauthMgr , err = oauth .NewManagerWithScopes (clientSecretsPath , cfg .TokensDir (), logger , requiredScopes )
422+ // For Gmail, handle scope escalation before building the client.
423+ // buildAPIClient uses standard scopes; deletion may need elevated ones.
424+ var clientSecretsPath string
425+ if src .SourceType == "gmail" {
426+ if ! cfg .OAuth .HasAnyConfig () {
427+ return errOAuthNotConfigured ()
428+ }
429+ appName := sourceOAuthApp (src )
430+ clientSecretsPath , err = cfg .OAuth .ClientSecretsFor (appName )
431+ if err != nil {
432+ return err
433+ }
434+
435+ needsBatchDelete := ! deleteTrash
436+ if needsBatchDelete {
437+ requiredScopes := oauth .ScopesDeletion
438+ oauthMgr , err := oauth .NewManagerWithScopes (clientSecretsPath , cfg .TokensDir (), logger , requiredScopes )
433439 if err != nil {
434440 return wrapOAuthError (fmt .Errorf ("create oauth manager: %w" , err ))
435441 }
442+ if ! oauthMgr .HasScope (account , "https://mail.google.com/" ) && oauthMgr .HasScopeMetadata (account ) {
443+ if err := promptScopeEscalation (ctx , oauthMgr , account , needsBatchDelete , clientSecretsPath ); err != nil {
444+ if errors .Is (err , errUserCanceled ) {
445+ return nil
446+ }
447+ return err
448+ }
449+ }
436450 }
437- // If no scope metadata at all (legacy token), fall through to reactive detection
438451 }
439452
440- interactive := isatty .IsTerminal (os .Stdin .Fd ()) ||
441- isatty .IsCygwinTerminal (os .Stdin .Fd ())
442- tokenSource , err := getTokenSourceWithReauth (ctx , oauthMgr , account , interactive )
453+ // Build API client — reuses the same factory as sync.
454+ getOAuthMgr := func (appName string ) (* oauth.Manager , error ) {
455+ secretsPath := clientSecretsPath
456+ if secretsPath == "" {
457+ var err error
458+ secretsPath , err = cfg .OAuth .ClientSecretsFor (appName )
459+ if err != nil {
460+ return nil , err
461+ }
462+ }
463+ scopes := oauth .Scopes
464+ if ! deleteTrash {
465+ scopes = oauth .ScopesDeletion
466+ }
467+ return oauth .NewManagerWithScopes (secretsPath , cfg .TokensDir (), logger , scopes )
468+ }
469+ client , err := buildAPIClient (ctx , src , getOAuthMgr )
443470 if err != nil {
444471 return err
445472 }
446-
447- // Create Gmail client
448- rateLimiter := gmail .NewRateLimiter (float64 (cfg .Sync .RateLimitQPS ))
449- client := gmail .NewClient (tokenSource ,
450- gmail .WithLogger (logger ),
451- gmail .WithRateLimiter (rateLimiter ),
452- )
453473 defer func () { _ = client .Close () }()
454474
455475 // Create executor
@@ -488,8 +508,12 @@ Examples:
488508 return nil
489509 }
490510
491- // Check if this is a scope error - offer to re-authorize
492- if isInsufficientScopeError (execErr ) {
511+ // Check if this is a scope error - offer to re-authorize (Gmail only)
512+ if src .SourceType == "gmail" && isInsufficientScopeError (execErr ) {
513+ oauthMgr , mgrErr := getOAuthMgr (sourceOAuthApp (src ))
514+ if mgrErr != nil {
515+ return mgrErr
516+ }
493517 if err := promptScopeEscalation (ctx , oauthMgr , account , ! useTrash , clientSecretsPath ); err != nil {
494518 if errors .Is (err , errUserCanceled ) {
495519 return nil
@@ -715,7 +739,7 @@ func init() {
715739 deleteStagedCmd .Flags ().BoolVarP (& deleteYes , "yes" , "y" , false , "Skip confirmation" )
716740 deleteStagedCmd .Flags ().BoolVar (& deleteDryRun , "dry-run" , false , "Show what would be deleted" )
717741 deleteStagedCmd .Flags ().BoolVarP (& deleteList , "list" , "l" , false , "List staged batches without executing" )
718- deleteStagedCmd .Flags ().StringVar (& deleteAccount , "account" , "" , "Gmail account to use" )
742+ deleteStagedCmd .Flags ().StringVar (& deleteAccount , "account" , "" , "Account to use (Gmail or IMAP) " )
719743
720744 rootCmd .AddCommand (listDeletionsCmd )
721745 rootCmd .AddCommand (showDeletionCmd )
0 commit comments