Skip to content

Commit 4801588

Browse files
wesmclaude
andauthored
Support modern Apple Mail V10 directory layout (#163)
## Summary - Modern Apple Mail (macOS 13+) uses `<GUID>/Data/Messages/` inside `.mbox` directories instead of a direct `Messages/` subdirectory, causing `import-emlx` to find 0 mailboxes - Add `findMessagesDir()` to probe both legacy and V10 layouts, and `MsgDir` field to `Mailbox` so the importer reads from the correct path - Filter UUID path components from labels so account GUIDs don't leak into mailbox names ## Test plan - [x] All existing discovery and importer tests pass (no regressions) - [x] New tests cover V10 walk, V10 single-mailbox auto-detect, V10 partial-emlx skipping, UUID filtering in labels, and `isUUID` edge cases - [x] Manual test with real `~/Library/Mail/V10` directory Fixes #157 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent 3164737 commit 4801588

3 files changed

Lines changed: 329 additions & 19 deletions

File tree

internal/emlx/discover.go

Lines changed: 122 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@ type Mailbox struct {
1313
// Path is the absolute path to the .mbox or .imapmbox directory.
1414
Path string
1515

16+
// MsgDir is the absolute path to the Messages/ directory
17+
// containing .emlx files. In legacy layouts this is Path/Messages;
18+
// in modern V10 layouts it is Path/<GUID>/Data/Messages.
19+
MsgDir string
20+
1621
// Label is the derived label for messages in this mailbox.
1722
Label string
1823

19-
// Files contains sorted .emlx filenames within Messages/.
24+
// Files contains sorted .emlx filenames within MsgDir.
2025
Files []string
2126
}
2227

@@ -44,14 +49,15 @@ func DiscoverMailboxes(rootDir string) ([]Mailbox, error) {
4449

4550
// Auto-detect: if the path itself is a mailbox, import just that one.
4651
if isMailboxDir(abs) {
47-
files, err := listEmlxFiles(abs)
52+
msgDir, files, err := listEmlxFiles(abs)
4853
if err != nil {
4954
return nil, err
5055
}
5156
if len(files) > 0 {
5257
label := LabelFromPath(filepath.Dir(abs), abs)
5358
return []Mailbox{{
54-
Path: abs, Label: label, Files: files,
59+
Path: abs, MsgDir: msgDir,
60+
Label: label, Files: files,
5561
}}, nil
5662
}
5763
}
@@ -75,14 +81,15 @@ func DiscoverMailboxes(rootDir string) ([]Mailbox, error) {
7581
return nil
7682
}
7783

78-
files, listErr := listEmlxFiles(path)
84+
msgDir, files, listErr := listEmlxFiles(path)
7985
if listErr != nil || len(files) == 0 {
8086
return nil
8187
}
8288

8389
label := LabelFromPath(abs, path)
8490
mailboxes = append(mailboxes, Mailbox{
85-
Path: path, Label: label, Files: files,
91+
Path: path, MsgDir: msgDir,
92+
Label: label, Files: files,
8693
})
8794

8895
return nil
@@ -124,6 +131,10 @@ func LabelFromPath(rootDir, mailboxPath string) string {
124131
if strings.HasPrefix(p, "IMAP-") || strings.HasPrefix(p, "POP-") {
125132
continue
126133
}
134+
// V10 account GUID directories (e.g. 13C9A646-...-E07FFBDDEED3).
135+
if isUUID(p) {
136+
continue
137+
}
127138
filtered = append(filtered, p)
128139
}
129140

@@ -147,10 +158,95 @@ func isMailboxDir(path string) bool {
147158
return false
148159
}
149160

150-
// Must have a Messages/ subdirectory.
151-
msgDir := filepath.Join(path, "Messages")
152-
info, err := os.Stat(msgDir)
153-
return err == nil && info.IsDir()
161+
return findMessagesDir(path) != ""
162+
}
163+
164+
// findMessagesDir locates the Messages/ directory within a .mbox.
165+
// Returns "" if none found. Checks both legacy (Messages/) and
166+
// modern V10 (<GUID>/Data/Messages/) layouts. When both exist,
167+
// prefers whichever contains .emlx files.
168+
func findMessagesDir(mailboxPath string) string {
169+
var candidates []string
170+
171+
// Legacy: direct Messages/ subdirectory.
172+
legacy := filepath.Join(mailboxPath, "Messages")
173+
if info, err := os.Stat(legacy); err == nil && info.IsDir() {
174+
candidates = append(candidates, legacy)
175+
}
176+
177+
// Modern V10: <subdir>/Data/Messages/ subdirectory.
178+
entries, err := os.ReadDir(mailboxPath)
179+
if err == nil {
180+
for _, e := range entries {
181+
if !e.IsDir() || e.Name() == "Messages" {
182+
continue
183+
}
184+
modern := filepath.Join(
185+
mailboxPath, e.Name(), "Data", "Messages",
186+
)
187+
info, statErr := os.Stat(modern)
188+
if statErr == nil && info.IsDir() {
189+
candidates = append(candidates, modern)
190+
}
191+
}
192+
}
193+
194+
if len(candidates) == 0 {
195+
return ""
196+
}
197+
198+
// Prefer the first candidate that has .emlx files.
199+
for _, dir := range candidates {
200+
if hasEmlxFiles(dir) {
201+
return dir
202+
}
203+
}
204+
205+
// No candidate has files; return first for isMailboxDir.
206+
return candidates[0]
207+
}
208+
209+
// hasEmlxFiles returns true if dir contains at least one
210+
// non-partial .emlx file.
211+
func hasEmlxFiles(dir string) bool {
212+
entries, err := os.ReadDir(dir)
213+
if err != nil {
214+
return false
215+
}
216+
for _, e := range entries {
217+
if e.IsDir() {
218+
continue
219+
}
220+
lower := strings.ToLower(e.Name())
221+
if strings.HasSuffix(lower, ".emlx") &&
222+
!strings.HasSuffix(lower, ".partial.emlx") {
223+
return true
224+
}
225+
}
226+
return false
227+
}
228+
229+
// isUUID returns true if s matches UUID format (8-4-4-4-12 hex).
230+
func isUUID(s string) bool {
231+
if len(s) != 36 {
232+
return false
233+
}
234+
for i, c := range s {
235+
switch i {
236+
case 8, 13, 18, 23:
237+
if c != '-' {
238+
return false
239+
}
240+
default:
241+
isHex := (c >= '0' && c <= '9') ||
242+
(c >= 'a' && c <= 'f') ||
243+
(c >= 'A' && c <= 'F')
244+
if !isHex {
245+
return false
246+
}
247+
}
248+
}
249+
return true
154250
}
155251

156252
func stripMailboxSuffix(name string) string {
@@ -164,16 +260,23 @@ func stripMailboxSuffix(name string) string {
164260
return name
165261
}
166262

167-
// listEmlxFiles returns sorted .emlx filenames (not paths) within
168-
// the Messages/ subdirectory of a mailbox, excluding .partial.emlx.
169-
func listEmlxFiles(mailboxPath string) ([]string, error) {
170-
msgDir := filepath.Join(mailboxPath, "Messages")
263+
// listEmlxFiles returns the Messages directory path and sorted .emlx
264+
// filenames within it, excluding .partial.emlx. Returns ("", nil, nil)
265+
// if no Messages directory is found.
266+
func listEmlxFiles(
267+
mailboxPath string,
268+
) (string, []string, error) {
269+
msgDir := findMessagesDir(mailboxPath)
270+
if msgDir == "" {
271+
return "", nil, nil
272+
}
273+
171274
entries, err := os.ReadDir(msgDir)
172275
if err != nil {
173276
if os.IsNotExist(err) {
174-
return nil, nil
277+
return "", nil, nil
175278
}
176-
return nil, fmt.Errorf("read Messages dir: %w", err)
279+
return "", nil, fmt.Errorf("read Messages dir: %w", err)
177280
}
178281

179282
var files []string
@@ -186,12 +289,14 @@ func listEmlxFiles(mailboxPath string) ([]string, error) {
186289
continue
187290
}
188291
// Skip .partial.emlx files (Apple Mail temp files).
189-
if strings.HasSuffix(strings.ToLower(name), ".partial.emlx") {
292+
if strings.HasSuffix(
293+
strings.ToLower(name), ".partial.emlx",
294+
) {
190295
continue
191296
}
192297
files = append(files, name)
193298
}
194299

195300
sort.Strings(files)
196-
return files, nil
301+
return msgDir, files, nil
197302
}

0 commit comments

Comments
 (0)