Skip to content
Open
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
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ make lint # Run linter
./msgvault build-cache --full-rebuild # Full rebuild
./msgvault stats # Show archive stats

# Apple Mail import
./msgvault import-emlx # Auto-discover accounts
./msgvault import-emlx ~/Library/Mail # Explicit mail directory
./msgvault import-emlx --account me@gmail.com # Specific account(s)
./msgvault import-emlx /path/to/dir --identifier me@gmail.com # Manual fallback

# Maintenance
./msgvault repair-encoding # Fix UTF-8 encoding issues
```
Expand All @@ -71,6 +77,8 @@ make lint # Run linter
- `build_cache.go` - Parquet cache builder (DuckDB)
- `repair_encoding.go` - UTF-8 encoding repair

- `import_emlx.go` - Apple Mail .emlx import command

### Core (`internal/`)
- `tui/model.go` - Bubble Tea TUI model and update logic
- `tui/view.go` - View rendering with lipgloss styling
Expand Down
318 changes: 266 additions & 52 deletions cmd/msgvault/cmd/import_emlx.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ package cmd
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"

"github.com/spf13/cobra"
"github.com/wesm/msgvault/internal/applemail"
"github.com/wesm/msgvault/internal/importer"
"github.com/wesm/msgvault/internal/store"
)
Expand All @@ -17,29 +21,60 @@ var (
importEmlxNoResume bool
importEmlxCheckpointInterval int
importEmlxNoAttachments bool
importEmlxAccountsDB string
importEmlxAccounts []string
importEmlxIdentifier string
)

var importEmlxCmd = &cobra.Command{
Use: "import-emlx <identifier> <mail-dir>",
Use: "import-emlx [mail-dir]",
Short: "Import Apple Mail .emlx files into msgvault",
Long: `Import Apple Mail .emlx files into msgvault.

The mail directory should be an Apple Mail mailbox tree containing
.mbox or .imapmbox directories, each with a Messages/ subdirectory
of .emlx files. You can also point directly at a single .mbox directory.
By default, auto-discovers accounts from Apple Mail's V10 directory layout
by reading ~/Library/Accounts/Accounts4.sqlite to map account GUIDs to
email addresses.

If mail-dir is omitted, defaults to ~/Library/Mail.

Labels are derived from directory names. Messages that appear in
multiple mailboxes are deduplicated and given labels from each.

Examples:
msgvault import-emlx me@gmail.com ~/Downloads/mail-2009/Mail/
msgvault import-emlx me@gmail.com ~/Mail/INBOX.mbox/
# Auto-discover accounts from default Apple Mail location
msgvault import-emlx

# Auto-discover accounts from explicit mail directory
msgvault import-emlx ~/Library/Mail

# Import only specific account(s)
msgvault import-emlx --account me@gmail.com
msgvault import-emlx --account me@gmail.com --account work@company.com

# Manual fallback: import a single directory with explicit identifier
msgvault import-emlx ~/Library/Mail/V10/SOME-GUID --identifier me@gmail.com
msgvault import-emlx ~/Mail/INBOX.mbox/ --identifier me@gmail.com
`,
Args: cobra.ExactArgs(2),
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
identifier := args[0]
mailDir := args[1]
// Determine mail directory.
var mailDir string
if len(args) > 0 {
mailDir = args[0]
} else {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("determine home directory: %w", err)
}
mailDir = filepath.Join(home, "Library", "Mail")
}

// Expand ~ if present.
if strings.HasPrefix(mailDir, "~/") {
home, _ := os.UserHomeDir()
mailDir = filepath.Join(home, mailDir[2:])
}

ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
Expand Down Expand Up @@ -103,8 +138,131 @@ Examples:
attachmentsDir = ""
}

if importEmlxIdentifier != "" {
// Manual fallback: single import with explicit identifier.
return importSingleAccount(ctx, cmd, st, mailDir, importEmlxIdentifier, attachmentsDir)
}

// Auto mode: discover accounts from V10 layout + Accounts4.sqlite.
return importAutoAccounts(ctx, cmd, st, mailDir, attachmentsDir)
},
}

func importSingleAccount(
ctx context.Context,
cmd *cobra.Command,
st *store.Store,
mailDir, identifier, attachmentsDir string,
) error {
summary, err := importer.ImportEmlxDir(
ctx, st, mailDir, importer.EmlxImportOptions{
SourceType: importEmlxSourceType,
Identifier: identifier,
NoResume: importEmlxNoResume,
CheckpointInterval: importEmlxCheckpointInterval,
AttachmentsDir: attachmentsDir,
Logger: logger,
},
)
if err != nil {
return err
}

printImportSummary(cmd, ctx, *summary)
return importResultError(ctx, *summary)
}

func importAutoAccounts(
ctx context.Context,
cmd *cobra.Command,
st *store.Store,
mailDir, attachmentsDir string,
) error {
accountsDBPath := importEmlxAccountsDB
if strings.HasPrefix(accountsDBPath, "~/") {
home, _ := os.UserHomeDir()
accountsDBPath = filepath.Join(home, accountsDBPath[2:])
}

out := cmd.OutOrStdout()

accounts, err := applemail.DiscoverV10Accounts(mailDir, accountsDBPath, logger)
if err != nil {
return fmt.Errorf("discover accounts: %w", err)
}

if len(accounts) == 0 {
return fmt.Errorf(
"no V10 accounts found in %s\n\n"+
"If this is not an Apple Mail V10 directory, use --identifier to specify\n"+
"the account email manually:\n\n"+
" msgvault import-emlx %s --identifier you@gmail.com",
mailDir, mailDir,
)
}

// Filter by --account flags if set.
if len(importEmlxAccounts) > 0 {
filter := make(map[string]bool)
for _, a := range importEmlxAccounts {
filter[strings.ToLower(a)] = true
}

var filtered []applemail.AccountInfo
for _, a := range accounts {
if filter[strings.ToLower(a.Email)] || filter[strings.ToLower(a.Identifier())] {
filtered = append(filtered, a)
}
}

if len(filtered) == 0 {
var available []string
for _, a := range accounts {
available = append(available, a.Identifier())
}
return fmt.Errorf(
"no matching accounts found for --account filter\n"+
"Available accounts: %s",
strings.Join(available, ", "),
)
}
accounts = filtered
}

fmt.Fprintf(out, "Discovered %d account(s):\n", len(accounts))
for _, a := range accounts {
if a.Email != "" {
fmt.Fprintf(out, " - %s (%s)\n", a.Email, a.Description)
} else {
fmt.Fprintf(out, " - %s\n", a.Description)
}
}
fmt.Fprintln(out)

var grandTotal importer.EmlxImportSummary
var importErrors []error

for _, account := range accounts {
if ctx.Err() != nil {
fmt.Fprintln(out, "Import interrupted between accounts.")
break
}

identifier := account.Identifier()
accountDir, err := applemail.V10AccountDir(mailDir, account.GUID)
if err != nil {
fmt.Fprintf(out, "Skipping %s: %v\n", identifier, err)
continue
}

if account.Email != "" {
fmt.Fprintf(out, "Importing %s (%s)...\n", account.Email, account.Description)
} else {
fmt.Fprintf(out, "Importing %s...\n", account.Description)
}

summary, err := importer.ImportEmlxDir(
ctx, st, mailDir, importer.EmlxImportOptions{
ctx, st, accountDir, importer.EmlxImportOptions{
SourceType: importEmlxSourceType,
Identifier: identifier,
NoResume: importEmlxNoResume,
Expand All @@ -114,54 +272,98 @@ Examples:
},
)
if err != nil {
return err
importErrors = append(importErrors, fmt.Errorf("%s: %w", identifier, err))
continue
}

out := cmd.OutOrStdout()
if ctx.Err() != nil {
fmt.Fprintln(out, "Import interrupted. Run again to resume.")
} else if summary.Errors > 0 {
fmt.Fprintln(out, "Import complete (with errors).")
} else {
fmt.Fprintln(out, "Import complete.")
printImportSummary(cmd, ctx, *summary)
fmt.Fprintln(out)

// Accumulate totals.
grandTotal.MailboxesTotal += summary.MailboxesTotal
grandTotal.MailboxesImported += summary.MailboxesImported
grandTotal.MessagesProcessed += summary.MessagesProcessed
grandTotal.MessagesAdded += summary.MessagesAdded
grandTotal.MessagesUpdated += summary.MessagesUpdated
grandTotal.MessagesSkipped += summary.MessagesSkipped
grandTotal.Errors += summary.Errors
if summary.HardErrors {
grandTotal.HardErrors = true
}
}

fmt.Fprintf(out,
" Mailboxes: %d discovered, %d imported\n",
summary.MailboxesTotal, summary.MailboxesImported,
)
fmt.Fprintf(out,
" Processed: %d messages\n",
summary.MessagesProcessed,
)
fmt.Fprintf(out,
" Added: %d messages\n",
summary.MessagesAdded,
)
fmt.Fprintf(out,
" Updated: %d messages\n",
summary.MessagesUpdated,
)
fmt.Fprintf(out,
" Skipped (dup): %d messages\n",
summary.MessagesSkipped,
)
fmt.Fprintf(out,
" Errors: %d\n",
summary.Errors,
)
if len(accounts) > 1 {
fmt.Fprintln(out, "=== Grand Total ===")
printImportStats(out, grandTotal)
}

if ctx.Err() == nil && summary.HardErrors {
return fmt.Errorf(
"import completed with %d errors",
summary.Errors,
)
if len(importErrors) > 0 {
for _, e := range importErrors {
fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", e)
}
if ctx.Err() != nil {
return context.Canceled
}
return nil
},
return fmt.Errorf("import completed with %d account error(s)", len(importErrors))
}

if ctx.Err() != nil {
return context.Canceled
}

if grandTotal.HardErrors {
return fmt.Errorf("import completed with %d errors", grandTotal.Errors)
}

return nil
}

func importResultError(ctx context.Context, summary importer.EmlxImportSummary) error {
if ctx.Err() != nil {
return context.Canceled
}
if summary.HardErrors {
return fmt.Errorf("import completed with %d errors", summary.Errors)
}
return nil
}

func printImportSummary(cmd *cobra.Command, ctx context.Context, summary importer.EmlxImportSummary) {
out := cmd.OutOrStdout()

if ctx.Err() != nil {
fmt.Fprintln(out, "Import interrupted. Run again to resume.")
} else if summary.Errors > 0 {
fmt.Fprintln(out, "Import complete (with errors).")
} else {
fmt.Fprintln(out, "Import complete.")
}

printImportStats(out, summary)
}

func printImportStats(out io.Writer, summary importer.EmlxImportSummary) {
fmt.Fprintf(out,
" Mailboxes: %d discovered, %d imported\n",
summary.MailboxesTotal, summary.MailboxesImported,
)
fmt.Fprintf(out,
" Processed: %d messages\n",
summary.MessagesProcessed,
)
fmt.Fprintf(out,
" Added: %d messages\n",
summary.MessagesAdded,
)
fmt.Fprintf(out,
" Updated: %d messages\n",
summary.MessagesUpdated,
)
fmt.Fprintf(out,
" Skipped (dup): %d messages\n",
summary.MessagesSkipped,
)
fmt.Fprintf(out,
" Errors: %d\n",
summary.Errors,
)
}

func init() {
Expand All @@ -183,4 +385,16 @@ func init() {
&importEmlxNoAttachments, "no-attachments", false,
"Do not store attachments on disk",
)
importEmlxCmd.Flags().StringVar(
&importEmlxAccountsDB, "accounts-db", applemail.DefaultAccountsDBPath(),
"Path to Apple's Accounts4.sqlite database",
)
importEmlxCmd.Flags().StringSliceVar(
&importEmlxAccounts, "account", nil,
"Filter to specific account email(s) (repeatable)",
)
importEmlxCmd.Flags().StringVar(
&importEmlxIdentifier, "identifier", "",
"Explicit email/identifier for single-directory import (manual fallback)",
)
}
Loading