Skip to content
Merged
Show file tree
Hide file tree
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
28 changes: 28 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6351,6 +6351,20 @@ paths:
name: email
schema:
type: string
- description: IMAP folder names to include (repeatable)
in: query
name: folder
schema:
items:
type: string
type: array
- description: IMAP folder names to exclude (repeatable)
in: query
name: skip-folder
schema:
items:
type: string
type: array
responses:
"200":
content:
Expand Down Expand Up @@ -6404,6 +6418,20 @@ paths:
name: noresume
schema:
type: boolean
- description: IMAP folder names to include (repeatable)
in: query
name: folder
schema:
items:
type: string
type: array
- description: IMAP folder names to exclude (repeatable)
in: query
name: skip-folder
schema:
items:
type: string
type: array
responses:
"200":
content:
Expand Down
48 changes: 40 additions & 8 deletions cmd/msgvault/cmd/imap_folder_state_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@ func TestSaveIMAPFolderStates_ErrorsBlockPersistence(t *testing.T) {
assert.Empty(t, loaded, "a run with fetch errors must not advance folder high water marks")
}

func TestSaveIMAPFolderStates_LeavesDeferredFoldersUnpersisted(t *testing.T) {
require := require.New(t)
addr, _ := testutil.StartIMAPMemServer(t, map[string]int{
"INBOX": 1,
"Archive": 0,
})
st := testutil.NewTestStore(t)
src, err := st.GetOrCreateSource("imap", "imap://alice@example.com")
require.NoError(err)

client := listedIMAPClient(t, addr, imapFolderStateSaveOption(st, src))
saveIMAPFolderStates(st, src, client, &gmail.SyncSummary{}, 0)

loaded, err := loadIMAPFolderStates(st, src.ID)
require.NoError(err)
assert.Contains(t, loaded, "Archive")
assert.NotContains(t, loaded, "INBOX",
"a folder with an unacknowledged message must remain retryable")
}

func TestSaveIMAPFolderStates_LimitTruncationBlocksPersistence(t *testing.T) {
require := require.New(t)
addr, _ := testutil.StartIMAPMemServer(t, map[string]int{"INBOX": 5})
Expand Down Expand Up @@ -129,20 +149,32 @@ func TestIMAPFolderStateOptions_RoundTripSkipsUnchangedFolders(t *testing.T) {
"a resync against an unchanged server must list no messages")
}

func TestIMAPFolderStateOptions_ForceRescanBypassesSavedStates(t *testing.T) {
func TestIMAPFolderStateOptions_ForceRescanRetainsStatesAndEnumerates(t *testing.T) {
require := require.New(t)
assert := assert.New(t)
addr, _ := testutil.StartIMAPMemServer(
t, map[string]int{"INBOX": 1})
st := testutil.NewTestStore(t)
src, err := st.GetOrCreateSource("imap", "imap://alice@example.com")
src, err := st.GetOrCreateSource(
"imap", "imap://alice@example.com")
require.NoError(err)

require.NoError(st.UpsertIMAPFolderStates(src.ID, []store.IMAPFolderState{
{Mailbox: "INBOX", UIDValidity: 42, UIDNext: 100},
}))
first := listedIMAPClient(t, addr)
saveIMAPFolderStates(st, src, first, &gmail.SyncSummary{}, 0)
require.NoError(first.Close())

opts := imapFolderStateOptions(st, src, true)
require.Len(opts, 2,
"--noresume needs saved identity state and forced enumeration")

assert.Empty(imapFolderStateOptions(st, src, true),
"--noresume must ignore saved folder states so every mailbox is re-enumerated")
assert.NotEmpty(imapFolderStateOptions(st, src, false))
second := listedIMAPClient(t, addr, opts...)
ctx, cancel := context.WithTimeout(
context.Background(), 30*time.Second)
defer cancel()
resp, err := second.ListMessages(ctx, "", "")
require.NoError(err)
assert.Len(resp.Messages, 1,
"forced enumeration must not skip an unchanged mailbox")
}

func TestIMAPFolderStateSaveOption_PersistsCompletedFolders(t *testing.T) {
Expand Down
155 changes: 155 additions & 0 deletions cmd/msgvault/cmd/listfolders.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package cmd

import (
"context"
"errors"
"fmt"
"strings"

"github.com/spf13/cobra"
imapclient "go.kenn.io/msgvault/internal/imap"
"go.kenn.io/msgvault/internal/microsoft"
"go.kenn.io/msgvault/internal/store"
"go.kenn.io/msgvault/internal/textutil"
)

func newListFoldersCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list-folders [name]",
Short: "List IMAP folders (mailboxes) available for an account",
Long: `List all IMAP folders (mailboxes) available for one or all
IMAP accounts, along with message count. This helps you
choose which folders to include or exclude via --folders or
--skip-folders on the sync command.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !isDaemonCLISubprocess() {
return runDaemonCLICommandHTTPFromCobra(cmd, args)
}
return runListFoldersLocal(cmd, args)
},
}
return cmd
}

func runListFoldersLocal(cmd *cobra.Command, args []string) error {
s, cleanup, err := openWritableStoreAndInit()
if err != nil {
return err
}
defer cleanup()

ctx := cmd.Context()

var sources []*store.Source
if len(args) == 1 {
matches, err := s.GetSourcesByIdentifierOrDisplayName(args[0])
if err != nil {
return fmt.Errorf("lookup source: %w", err)
}
for _, src := range matches {
if src.SourceType == sourceTypeIMAP {
sources = append(sources, src)
}
}
if len(sources) == 0 && len(matches) > 0 {
return fmt.Errorf("account %q is not an IMAP source (type: %s)", args[0], matches[0].SourceType)
}
} else {
all, err := s.ListSources(sourceTypeIMAP)
if err != nil {
return fmt.Errorf("list IMAP sources: %w", err)
}
sources = all
}
if len(sources) == 0 {
return errors.New("no IMAP accounts configured")
}

for i, src := range sources {
if i > 0 {
fmt.Println()
}
if err := listFolders(ctx, src); err != nil {
return err
}
}
return nil
}

func listFolders(ctx context.Context, src *store.Source) error {
if cfg == nil {
return errors.New("configuration not loaded")
}
displayID := src.Identifier
if src.DisplayName.Valid && src.DisplayName.String != "" {
displayID = src.DisplayName.String
}
fmt.Printf("Account: %s\n", displayID)

skip, err := imapSkipReason(src)
if err != nil {
return err
}
if skip != "" {
fmt.Println(skip)
return nil
}

imapCfg, err := imapclient.ConfigFromJSON(src.SyncConfig.String)
if err != nil {
return fmt.Errorf("malformed IMAP config: %w", err)
}

var password string
var tokenFn func(ctx context.Context) (string, error)
msClientID := cfg.Microsoft.ClientID
msTenant := cfg.Microsoft.EffectiveTenantID()
msTokensDir := cfg.TokensDir()

switch imapCfg.EffectiveAuthMethod() {
case imapclient.AuthXOAuth2:
if msClientID == "" {
fmt.Println(" Microsoft OAuth not configured — add [microsoft] section to config.toml")
return errors.New("no Microsoft client ID configured")
}
msRedirectURI := cfg.Microsoft.EffectiveRedirectURI()
msMgr := microsoft.NewManager(msClientID, msTenant, msRedirectURI, msTokensDir, logger)
if !msMgr.HasToken(imapCfg.Username) {
fmt.Println(" Microsoft token not found — run 'add-o365' first")
return fmt.Errorf("no Microsoft token for %s", imapCfg.Username)
}
tokenFn, err = msMgr.TokenSource(ctx, imapCfg.Username)
if err != nil {
fmt.Printf(" Failed to load token: %v (run 'add-o365' first)\n", err)
return fmt.Errorf("load Microsoft token: %w", err)
}
default:
password, err = imapclient.LoadCredentials(cfg.TokensDir(), src.Identifier)
if err != nil {
fmt.Println(" Credentials not found — run 'add-imap' first")
return fmt.Errorf("load credentials: %w", err)
}
}

folders, err := imapclient.NewClient(imapCfg, password, imapclient.WithTokenSource(tokenFn)).ListMailboxes(ctx)
if err != nil {
return fmt.Errorf("list mailboxes: %w", err)
}

fmt.Printf("\n %-35s %10s\n", "Folder", "Messages")
fmt.Println(" " + strings.Repeat("-", 46))
for _, f := range folders {
if f.NumMessages == -1 {
fmt.Printf(" %-35s %10s\n", textutil.SanitizeTerminal(f.Mailbox), "??")
} else {
fmt.Printf(" %-35s %10d\n", textutil.SanitizeTerminal(f.Mailbox), f.NumMessages)
}
}
fmt.Println()
return nil
}

func init() {
rootCmd.AddCommand(newListFoldersCmd())
}
Loading