Skip to content

Commit d629a1e

Browse files
DominicHolmeswesmclaude
authored
Support IMAP accounts in delete-staged command (#223)
_Human note: this fixed my use case (processing a ton of iCloud-hosted emails). Code _looks_ reasonable to me, but I don't know golang. iCloud (IMAP) path is tested, Gmail path is untested (I don't have a gmail)._ =========== ## Summary - `delete-staged` now detects account type and routes IMAP accounts through the IMAP client path instead of always assuming Gmail OAuth - Reuses the existing `buildAPIClient` factory (same one used by sync) to avoid duplicating IMAP client construction - Preserves full Gmail scope escalation behavior (both proactive and reactive paths) Closes #221 ## Testing Tested end-to-end on a real iCloud IMAP account: - **10-message batch**: 10/10 succeeded, 0 failed (8s). Batch delete correctly fell back to per-message `UID STORE \Deleted` + `UID EXPUNGE`. - **741-message batch**: 741/741 succeeded, 0 failed (~7min). Confirmed the fallback path handles large batches reliably. - `--dry-run` works correctly for IMAP accounts - All existing tests pass (`make test`) - `go fmt` and `go vet` clean --------- Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f83ba71 commit d629a1e

1 file changed

Lines changed: 102 additions & 78 deletions

File tree

cmd/msgvault/cmd/deletions.go

Lines changed: 102 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -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\nUse 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

Comments
 (0)