main
1package maildir
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "mime"
8 "mime/multipart"
9 "net/mail"
10 "os"
11 "path/filepath"
12 "regexp"
13 "sort"
14 "strings"
15 "time"
16
17 "github.com/xlgmokha/mcp/pkg/mcp"
18)
19
20// MaildirOperations provides maildir email analysis operations
21type MaildirOperations struct {
22 allowedPaths []string
23}
24
25// MessageInfo represents basic email message information
26type MessageInfo struct {
27 ID string `json:"id"`
28 Filename string `json:"filename"`
29 Subject string `json:"subject"`
30 From string `json:"from"`
31 To string `json:"to"`
32 Date time.Time `json:"date"`
33 Flags []string `json:"flags"`
34 Folder string `json:"folder"`
35 Size int64 `json:"size"`
36 Headers map[string]string `json:"headers"`
37 MessageID string `json:"message_id"`
38 InReplyTo string `json:"in_reply_to"`
39 References []string `json:"references"`
40}
41
42// Message represents a full email message
43type Message struct {
44 MessageInfo
45 Body string `json:"body"`
46 HTMLBody string `json:"html_body,omitempty"`
47}
48
49// FolderInfo represents maildir folder information
50type FolderInfo struct {
51 Name string `json:"name"`
52 Path string `json:"path"`
53 MessageCount int `json:"message_count"`
54 UnreadCount int `json:"unread_count"`
55}
56
57// ContactInfo represents contact analysis data
58type ContactInfo struct {
59 Email string `json:"email"`
60 Name string `json:"name"`
61 Count int `json:"count"`
62 LastSeen time.Time `json:"last_seen"`
63 FirstSeen time.Time `json:"first_seen"`
64 IsOutgoing bool `json:"is_outgoing"`
65}
66
67// NewMaildirOperations creates a new MaildirOperations helper
68func NewMaildirOperations(allowedPaths []string) *MaildirOperations {
69 // Normalize and validate allowed paths
70 normalizedPaths := make([]string, len(allowedPaths))
71 for i, path := range allowedPaths {
72 absPath, err := filepath.Abs(expandHome(path))
73 if err != nil {
74 panic(fmt.Sprintf("Invalid maildir path: %s", path))
75 }
76 normalizedPaths[i] = filepath.Clean(absPath)
77 }
78
79 return &MaildirOperations{
80 allowedPaths: normalizedPaths,
81 }
82}
83
84// New creates a new Maildir MCP server
85func New(allowedPaths []string) *mcp.Server {
86 maildir := NewMaildirOperations(allowedPaths)
87 builder := mcp.NewServerBuilder("maildir-server", "1.0.0")
88
89 // Add maildir_scan_folders tool
90 builder.AddTool(mcp.NewTool("maildir_scan_folders", "Scan and list all folders in the Maildir structure", map[string]interface{}{
91 "type": "object",
92 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
93 return maildir.handleScanFolders(req)
94 }))
95
96 // Add maildir_list_messages tool
97 builder.AddTool(mcp.NewTool("maildir_list_messages", "List messages in a specific folder with optional pagination and filtering", map[string]interface{}{
98 "type": "object",
99 "properties": map[string]interface{}{
100 "folder": map[string]interface{}{
101 "type": "string",
102 "description": "The folder name to list messages from",
103 },
104 "limit": map[string]interface{}{
105 "type": "integer",
106 "description": "Maximum number of messages to return",
107 "minimum": 1,
108 "default": 50,
109 },
110 "offset": map[string]interface{}{
111 "type": "integer",
112 "description": "Number of messages to skip",
113 "minimum": 0,
114 "default": 0,
115 },
116 "unread_only": map[string]interface{}{
117 "type": "boolean",
118 "description": "Only return unread messages",
119 "default": false,
120 },
121 },
122 "required": []string{"folder"},
123 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
124 return maildir.handleListMessages(req)
125 }))
126
127 // Add maildir_read_message tool
128 builder.AddTool(mcp.NewTool("maildir_read_message", "Read the full content of a specific message", map[string]interface{}{
129 "type": "object",
130 "properties": map[string]interface{}{
131 "message_id": map[string]interface{}{
132 "type": "string",
133 "description": "The unique message ID",
134 },
135 },
136 "required": []string{"message_id"},
137 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
138 return maildir.handleReadMessage(req)
139 }))
140
141 // Add maildir_search_messages tool
142 builder.AddTool(mcp.NewTool("maildir_search_messages", "Search for messages across all folders using various criteria", map[string]interface{}{
143 "type": "object",
144 "properties": map[string]interface{}{
145 "query": map[string]interface{}{
146 "type": "string",
147 "description": "Search query string",
148 },
149 "sender": map[string]interface{}{
150 "type": "string",
151 "description": "Filter by sender email or name",
152 },
153 "subject": map[string]interface{}{
154 "type": "string",
155 "description": "Filter by subject line",
156 },
157 "folder": map[string]interface{}{
158 "type": "string",
159 "description": "Limit search to specific folder",
160 },
161 "limit": map[string]interface{}{
162 "type": "integer",
163 "description": "Maximum number of results",
164 "minimum": 1,
165 "default": 50,
166 },
167 },
168 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
169 return maildir.handleSearchMessages(req)
170 }))
171
172 // Add maildir_get_thread tool
173 builder.AddTool(mcp.NewTool("maildir_get_thread", "Get all messages in a conversation thread", map[string]interface{}{
174 "type": "object",
175 "properties": map[string]interface{}{
176 "message_id": map[string]interface{}{
177 "type": "string",
178 "description": "Message ID to find thread for",
179 },
180 },
181 "required": []string{"message_id"},
182 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
183 return maildir.handleGetThread(req)
184 }))
185
186 // Add maildir_analyze_contacts tool
187 builder.AddTool(mcp.NewTool("maildir_analyze_contacts", "Analyze contact frequency and relationship data from email history", map[string]interface{}{
188 "type": "object",
189 "properties": map[string]interface{}{
190 "limit": map[string]interface{}{
191 "type": "integer",
192 "description": "Maximum number of contacts to analyze",
193 "minimum": 1,
194 "default": 100,
195 },
196 },
197 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
198 return maildir.handleAnalyzeContacts(req)
199 }))
200
201 // Add maildir_get_statistics tool
202 builder.AddTool(mcp.NewTool("maildir_get_statistics", "Get comprehensive statistics about the maildir including message counts, storage usage, and activity patterns", map[string]interface{}{
203 "type": "object",
204 }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
205 return maildir.handleGetStatistics(req)
206 }))
207
208 // Add maildir resources
209 builder.AddResource(mcp.NewResource("maildir://folders", "Maildir Folders", "Browse available maildir folders and structures", func(req mcp.ReadResourceRequest) (mcp.ReadResourceResult, error) {
210 return mcp.ReadResourceResult{
211 Contents: []mcp.Content{
212 mcp.NewTextContent("Use maildir_scan_folders tool to explore available maildir folders and their message counts"),
213 },
214 }, nil
215 }))
216
217 return builder.Build()
218}
219
220
221// Helper methods for MaildirOperations
222
223
224
225// HandleScanFolders implements the maildir_scan_folders tool
226func (maildir *MaildirOperations) handleScanFolders(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
227 maildirPath, ok := req.Arguments["maildir_path"].(string)
228 if !ok {
229 return mcp.NewToolError("maildir_path is required"), nil
230 }
231
232 if !maildir.isPathAllowed(maildirPath) {
233 return mcp.NewToolError("access denied: path not in allowed directories"), nil
234 }
235
236 includeCounts := true
237 if ic, ok := req.Arguments["include_counts"].(bool); ok {
238 includeCounts = ic
239 }
240
241 folders, err := maildir.scanFolders(maildirPath, includeCounts)
242 if err != nil {
243 return mcp.NewToolError(fmt.Sprintf("failed to scan folders: %v", err)), nil
244 }
245
246 result, err := json.Marshal(map[string]interface{}{
247 "folders": folders,
248 })
249 if err != nil {
250 return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
251 }
252
253 return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
254}
255
256// HandleListMessages implements the maildir_list_messages tool
257func (maildir *MaildirOperations) handleListMessages(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
258 maildirPath, ok := req.Arguments["maildir_path"].(string)
259 if !ok {
260 return mcp.NewToolError("maildir_path is required"), nil
261 }
262
263 if !maildir.isPathAllowed(maildirPath) {
264 return mcp.NewToolError("access denied: path not in allowed directories"), nil
265 }
266
267 folder := "INBOX"
268 if f, ok := req.Arguments["folder"].(string); ok {
269 folder = f
270 }
271
272 limit := 50
273 if l, ok := req.Arguments["limit"].(float64); ok {
274 limit = int(l)
275 if limit > 200 {
276 limit = 200
277 }
278 }
279
280 offset := 0
281 if o, ok := req.Arguments["offset"].(float64); ok {
282 offset = int(o)
283 }
284
285 messages, total, err := maildir.listMessages(maildirPath, folder, limit, offset, req.Arguments)
286 if err != nil {
287 return mcp.NewToolError(fmt.Sprintf("failed to list messages: %v", err)), nil
288 }
289
290 result, err := json.Marshal(map[string]interface{}{
291 "messages": messages,
292 "total": total,
293 "offset": offset,
294 "limit": limit,
295 })
296 if err != nil {
297 return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
298 }
299
300 return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
301}
302
303// HandleReadMessage implements the maildir_read_message tool
304func (maildir *MaildirOperations) handleReadMessage(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
305 maildirPath, ok := req.Arguments["maildir_path"].(string)
306 if !ok {
307 return mcp.NewToolError("maildir_path is required"), nil
308 }
309
310 if !maildir.isPathAllowed(maildirPath) {
311 return mcp.NewToolError("access denied: path not in allowed directories"), nil
312 }
313
314 messageID, ok := req.Arguments["message_id"].(string)
315 if !ok {
316 return mcp.NewToolError("message_id is required"), nil
317 }
318
319 includeHTML := false
320 if ih, ok := req.Arguments["include_html"].(bool); ok {
321 includeHTML = ih
322 }
323
324 includeHeaders := true
325 if ih, ok := req.Arguments["include_headers"].(bool); ok {
326 includeHeaders = ih
327 }
328
329 sanitizeContent := true
330 if sc, ok := req.Arguments["sanitize_content"].(bool); ok {
331 sanitizeContent = sc
332 }
333
334 message, err := maildir.readMessage(maildirPath, messageID, includeHTML, includeHeaders, sanitizeContent)
335 if err != nil {
336 return mcp.NewToolError(fmt.Sprintf("failed to read message: %v", err)), nil
337 }
338
339 result, err := json.Marshal(message)
340 if err != nil {
341 return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
342 }
343
344 return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
345}
346
347// HandleSearchMessages implements the maildir_search_messages tool
348func (maildir *MaildirOperations) handleSearchMessages(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
349 maildirPath, ok := req.Arguments["maildir_path"].(string)
350 if !ok {
351 return mcp.NewToolError("maildir_path is required"), nil
352 }
353
354 if !maildir.isPathAllowed(maildirPath) {
355 return mcp.NewToolError("access denied: path not in allowed directories"), nil
356 }
357
358 query, ok := req.Arguments["query"].(string)
359 if !ok {
360 return mcp.NewToolError("query is required"), nil
361 }
362
363 limit := 50
364 if l, ok := req.Arguments["limit"].(float64); ok {
365 limit = int(l)
366 if limit > 200 {
367 limit = 200
368 }
369 }
370
371 results, err := maildir.searchMessages(maildirPath, query, limit, req.Arguments)
372 if err != nil {
373 return mcp.NewToolError(fmt.Sprintf("failed to search messages: %v", err)), nil
374 }
375
376 result, err := json.Marshal(map[string]interface{}{
377 "results": results,
378 "query": query,
379 })
380 if err != nil {
381 return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
382 }
383
384 return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
385}
386
387// HandleGetThread implements the maildir_get_thread tool
388func (maildir *MaildirOperations) handleGetThread(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
389 maildirPath, ok := req.Arguments["maildir_path"].(string)
390 if !ok {
391 return mcp.NewToolError("maildir_path is required"), nil
392 }
393
394 if !maildir.isPathAllowed(maildirPath) {
395 return mcp.NewToolError("access denied: path not in allowed directories"), nil
396 }
397
398 messageID, ok := req.Arguments["message_id"].(string)
399 if !ok {
400 return mcp.NewToolError("message_id is required"), nil
401 }
402
403 maxDepth := 50
404 if md, ok := req.Arguments["max_depth"].(float64); ok {
405 maxDepth = int(md)
406 }
407
408 thread, err := maildir.getThread(maildirPath, messageID, maxDepth)
409 if err != nil {
410 return mcp.NewToolError(fmt.Sprintf("failed to get thread: %v", err)), nil
411 }
412
413 result, err := json.Marshal(map[string]interface{}{
414 "thread": thread,
415 })
416 if err != nil {
417 return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
418 }
419
420 return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
421}
422
423// HandleAnalyzeContacts implements the maildir_analyze_contacts tool
424func (maildir *MaildirOperations) handleAnalyzeContacts(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
425 maildirPath, ok := req.Arguments["maildir_path"].(string)
426 if !ok {
427 return mcp.NewToolError("maildir_path is required"), nil
428 }
429
430 if !maildir.isPathAllowed(maildirPath) {
431 return mcp.NewToolError("access denied: path not in allowed directories"), nil
432 }
433
434 contacts, err := maildir.analyzeContacts(maildirPath, req.Arguments)
435 if err != nil {
436 return mcp.NewToolError(fmt.Sprintf("failed to analyze contacts: %v", err)), nil
437 }
438
439 result, err := json.Marshal(map[string]interface{}{
440 "contacts": contacts,
441 })
442 if err != nil {
443 return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
444 }
445
446 return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
447}
448
449// HandleGetStatistics implements the maildir_get_statistics tool
450func (maildir *MaildirOperations) handleGetStatistics(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
451 maildirPath, ok := req.Arguments["maildir_path"].(string)
452 if !ok {
453 return mcp.NewToolError("maildir_path is required"), nil
454 }
455
456 if !maildir.isPathAllowed(maildirPath) {
457 return mcp.NewToolError("access denied: path not in allowed directories"), nil
458 }
459
460 stats, err := maildir.getStatistics(maildirPath, req.Arguments)
461 if err != nil {
462 return mcp.NewToolError(fmt.Sprintf("failed to get statistics: %v", err)), nil
463 }
464
465 result, err := json.Marshal(stats)
466 if err != nil {
467 return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
468 }
469
470 return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
471}
472
473
474// Helper functions
475
476// isPathAllowed checks if the given path is within allowed directories
477func (maildir *MaildirOperations) isPathAllowed(path string) bool {
478 absPath, err := filepath.Abs(expandHome(path))
479 if err != nil {
480 return false
481 }
482 absPath = filepath.Clean(absPath)
483
484 for _, allowedPath := range maildir.allowedPaths {
485 if strings.HasPrefix(absPath, allowedPath) {
486 return true
487 }
488 }
489 return false
490}
491
492// expandHome expands ~ to user's home directory
493func expandHome(path string) string {
494 if strings.HasPrefix(path, "~/") {
495 home, err := os.UserHomeDir()
496 if err != nil {
497 return path
498 }
499 return filepath.Join(home, path[2:])
500 }
501 return path
502}
503
504// scanFolders scans maildir folders and returns folder information
505func (maildir *MaildirOperations) scanFolders(maildirPath string, includeCounts bool) ([]FolderInfo, error) {
506 var folders []FolderInfo
507
508 // Walk the maildir directory to find folders
509 err := filepath.Walk(maildirPath, func(path string, info os.FileInfo, err error) error {
510 if err != nil {
511 return nil // Skip directories with errors
512 }
513
514 if !info.IsDir() {
515 return nil
516 }
517
518 // Check if this looks like a maildir folder (has cur, new, tmp subdirs)
519 curDir := filepath.Join(path, "cur")
520 newDir := filepath.Join(path, "new")
521 tmpDir := filepath.Join(path, "tmp")
522
523 if _, err := os.Stat(curDir); err == nil {
524 if _, err := os.Stat(newDir); err == nil {
525 if _, err := os.Stat(tmpDir); err == nil {
526 // This is a maildir folder
527 relPath, _ := filepath.Rel(maildirPath, path)
528 if relPath == "." {
529 relPath = "INBOX"
530 }
531
532 folder := FolderInfo{
533 Name: relPath,
534 Path: path,
535 }
536
537 if includeCounts {
538 // Count messages in cur and new directories
539 curCount := maildir.countMessagesInDir(curDir)
540 newCount := maildir.countMessagesInDir(newDir)
541 folder.MessageCount = curCount + newCount
542 folder.UnreadCount = newCount
543 }
544
545 folders = append(folders, folder)
546 }
547 }
548 }
549
550 return nil
551 })
552
553 if err != nil {
554 return nil, err
555 }
556
557 // Sort folders by name
558 sort.Slice(folders, func(i, j int) bool {
559 return folders[i].Name < folders[j].Name
560 })
561
562 return folders, nil
563}
564
565// countMessagesInDir counts the number of message files in a directory
566func (maildir *MaildirOperations) countMessagesInDir(dirPath string) int {
567 entries, err := os.ReadDir(dirPath)
568 if err != nil {
569 return 0
570 }
571
572 count := 0
573 for _, entry := range entries {
574 if !entry.IsDir() && maildir.isMaildirMessage(entry.Name()) {
575 count++
576 }
577 }
578 return count
579}
580
581// isMaildirMessage checks if a filename looks like a maildir message
582func (maildir *MaildirOperations) isMaildirMessage(filename string) bool {
583 // Basic check for maildir message format
584 return strings.Contains(filename, ".") && !strings.HasPrefix(filename, ".")
585}
586
587// listMessages lists messages in a specific folder with filtering and pagination
588func (maildir *MaildirOperations) listMessages(maildirPath, folder string, limit, offset int, filters map[string]interface{}) ([]MessageInfo, int, error) {
589 folderPath := filepath.Join(maildirPath, folder)
590 if folder == "INBOX" && maildirPath == folderPath {
591 // Handle case where INBOX might be the root maildir
592 folderPath = maildirPath
593 }
594
595 var allMessages []MessageInfo
596
597 // Scan cur and new directories
598 for _, subdir := range []string{"cur", "new"} {
599 dirPath := filepath.Join(folderPath, subdir)
600 messages, err := maildir.scanMessagesInDir(dirPath, folder, subdir == "new")
601 if err != nil {
602 continue // Skip if directory doesn't exist or can't be read
603 }
604 allMessages = append(allMessages, messages...)
605 }
606
607 // Apply filters
608 filteredMessages := maildir.applyMessageFilters(allMessages, filters)
609
610 // Sort by date (newest first)
611 sort.Slice(filteredMessages, func(i, j int) bool {
612 return filteredMessages[i].Date.After(filteredMessages[j].Date)
613 })
614
615 total := len(filteredMessages)
616
617 // Apply pagination
618 start := offset
619 if start >= total {
620 return []MessageInfo{}, total, nil
621 }
622
623 end := start + limit
624 if end > total {
625 end = total
626 }
627
628 return filteredMessages[start:end], total, nil
629}
630
631// scanMessagesInDir scans messages in a specific directory
632func (maildir *MaildirOperations) scanMessagesInDir(dirPath, folder string, isNew bool) ([]MessageInfo, error) {
633 entries, err := os.ReadDir(dirPath)
634 if err != nil {
635 return nil, err
636 }
637
638 var messages []MessageInfo
639 for _, entry := range entries {
640 if entry.IsDir() || !maildir.isMaildirMessage(entry.Name()) {
641 continue
642 }
643
644 messagePath := filepath.Join(dirPath, entry.Name())
645 message, err := maildir.parseMessageInfo(messagePath, folder, entry.Name(), isNew)
646 if err != nil {
647 continue // Skip malformed messages
648 }
649
650 messages = append(messages, message)
651 }
652
653 return messages, nil
654}
655
656// parseMessageInfo parses basic message information from a maildir message file
657func (maildir *MaildirOperations) parseMessageInfo(messagePath, folder, filename string, isNew bool) (MessageInfo, error) {
658 file, err := os.Open(messagePath)
659 if err != nil {
660 return MessageInfo{}, err
661 }
662 defer file.Close()
663
664 // Get file info for size
665 fileInfo, err := file.Stat()
666 if err != nil {
667 return MessageInfo{}, err
668 }
669
670 // Parse email headers
671 msg, err := mail.ReadMessage(file)
672 if err != nil {
673 return MessageInfo{}, err
674 }
675
676 // Extract flags from filename
677 flags := maildir.parseMaildirFlags(filename, isNew)
678
679 // Parse date
680 dateStr := msg.Header.Get("Date")
681 date, err := mail.ParseDate(dateStr)
682 if err != nil {
683 date = fileInfo.ModTime() // Fallback to file modification time
684 }
685
686 // Extract references for threading
687 references := maildir.parseReferences(msg.Header.Get("References"))
688
689 messageInfo := MessageInfo{
690 ID: maildir.generateMessageID(messagePath),
691 Filename: filename,
692 Subject: msg.Header.Get("Subject"),
693 From: msg.Header.Get("From"),
694 To: msg.Header.Get("To"),
695 Date: date,
696 Flags: flags,
697 Folder: folder,
698 Size: fileInfo.Size(),
699 MessageID: msg.Header.Get("Message-ID"),
700 InReplyTo: msg.Header.Get("In-Reply-To"),
701 References: references,
702 Headers: map[string]string{
703 "Subject": msg.Header.Get("Subject"),
704 "From": msg.Header.Get("From"),
705 "To": msg.Header.Get("To"),
706 "Date": dateStr,
707 "Message-ID": msg.Header.Get("Message-ID"),
708 },
709 }
710
711 return messageInfo, nil
712}
713
714// parseMaildirFlags parses maildir flags from filename
715func (maildir *MaildirOperations) parseMaildirFlags(filename string, isNew bool) []string {
716 var flags []string
717
718 if isNew {
719 flags = append(flags, "New")
720 }
721
722 // Parse standard maildir flags from filename
723 // Format: unique_name:2,flags
724 parts := strings.Split(filename, ":2,")
725 if len(parts) == 2 {
726 flagStr := parts[1]
727 for _, flag := range flagStr {
728 switch flag {
729 case 'S':
730 flags = append(flags, "Seen")
731 case 'R':
732 flags = append(flags, "Replied")
733 case 'F':
734 flags = append(flags, "Flagged")
735 case 'T':
736 flags = append(flags, "Trashed")
737 case 'D':
738 flags = append(flags, "Draft")
739 case 'P':
740 flags = append(flags, "Passed")
741 }
742 }
743 } else if !isNew {
744 // If no flags but in cur directory, assume Seen
745 flags = append(flags, "Seen")
746 }
747
748 return flags
749}
750
751// parseReferences parses References header for email threading
752func (maildir *MaildirOperations) parseReferences(referencesStr string) []string {
753 if referencesStr == "" {
754 return nil
755 }
756
757 // Simple parsing - split by whitespace and extract message IDs
758 re := regexp.MustCompile(`<[^>]+>`)
759 matches := re.FindAllString(referencesStr, -1)
760
761 var references []string
762 for _, match := range matches {
763 references = append(references, match)
764 }
765
766 return references
767}
768
769// generateMessageID generates a unique ID for a message based on its path
770func (maildir *MaildirOperations) generateMessageID(messagePath string) string {
771 // Use the filename without path as the ID
772 return filepath.Base(messagePath)
773}
774
775// applyMessageFilters applies various filters to messages
776func (maildir *MaildirOperations) applyMessageFilters(messages []MessageInfo, filters map[string]interface{}) []MessageInfo {
777 var filtered []MessageInfo
778
779 for _, msg := range messages {
780 if maildir.messageMatchesFilters(msg, filters) {
781 filtered = append(filtered, msg)
782 }
783 }
784
785 return filtered
786}
787
788// messageMatchesFilters checks if a message matches the given filters
789func (maildir *MaildirOperations) messageMatchesFilters(msg MessageInfo, filters map[string]interface{}) bool {
790 // Date range filter
791 if dateFromStr, ok := filters["date_from"].(string); ok {
792 if dateFrom, err := time.Parse("2006-01-02", dateFromStr); err == nil {
793 if msg.Date.Before(dateFrom) {
794 return false
795 }
796 }
797 }
798
799 if dateToStr, ok := filters["date_to"].(string); ok {
800 if dateTo, err := time.Parse("2006-01-02", dateToStr); err == nil {
801 if msg.Date.After(dateTo.Add(24 * time.Hour)) {
802 return false
803 }
804 }
805 }
806
807 // Sender filter
808 if sender, ok := filters["sender"].(string); ok {
809 if !strings.Contains(strings.ToLower(msg.From), strings.ToLower(sender)) {
810 return false
811 }
812 }
813
814 // Subject filter
815 if subjectContains, ok := filters["subject_contains"].(string); ok {
816 if !strings.Contains(strings.ToLower(msg.Subject), strings.ToLower(subjectContains)) {
817 return false
818 }
819 }
820
821 // Unread only filter
822 if unreadOnly, ok := filters["unread_only"].(bool); ok && unreadOnly {
823 hasNewFlag := false
824 for _, flag := range msg.Flags {
825 if flag == "New" {
826 hasNewFlag = true
827 break
828 }
829 }
830 if !hasNewFlag {
831 return false
832 }
833 }
834
835 return true
836}
837
838// readMessage reads a full message with content
839func (maildir *MaildirOperations) readMessage(maildirPath, messageID string, includeHTML, includeHeaders, sanitizeContent bool) (*Message, error) {
840 // Find the message file
841 messagePath, err := maildir.findMessagePath(maildirPath, messageID)
842 if err != nil {
843 return nil, err
844 }
845
846 file, err := os.Open(messagePath)
847 if err != nil {
848 return nil, err
849 }
850 defer file.Close()
851
852 // Parse the message
853 msg, err := mail.ReadMessage(file)
854 if err != nil {
855 return nil, err
856 }
857
858 // Get file info for message info
859 _, err = file.Stat()
860 if err != nil {
861 return nil, err
862 }
863
864 // Parse basic message info
865 folder := maildir.extractFolderFromPath(messagePath)
866 isNew := strings.Contains(messagePath, "/new/")
867 messageInfo, err := maildir.parseMessageInfo(messagePath, folder, filepath.Base(messagePath), isNew)
868 if err != nil {
869 return nil, err
870 }
871
872 // Extract message body
873 body, htmlBody, err := maildir.extractMessageBody(msg, includeHTML)
874 if err != nil {
875 return nil, err
876 }
877
878 if sanitizeContent {
879 body = maildir.sanitizeContent(body)
880 if htmlBody != "" {
881 htmlBody = maildir.sanitizeContent(htmlBody)
882 }
883 }
884
885 message := &Message{
886 MessageInfo: messageInfo,
887 Body: body,
888 HTMLBody: htmlBody,
889 }
890
891 return message, nil
892}
893
894// findMessagePath finds the full path to a message file by ID
895func (maildir *MaildirOperations) findMessagePath(maildirPath, messageID string) (string, error) {
896 var foundPath string
897
898 err := filepath.Walk(maildirPath, func(path string, info os.FileInfo, err error) error {
899 if err != nil {
900 return nil
901 }
902
903 if !info.IsDir() && filepath.Base(path) == messageID {
904 foundPath = path
905 return filepath.SkipAll
906 }
907
908 return nil
909 })
910
911 if err != nil {
912 return "", err
913 }
914
915 if foundPath == "" {
916 return "", fmt.Errorf("message not found: %s", messageID)
917 }
918
919 return foundPath, nil
920}
921
922// extractFolderFromPath extracts folder name from message path
923func (maildir *MaildirOperations) extractFolderFromPath(messagePath string) string {
924 // Navigate up from the message file to find the folder
925 dir := filepath.Dir(messagePath) // cur or new directory
926 dir = filepath.Dir(dir) // folder directory
927 return filepath.Base(dir)
928}
929
930// extractMessageBody extracts plain text and HTML body from message
931func (maildir *MaildirOperations) extractMessageBody(msg *mail.Message, includeHTML bool) (string, string, error) {
932 contentType := msg.Header.Get("Content-Type")
933
934 if contentType == "" {
935 // Plain text message
936 body, err := io.ReadAll(msg.Body)
937 if err != nil {
938 return "", "", err
939 }
940 return string(body), "", nil
941 }
942
943 mediaType, params, err := mime.ParseMediaType(contentType)
944 if err != nil {
945 // Fallback to reading as plain text
946 body, err := io.ReadAll(msg.Body)
947 if err != nil {
948 return "", "", err
949 }
950 return string(body), "", nil
951 }
952
953 if strings.HasPrefix(mediaType, "text/plain") {
954 body, err := io.ReadAll(msg.Body)
955 if err != nil {
956 return "", "", err
957 }
958 return string(body), "", nil
959 }
960
961 if strings.HasPrefix(mediaType, "text/html") {
962 body, err := io.ReadAll(msg.Body)
963 if err != nil {
964 return "", "", err
965 }
966 htmlBody := string(body)
967 plainBody := maildir.htmlToPlainText(htmlBody)
968 if includeHTML {
969 return plainBody, htmlBody, nil
970 }
971 return plainBody, "", nil
972 }
973
974 if strings.HasPrefix(mediaType, "multipart/") {
975 return maildir.extractMultipartBody(msg.Body, params["boundary"], includeHTML)
976 }
977
978 // Fallback to reading as plain text
979 body, err := io.ReadAll(msg.Body)
980 if err != nil {
981 return "", "", err
982 }
983 return string(body), "", nil
984}
985
986// extractMultipartBody extracts body from multipart message
987func (maildir *MaildirOperations) extractMultipartBody(body io.Reader, boundary string, includeHTML bool) (string, string, error) {
988 mr := multipart.NewReader(body, boundary)
989
990 var plainBody, htmlBody string
991
992 for {
993 part, err := mr.NextPart()
994 if err == io.EOF {
995 break
996 }
997 if err != nil {
998 return "", "", err
999 }
1000
1001 contentType := part.Header.Get("Content-Type")
1002 mediaType, _, _ := mime.ParseMediaType(contentType)
1003
1004 partBody, err := io.ReadAll(part)
1005 if err != nil {
1006 continue
1007 }
1008
1009 switch mediaType {
1010 case "text/plain":
1011 plainBody = string(partBody)
1012 case "text/html":
1013 htmlBody = string(partBody)
1014 }
1015 }
1016
1017 // If we only have HTML, convert it to plain text
1018 if plainBody == "" && htmlBody != "" {
1019 plainBody = maildir.htmlToPlainText(htmlBody)
1020 }
1021
1022 if includeHTML {
1023 return plainBody, htmlBody, nil
1024 }
1025 return plainBody, "", nil
1026}
1027
1028// htmlToPlainText converts HTML to plain text (simple implementation)
1029func (maildir *MaildirOperations) htmlToPlainText(html string) string {
1030 // Simple HTML to text conversion - remove tags
1031 re := regexp.MustCompile(`<[^>]*>`)
1032 text := re.ReplaceAllString(html, "")
1033
1034 // Decode common HTML entities
1035 text = strings.ReplaceAll(text, "&", "&")
1036 text = strings.ReplaceAll(text, "<", "<")
1037 text = strings.ReplaceAll(text, ">", ">")
1038 text = strings.ReplaceAll(text, """, "\"")
1039 text = strings.ReplaceAll(text, "'", "'")
1040 text = strings.ReplaceAll(text, " ", " ")
1041
1042 return strings.TrimSpace(text)
1043}
1044
1045// sanitizeContent sanitizes message content (basic implementation)
1046func (maildir *MaildirOperations) sanitizeContent(content string) string {
1047 // Basic PII masking - this is a simple implementation
1048 // In production, you'd want more sophisticated PII detection
1049
1050 // Mask phone numbers
1051 phoneRegex := regexp.MustCompile(`\b\d{3}-\d{3}-\d{4}\b`)
1052 content = phoneRegex.ReplaceAllString(content, "XXX-XXX-XXXX")
1053
1054 // Mask SSNs
1055 ssnRegex := regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`)
1056 content = ssnRegex.ReplaceAllString(content, "XXX-XX-XXXX")
1057
1058 return content
1059}
1060
1061// searchMessages performs full-text search across messages
1062func (maildir *MaildirOperations) searchMessages(maildirPath, query string, limit int, filters map[string]interface{}) ([]MessageInfo, error) {
1063 var results []MessageInfo
1064
1065 // Simple implementation - scan all messages and search in subject/from/body
1066 err := filepath.Walk(maildirPath, func(path string, info os.FileInfo, err error) error {
1067 if err != nil || info.IsDir() || !maildir.isMaildirMessage(info.Name()) {
1068 return nil
1069 }
1070
1071 // Skip if not in cur or new directory
1072 if !strings.Contains(path, "/cur/") && !strings.Contains(path, "/new/") {
1073 return nil
1074 }
1075
1076 matches, messageInfo := maildir.searchInMessage(path, query)
1077 if matches {
1078 results = append(results, messageInfo)
1079 }
1080
1081 return nil
1082 })
1083
1084 if err != nil {
1085 return nil, err
1086 }
1087
1088 // Sort by date (newest first)
1089 sort.Slice(results, func(i, j int) bool {
1090 return results[i].Date.After(results[j].Date)
1091 })
1092
1093 // Apply limit
1094 if len(results) > limit {
1095 results = results[:limit]
1096 }
1097
1098 return results, nil
1099}
1100
1101// searchInMessage searches for query in a specific message
1102func (maildir *MaildirOperations) searchInMessage(messagePath, query string) (bool, MessageInfo) {
1103 file, err := os.Open(messagePath)
1104 if err != nil {
1105 return false, MessageInfo{}
1106 }
1107 defer file.Close()
1108
1109 folder := maildir.extractFolderFromPath(messagePath)
1110 isNew := strings.Contains(messagePath, "/new/")
1111 messageInfo, err := maildir.parseMessageInfo(messagePath, folder, filepath.Base(messagePath), isNew)
1112 if err != nil {
1113 return false, MessageInfo{}
1114 }
1115
1116 queryLower := strings.ToLower(query)
1117
1118 // Search in subject, from, to
1119 if strings.Contains(strings.ToLower(messageInfo.Subject), queryLower) ||
1120 strings.Contains(strings.ToLower(messageInfo.From), queryLower) ||
1121 strings.Contains(strings.ToLower(messageInfo.To), queryLower) {
1122 return true, messageInfo
1123 }
1124
1125 // Search in message body (basic implementation)
1126 file.Seek(0, 0)
1127 msg, err := mail.ReadMessage(file)
1128 if err != nil {
1129 return false, messageInfo
1130 }
1131
1132 body, _, err := maildir.extractMessageBody(msg, false)
1133 if err != nil {
1134 return false, messageInfo
1135 }
1136
1137 if strings.Contains(strings.ToLower(body), queryLower) {
1138 return true, messageInfo
1139 }
1140
1141 return false, messageInfo
1142}
1143
1144// getThread retrieves email thread for a message
1145func (maildir *MaildirOperations) getThread(maildirPath, messageID string, maxDepth int) ([]MessageInfo, error) {
1146 // Find the starting message
1147 messagePath, err := maildir.findMessagePath(maildirPath, messageID)
1148 if err != nil {
1149 return nil, err
1150 }
1151
1152 folder := maildir.extractFolderFromPath(messagePath)
1153 isNew := strings.Contains(messagePath, "/new/")
1154 startMessage, err := maildir.parseMessageInfo(messagePath, folder, filepath.Base(messagePath), isNew)
1155 if err != nil {
1156 return nil, err
1157 }
1158
1159 // Simple threading implementation - find messages with matching Message-ID, In-Reply-To, References
1160 thread := []MessageInfo{startMessage}
1161
1162 // This is a simplified implementation
1163 // A full implementation would build a proper thread tree
1164
1165 return thread, nil
1166}
1167
1168// analyzeContacts analyzes contact information from messages
1169func (maildir *MaildirOperations) analyzeContacts(maildirPath string, filters map[string]interface{}) ([]ContactInfo, error) {
1170 contactMap := make(map[string]*ContactInfo)
1171
1172 err := filepath.Walk(maildirPath, func(path string, info os.FileInfo, err error) error {
1173 if err != nil || info.IsDir() || !maildir.isMaildirMessage(info.Name()) {
1174 return nil
1175 }
1176
1177 // Skip if not in cur or new directory
1178 if !strings.Contains(path, "/cur/") && !strings.Contains(path, "/new/") {
1179 return nil
1180 }
1181
1182 folder := maildir.extractFolderFromPath(path)
1183 isNew := strings.Contains(path, "/new/")
1184 messageInfo, err := maildir.parseMessageInfo(path, folder, filepath.Base(path), isNew)
1185 if err != nil {
1186 return nil
1187 }
1188
1189 // Process From address
1190 if messageInfo.From != "" {
1191 maildir.processContact(contactMap, messageInfo.From, messageInfo.Date, false)
1192 }
1193
1194 // Process To addresses
1195 if messageInfo.To != "" {
1196 toAddresses := strings.Split(messageInfo.To, ",")
1197 for _, addr := range toAddresses {
1198 maildir.processContact(contactMap, strings.TrimSpace(addr), messageInfo.Date, true)
1199 }
1200 }
1201
1202 return nil
1203 })
1204
1205 if err != nil {
1206 return nil, err
1207 }
1208
1209 // Convert map to slice and sort by message count
1210 var contacts []ContactInfo
1211 for _, contact := range contactMap {
1212 contacts = append(contacts, *contact)
1213 }
1214
1215 sort.Slice(contacts, func(i, j int) bool {
1216 return contacts[i].Count > contacts[j].Count
1217 })
1218
1219 return contacts, nil
1220}
1221
1222// processContact processes a contact address for analysis
1223func (maildir *MaildirOperations) processContact(contactMap map[string]*ContactInfo, address string, date time.Time, isOutgoing bool) {
1224 // Extract email address from "Name <email>" format
1225 addr, err := mail.ParseAddress(address)
1226 if err != nil {
1227 // Fallback for simple email addresses
1228 addr = &mail.Address{Address: address}
1229 }
1230
1231 email := strings.ToLower(addr.Address)
1232 if email == "" {
1233 return
1234 }
1235
1236 contact, exists := contactMap[email]
1237 if !exists {
1238 contact = &ContactInfo{
1239 Email: email,
1240 Name: addr.Name,
1241 Count: 0,
1242 FirstSeen: date,
1243 LastSeen: date,
1244 IsOutgoing: isOutgoing,
1245 }
1246 contactMap[email] = contact
1247 }
1248
1249 contact.Count++
1250 if date.After(contact.LastSeen) {
1251 contact.LastSeen = date
1252 }
1253 if date.Before(contact.FirstSeen) {
1254 contact.FirstSeen = date
1255 }
1256
1257 // Update name if we have a better one
1258 if addr.Name != "" && contact.Name == "" {
1259 contact.Name = addr.Name
1260 }
1261}
1262
1263// getStatistics generates maildir usage statistics
1264func (maildir *MaildirOperations) getStatistics(maildirPath string, filters map[string]interface{}) (map[string]interface{}, error) {
1265 stats := make(map[string]interface{})
1266
1267 // Basic statistics implementation
1268 totalMessages := 0
1269 totalSize := int64(0)
1270 folderCounts := make(map[string]int)
1271
1272 err := filepath.Walk(maildirPath, func(path string, info os.FileInfo, err error) error {
1273 if err != nil || info.IsDir() || !maildir.isMaildirMessage(info.Name()) {
1274 return nil
1275 }
1276
1277 // Skip if not in cur or new directory
1278 if !strings.Contains(path, "/cur/") && !strings.Contains(path, "/new/") {
1279 return nil
1280 }
1281
1282 totalMessages++
1283 totalSize += info.Size()
1284
1285 folder := maildir.extractFolderFromPath(path)
1286 folderCounts[folder]++
1287
1288 return nil
1289 })
1290
1291 if err != nil {
1292 return nil, err
1293 }
1294
1295 stats["total_messages"] = totalMessages
1296 stats["total_size_bytes"] = totalSize
1297 stats["folder_counts"] = folderCounts
1298
1299 return stats, nil
1300}