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
185 changes: 149 additions & 36 deletions internal/emlx/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,35 @@ type Mailbox struct {
// Path is the absolute path to the .mbox or .imapmbox directory.
Path string

// MsgDir is the absolute path to the Messages/ directory
// MsgDir is the absolute path to the primary Messages/ directory
// containing .emlx files. In legacy layouts this is Path/Messages;
// in modern V10 layouts it is Path/<GUID>/Data/Messages.
MsgDir string

// Label is the derived label for messages in this mailbox.
Label string

// Files contains sorted .emlx filenames within MsgDir.
// Files contains sorted .emlx filenames within MsgDir plus any
// files discovered in numeric partition subdirectories.
Files []string

// FileIndex maps filename → absolute path of the Messages/ subdirectory
// within a numeric partition directory, for V10 partitioned layouts.
// Files in MsgDir itself are absent from this map. Nil when no
// partition files exist.
FileIndex map[string]string
}

// FilePath returns the absolute path to a .emlx file within this mailbox.
// For files in numeric partition directories, the path is resolved via
// FileIndex; all other files are resolved relative to MsgDir.
func (m *Mailbox) FilePath(fileName string) string {
if m.FileIndex != nil {
if sub, ok := m.FileIndex[fileName]; ok {
return filepath.Join(sub, fileName)
}
}
return filepath.Join(m.MsgDir, fileName)
}

// DiscoverMailboxes walks an Apple Mail directory tree and returns all
Expand All @@ -49,7 +68,7 @@ func DiscoverMailboxes(rootDir string) ([]Mailbox, error) {

// Auto-detect: if the path itself is a mailbox, import just that one.
if isMailboxDir(abs) {
msgDir, files, err := listEmlxFiles(abs)
msgDir, files, fileIndex, err := listEmlxFiles(abs)
if err != nil {
return nil, err
}
Expand All @@ -58,6 +77,7 @@ func DiscoverMailboxes(rootDir string) ([]Mailbox, error) {
return []Mailbox{{
Path: abs, MsgDir: msgDir,
Label: label, Files: files,
FileIndex: fileIndex,
}}, nil
}
}
Expand All @@ -81,7 +101,7 @@ func DiscoverMailboxes(rootDir string) ([]Mailbox, error) {
return nil
}

msgDir, files, listErr := listEmlxFiles(path)
msgDir, files, fileIndex, listErr := listEmlxFiles(path)
if listErr != nil || len(files) == 0 {
return nil
}
Expand All @@ -90,6 +110,7 @@ func DiscoverMailboxes(rootDir string) ([]Mailbox, error) {
mailboxes = append(mailboxes, Mailbox{
Path: path, MsgDir: msgDir,
Label: label, Files: files,
FileIndex: fileIndex,
})

return nil
Expand Down Expand Up @@ -164,7 +185,7 @@ func isMailboxDir(path string) bool {
// findMessagesDir locates the Messages/ directory within a .mbox.
// Returns "" if none found. Checks both legacy (Messages/) and
// modern V10 (<GUID>/Data/Messages/) layouts. When both exist,
// prefers whichever contains .emlx files.
// prefers whichever contains .emlx files (directly or in partitions).
func findMessagesDir(mailboxPath string) string {
var candidates []string

Expand All @@ -175,17 +196,24 @@ func findMessagesDir(mailboxPath string) string {
}

// Modern V10: <subdir>/Data/Messages/ subdirectory.
// Also handles partition-only layouts where Data/Messages/ doesn't exist.
entries, err := os.ReadDir(mailboxPath)
if err == nil {
for _, e := range entries {
if !e.IsDir() || e.Name() == "Messages" {
continue
}
modern := filepath.Join(
mailboxPath, e.Name(), "Data", "Messages",
)
info, statErr := os.Stat(modern)
if statErr == nil && info.IsDir() {
dataDir := filepath.Join(mailboxPath, e.Name(), "Data")
dataStat, statErr := os.Stat(dataDir)
if statErr != nil || !dataStat.IsDir() {
continue
}
modern := filepath.Join(dataDir, "Messages")
msgStat, statErr := os.Stat(modern)
if statErr == nil && msgStat.IsDir() {
candidates = append(candidates, modern)
} else if hasEmlxFilesInPartitions(dataDir) {
// Partition-only: Data/Messages/ absent but partitions exist.
candidates = append(candidates, modern)
}
}
Expand All @@ -195,11 +223,18 @@ func findMessagesDir(mailboxPath string) string {
return ""
}

// Prefer the first candidate that has .emlx files.
// Prefer the first candidate that has .emlx files directly or
// within numeric partition subdirectories (V10 only).
for _, dir := range candidates {
if hasEmlxFiles(dir) {
return dir
}
// For V10 layout the parent is Data/; check partitions there.
dataDir := filepath.Dir(dir)
if filepath.Base(dataDir) == "Data" &&
hasEmlxFilesInPartitions(dataDir) {
return dir
}
}

// No candidate has files; return first for isMailboxDir.
Expand All @@ -214,13 +249,33 @@ func hasEmlxFiles(dir string) bool {
return false
}
for _, e := range entries {
if e.IsDir() {
if !e.IsDir() && isEmlxFile(e.Name()) {
return true
}
}
return false
}

// hasEmlxFilesInPartitions returns true if dir contains .emlx files
// within Messages/ subdirectories or nested numeric partition dirs (0-9).
func hasEmlxFilesInPartitions(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
if !e.IsDir() {
continue
}
lower := strings.ToLower(e.Name())
if strings.HasSuffix(lower, ".emlx") &&
!strings.HasSuffix(lower, ".partial.emlx") {
return true
name := e.Name()
if name == "Messages" {
if hasEmlxFiles(filepath.Join(dir, name)) {
return true
}
} else if isDigitDir(name) {
if hasEmlxFilesInPartitions(filepath.Join(dir, name)) {
return true
}
}
}
return false
Expand Down Expand Up @@ -249,6 +304,16 @@ func isUUID(s string) bool {
return true
}

func isDigitDir(name string) bool {
return len(name) == 1 && name[0] >= '0' && name[0] <= '9'
}

func isEmlxFile(name string) bool {
lower := strings.ToLower(name)
return strings.HasSuffix(lower, ".emlx") &&
!strings.HasSuffix(lower, ".partial.emlx")
}

func stripMailboxSuffix(name string) string {
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".imapmbox") {
Expand All @@ -260,43 +325,91 @@ func stripMailboxSuffix(name string) string {
return name
}

// listEmlxFiles returns the Messages directory path and sorted .emlx
// filenames within it, excluding .partial.emlx. Returns ("", nil, nil)
// if no Messages directory is found.
// listEmlxFiles returns the Messages directory path, sorted .emlx
// filenames (from both the primary Messages/ dir and numeric partition
// subdirectories), and a FileIndex mapping partition filenames to their
// containing subdirectory. Returns ("", nil, nil, nil) if no Messages
// directory is found.
func listEmlxFiles(
mailboxPath string,
) (string, []string, error) {
) (string, []string, map[string]string, error) {
msgDir := findMessagesDir(mailboxPath)
if msgDir == "" {
return "", nil, nil
return "", nil, nil, nil
}

entries, err := os.ReadDir(msgDir)
if err != nil {
if os.IsNotExist(err) {
return "", nil, nil
if !os.IsNotExist(err) {
return "", nil, nil, fmt.Errorf("read Messages dir: %w", err)
}
return "", nil, fmt.Errorf("read Messages dir: %w", err)
// Primary Messages/ dir absent (partition-only layout); continue
// so that partition files are still collected below.
entries = nil
}

var files []string
for _, e := range entries {
if e.IsDir() {
continue
if !e.IsDir() && isEmlxFile(e.Name()) {
files = append(files, e.Name())
}
name := e.Name()
if !strings.HasSuffix(strings.ToLower(name), ".emlx") {
continue
}

// Walk numeric partition dirs in Data/ (parent of Messages/).
// Only enter digit dirs (0-9) to avoid re-collecting from the
// primary Messages/ dir which was already handled above.
var fileIndex map[string]string
dataDir := filepath.Dir(msgDir)
if filepath.Base(dataDir) == "Data" {
result := make(map[string]string)
topEntries, readErr := os.ReadDir(dataDir)
if readErr == nil {
for _, e := range topEntries {
if e.IsDir() && isDigitDir(e.Name()) {
collectPartitionFiles(
filepath.Join(dataDir, e.Name()), result,
)
}
}
}
// Skip .partial.emlx files (Apple Mail temp files).
if strings.HasSuffix(
strings.ToLower(name), ".partial.emlx",
) {
continue
if len(result) > 0 {
fileIndex = result
for name := range result {
files = append(files, name)
}
}
files = append(files, name)
}

sort.Strings(files)
return msgDir, files, nil
return msgDir, files, fileIndex, nil
}

// collectPartitionFiles recursively walks dir for Messages/ subdirs and
// numeric partition dirs (0-9), collecting .emlx files into result
// (filename → absolute Messages/ dir path).
func collectPartitionFiles(dir string, result map[string]string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if name == "Messages" {
msgDir := filepath.Join(dir, name)
msgs, err := os.ReadDir(msgDir)
if err != nil {
continue
}
for _, m := range msgs {
if !m.IsDir() && isEmlxFile(m.Name()) {
result[m.Name()] = msgDir
}
}
} else if isDigitDir(name) {
collectPartitionFiles(filepath.Join(dir, name), result)
}
}
}
Loading