Skip to content

Commit cec0d51

Browse files
mattfwesmcodex
committed
feat(imap): add folder listing and filtering for imap sync
Large IMAP accounts need a way to start with a useful subset and avoid repeatedly scanning folders that do not belong in the local archive. - Honor folder filters across normal and full sync paths without reintroducing excluded special-use mailboxes. - Track unchanged folders so later syncs can skip unnecessary enumeration. - Preserve canonical message IDs and previously learned labels during partial rescans. - Refresh existing-message labels with lightweight header metadata: filtered or incomplete snapshots merge observations, while complete snapshots replace stale membership. - Keep one missing or failed metadata result from aborting updates for the rest of its batch. - Keep raw MIME downloads for new messages instead of retransferring archived bodies and attachments. - Accept repeatable folder flags so mailbox names containing commas remain unambiguous. - Sanitize server-provided mailbox names before printing them to a terminal. - Document folder discovery, matching rules, and filtered-sync behavior in the Zensical site. Generated with Codex Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com> Co-authored-by: Codex <noreply@openai.com>
1 parent 3445895 commit cec0d51

36 files changed

Lines changed: 2386 additions & 94 deletions

api/openapi.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6110,6 +6110,20 @@ paths:
61106110
name: email
61116111
schema:
61126112
type: string
6113+
- description: IMAP folder names to include (repeatable)
6114+
in: query
6115+
name: folder
6116+
schema:
6117+
items:
6118+
type: string
6119+
type: array
6120+
- description: IMAP folder names to exclude (repeatable)
6121+
in: query
6122+
name: skip-folder
6123+
schema:
6124+
items:
6125+
type: string
6126+
type: array
61136127
responses:
61146128
"200":
61156129
content:
@@ -6163,6 +6177,20 @@ paths:
61636177
name: noresume
61646178
schema:
61656179
type: boolean
6180+
- description: IMAP folder names to include (repeatable)
6181+
in: query
6182+
name: folder
6183+
schema:
6184+
items:
6185+
type: string
6186+
type: array
6187+
- description: IMAP folder names to exclude (repeatable)
6188+
in: query
6189+
name: skip-folder
6190+
schema:
6191+
items:
6192+
type: string
6193+
type: array
61666194
responses:
61676195
"200":
61686196
content:

cmd/msgvault/cmd/listfolders.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
imapclient "go.kenn.io/msgvault/internal/imap"
11+
"go.kenn.io/msgvault/internal/microsoft"
12+
"go.kenn.io/msgvault/internal/store"
13+
"go.kenn.io/msgvault/internal/textutil"
14+
)
15+
16+
func newListFoldersCmd() *cobra.Command {
17+
cmd := &cobra.Command{
18+
Use: "list-folders [name]",
19+
Short: "List IMAP folders (mailboxes) available for an account",
20+
Long: `List all IMAP folders (mailboxes) available for one or all
21+
IMAP accounts, along with message count. This helps you
22+
choose which folders to include or exclude via --folders or
23+
--skip-folders on the sync command.`,
24+
Args: cobra.MaximumNArgs(1),
25+
RunE: func(cmd *cobra.Command, args []string) error {
26+
if !isDaemonCLISubprocess() {
27+
return runDaemonCLICommandHTTPFromCobra(cmd, args)
28+
}
29+
return runListFoldersLocal(cmd, args)
30+
},
31+
}
32+
return cmd
33+
}
34+
35+
func runListFoldersLocal(cmd *cobra.Command, args []string) error {
36+
s, cleanup, err := openWritableStoreAndInit()
37+
if err != nil {
38+
return err
39+
}
40+
defer cleanup()
41+
42+
ctx := cmd.Context()
43+
44+
var sources []*store.Source
45+
if len(args) == 1 {
46+
matches, err := s.GetSourcesByIdentifierOrDisplayName(args[0])
47+
if err != nil {
48+
return fmt.Errorf("lookup source: %w", err)
49+
}
50+
for _, src := range matches {
51+
if src.SourceType == sourceTypeIMAP {
52+
sources = append(sources, src)
53+
}
54+
}
55+
if len(sources) == 0 && len(matches) > 0 {
56+
return fmt.Errorf("account %q is not an IMAP source (type: %s)", args[0], matches[0].SourceType)
57+
}
58+
} else {
59+
all, err := s.ListSources(sourceTypeIMAP)
60+
if err != nil {
61+
return fmt.Errorf("list IMAP sources: %w", err)
62+
}
63+
sources = all
64+
}
65+
if len(sources) == 0 {
66+
return errors.New("no IMAP accounts configured")
67+
}
68+
69+
for i, src := range sources {
70+
if i > 0 {
71+
fmt.Println()
72+
}
73+
if err := listFolders(ctx, src); err != nil {
74+
return err
75+
}
76+
}
77+
return nil
78+
}
79+
80+
func listFolders(ctx context.Context, src *store.Source) error {
81+
if cfg == nil {
82+
return errors.New("configuration not loaded")
83+
}
84+
displayID := src.Identifier
85+
if src.DisplayName.Valid && src.DisplayName.String != "" {
86+
displayID = src.DisplayName.String
87+
}
88+
fmt.Printf("Account: %s\n", displayID)
89+
90+
skip, err := imapSkipReason(src)
91+
if err != nil {
92+
return err
93+
}
94+
if skip != "" {
95+
fmt.Println(skip)
96+
return nil
97+
}
98+
99+
imapCfg, err := imapclient.ConfigFromJSON(src.SyncConfig.String)
100+
if err != nil {
101+
return fmt.Errorf("malformed IMAP config: %w", err)
102+
}
103+
104+
var password string
105+
var tokenFn func(ctx context.Context) (string, error)
106+
msClientID := cfg.Microsoft.ClientID
107+
msTenant := cfg.Microsoft.EffectiveTenantID()
108+
msTokensDir := cfg.TokensDir()
109+
110+
switch imapCfg.EffectiveAuthMethod() {
111+
case imapclient.AuthXOAuth2:
112+
if msClientID == "" {
113+
fmt.Println(" Microsoft OAuth not configured — add [microsoft] section to config.toml")
114+
return errors.New("no Microsoft client ID configured")
115+
}
116+
msRedirectURI := cfg.Microsoft.EffectiveRedirectURI()
117+
msMgr := microsoft.NewManager(msClientID, msTenant, msRedirectURI, msTokensDir, logger)
118+
if !msMgr.HasToken(imapCfg.Username) {
119+
fmt.Println(" Microsoft token not found — run 'add-o365' first")
120+
return fmt.Errorf("no Microsoft token for %s", imapCfg.Username)
121+
}
122+
tokenFn, err = msMgr.TokenSource(ctx, imapCfg.Username)
123+
if err != nil {
124+
fmt.Printf(" Failed to load token: %v (run 'add-o365' first)\n", err)
125+
return fmt.Errorf("load Microsoft token: %w", err)
126+
}
127+
default:
128+
password, err = imapclient.LoadCredentials(cfg.TokensDir(), src.Identifier)
129+
if err != nil {
130+
fmt.Println(" Credentials not found — run 'add-imap' first")
131+
return fmt.Errorf("load credentials: %w", err)
132+
}
133+
}
134+
135+
folders, err := imapclient.NewClient(imapCfg, password, imapclient.WithTokenSource(tokenFn)).ListMailboxes(ctx)
136+
if err != nil {
137+
return fmt.Errorf("list mailboxes: %w", err)
138+
}
139+
140+
fmt.Printf("\n %-35s %10s\n", "Folder", "Messages")
141+
fmt.Println(" " + strings.Repeat("-", 46))
142+
for _, f := range folders {
143+
if f.NumMessages == -1 {
144+
fmt.Printf(" %-35s %10s\n", textutil.SanitizeTerminal(f.Mailbox), "??")
145+
} else {
146+
fmt.Printf(" %-35s %10d\n", textutil.SanitizeTerminal(f.Mailbox), f.NumMessages)
147+
}
148+
}
149+
fmt.Println()
150+
return nil
151+
}
152+
153+
func init() {
154+
rootCmd.AddCommand(newListFoldersCmd())
155+
}

0 commit comments

Comments
 (0)