Skip to content

Commit 0508613

Browse files
committed
Add iMessage sync support
Implement a sync-imessage command that reads from macOS's ~/Library/Messages/chat.db and imports messages into the msgvault archive using the existing sync infrastructure. New files: - internal/imessage/client.go: gmail.API implementation over chat.db - internal/imessage/parser.go: MIME builder, timestamp conversion - internal/imessage/models.go: chat.db row types - internal/imessage/parser_test.go: tests for parsing utilities - cmd/msgvault/cmd/sync_imessage.go: CLI command Uses existing SourceType field in sync.Options (added by IMAP support).
1 parent 14b0687 commit 0508613

6 files changed

Lines changed: 1086 additions & 0 deletions

File tree

cmd/msgvault/cmd/sync_imessage.go

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/signal"
8+
"path/filepath"
9+
"syscall"
10+
"time"
11+
12+
"github.com/spf13/cobra"
13+
"github.com/wesm/msgvault/internal/imessage"
14+
"github.com/wesm/msgvault/internal/store"
15+
"github.com/wesm/msgvault/internal/sync"
16+
)
17+
18+
var (
19+
imessageDBPath string
20+
imessageBefore string
21+
imessageAfter string
22+
imessageLimit int
23+
imessageMe string
24+
imessageNoResume bool
25+
)
26+
27+
var syncImessageCmd = &cobra.Command{
28+
Use: "sync-imessage",
29+
Short: "Import iMessages from local database",
30+
Long: `Import iMessages from macOS's local Messages database (chat.db).
31+
32+
Reads messages from ~/Library/Messages/chat.db and stores them in the
33+
msgvault archive alongside Gmail messages. This is a read-only operation
34+
that does not modify the iMessage database.
35+
36+
Requires Full Disk Access permission in System Settings > Privacy & Security.
37+
38+
Date filters:
39+
--after 2024-01-01 Only messages on or after this date
40+
--before 2024-12-31 Only messages before this date
41+
42+
Examples:
43+
msgvault sync-imessage
44+
msgvault sync-imessage --after 2024-01-01
45+
msgvault sync-imessage --limit 100
46+
msgvault sync-imessage --me "+15551234567"
47+
msgvault sync-imessage --db-path /path/to/chat.db`,
48+
RunE: func(cmd *cobra.Command, args []string) error {
49+
// Open msgvault database
50+
dbPath := cfg.DatabaseDSN()
51+
s, err := store.Open(dbPath)
52+
if err != nil {
53+
return fmt.Errorf("open database: %w", err)
54+
}
55+
defer s.Close()
56+
57+
if err := s.InitSchema(); err != nil {
58+
return fmt.Errorf("init schema: %w", err)
59+
}
60+
61+
// Resolve chat.db path
62+
chatDBPath := imessageDBPath
63+
if chatDBPath == "" {
64+
home, err := os.UserHomeDir()
65+
if err != nil {
66+
return fmt.Errorf("get home directory: %w", err)
67+
}
68+
chatDBPath = filepath.Join(home, "Library", "Messages", "chat.db")
69+
}
70+
71+
// Check chat.db exists
72+
if _, err := os.Stat(chatDBPath); os.IsNotExist(err) {
73+
return fmt.Errorf("iMessage database not found at %s\n\nMake sure you're running on macOS with Messages enabled", chatDBPath)
74+
}
75+
76+
// Build client options
77+
var clientOpts []imessage.ClientOption
78+
clientOpts = append(clientOpts, imessage.WithImessageLogger(logger))
79+
80+
if imessageMe != "" {
81+
clientOpts = append(clientOpts, imessage.WithMyAddress(imessageMe))
82+
}
83+
84+
if imessageAfter != "" {
85+
t, err := time.Parse("2006-01-02", imessageAfter)
86+
if err != nil {
87+
return fmt.Errorf("invalid --after date: %w (use YYYY-MM-DD format)", err)
88+
}
89+
clientOpts = append(clientOpts, imessage.WithAfterDate(t))
90+
}
91+
92+
if imessageBefore != "" {
93+
t, err := time.Parse("2006-01-02", imessageBefore)
94+
if err != nil {
95+
return fmt.Errorf("invalid --before date: %w (use YYYY-MM-DD format)", err)
96+
}
97+
clientOpts = append(clientOpts, imessage.WithBeforeDate(t))
98+
}
99+
100+
if imessageLimit > 0 {
101+
clientOpts = append(clientOpts, imessage.WithLimit(imessageLimit))
102+
}
103+
104+
// Determine source identifier
105+
identifier := "local"
106+
if imessageMe != "" {
107+
identifier = imessageMe
108+
}
109+
110+
// Create iMessage client
111+
imsgClient, err := imessage.NewClient(chatDBPath, identifier, clientOpts...)
112+
if err != nil {
113+
return fmt.Errorf("open iMessage database: %w", err)
114+
}
115+
defer imsgClient.Close()
116+
117+
// Set up context with cancellation
118+
ctx, cancel := context.WithCancel(cmd.Context())
119+
defer cancel()
120+
121+
// Handle Ctrl+C gracefully
122+
sigChan := make(chan os.Signal, 1)
123+
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
124+
go func() {
125+
<-sigChan
126+
fmt.Println("\nInterrupted. Saving checkpoint...")
127+
cancel()
128+
}()
129+
130+
// Set up sync options
131+
opts := sync.DefaultOptions()
132+
opts.NoResume = imessageNoResume
133+
opts.SourceType = "apple_messages"
134+
opts.AttachmentsDir = cfg.AttachmentsDir()
135+
136+
// Create syncer with progress reporter
137+
syncer := sync.New(imsgClient, s, opts).
138+
WithLogger(logger).
139+
WithProgress(&CLIProgress{})
140+
141+
// Run sync
142+
startTime := time.Now()
143+
fmt.Printf("Starting iMessage sync from %s\n", chatDBPath)
144+
if imessageAfter != "" || imessageBefore != "" {
145+
parts := []string{}
146+
if imessageAfter != "" {
147+
parts = append(parts, "after "+imessageAfter)
148+
}
149+
if imessageBefore != "" {
150+
parts = append(parts, "before "+imessageBefore)
151+
}
152+
fmt.Printf("Date filter: %s\n", joinParts(parts))
153+
}
154+
if imessageLimit > 0 {
155+
fmt.Printf("Limit: %d messages\n", imessageLimit)
156+
}
157+
fmt.Println()
158+
159+
summary, err := syncer.Full(ctx, identifier)
160+
if err != nil {
161+
if ctx.Err() != nil {
162+
fmt.Println("\nSync interrupted. Run again to resume.")
163+
return nil
164+
}
165+
return fmt.Errorf("sync failed: %w", err)
166+
}
167+
168+
// Print summary
169+
fmt.Println()
170+
fmt.Println("iMessage sync complete!")
171+
fmt.Printf(" Duration: %s\n", summary.Duration.Round(time.Second))
172+
fmt.Printf(" Messages: %d found, %d added, %d skipped\n",
173+
summary.MessagesFound, summary.MessagesAdded, summary.MessagesSkipped)
174+
if summary.Errors > 0 {
175+
fmt.Printf(" Errors: %d\n", summary.Errors)
176+
}
177+
if summary.WasResumed {
178+
fmt.Printf(" (Resumed from checkpoint)\n")
179+
}
180+
181+
if summary.MessagesAdded > 0 {
182+
elapsed := time.Since(startTime)
183+
messagesPerSec := float64(summary.MessagesAdded) / elapsed.Seconds()
184+
fmt.Printf(" Rate: %.1f messages/sec\n", messagesPerSec)
185+
}
186+
187+
return nil
188+
},
189+
}
190+
191+
func joinParts(parts []string) string {
192+
result := ""
193+
for i, p := range parts {
194+
if i > 0 {
195+
result += ", "
196+
}
197+
result += p
198+
}
199+
return result
200+
}
201+
202+
func init() {
203+
syncImessageCmd.Flags().StringVar(&imessageDBPath, "db-path", "", "path to chat.db (default: ~/Library/Messages/chat.db)")
204+
syncImessageCmd.Flags().StringVar(&imessageBefore, "before", "", "only messages before this date (YYYY-MM-DD)")
205+
syncImessageCmd.Flags().StringVar(&imessageAfter, "after", "", "only messages after this date (YYYY-MM-DD)")
206+
syncImessageCmd.Flags().IntVar(&imessageLimit, "limit", 0, "limit number of messages (for testing)")
207+
syncImessageCmd.Flags().StringVar(&imessageMe, "me", "", "your phone number or email (e.g., +15551234567)")
208+
syncImessageCmd.Flags().BoolVar(&imessageNoResume, "noresume", false, "force fresh sync (don't resume)")
209+
rootCmd.AddCommand(syncImessageCmd)
210+
}

0 commit comments

Comments
 (0)