Skip to content

Commit 82a42d8

Browse files
fkoyerclaude
andcommitted
Add CardDAV contact sync
- Sync contacts from PST to CardDAV server automatically after email import - Read email addresses via named properties (PSETID_Address namespace) - Include all contact fields: name, emails, phone numbers, addresses, company, title - Only process contacts from Contacts folders (skip Trash, etc.) - Keep state file until contacts sync completes to prevent email duplication on interrupt 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 996a34d commit 82a42d8

File tree

7 files changed

+589
-4
lines changed

7 files changed

+589
-4
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ require (
66
fyne.io/fyne/v2 v2.7.1
77
github.com/bits-and-blooms/bloom/v3 v3.7.1
88
github.com/emersion/go-imap v1.2.1
9+
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9
10+
github.com/emersion/go-webdav v0.7.0
911
github.com/mooijtech/go-pst/v6 v6.0.2
1012
)
1113

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ github.com/bits-and-blooms/bloom/v3 v3.7.1/go.mod h1:rZzYLLje2dfzXfAkJNxQQHsKurA
1111
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
1212
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1313
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
14+
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
1415
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
1516
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
1617
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
@@ -20,6 +21,10 @@ github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1X
2021
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
2122
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
2223
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
24+
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA=
25+
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
26+
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
27+
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
2328
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
2429
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
2530
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
@@ -91,6 +96,7 @@ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqd
9196
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
9297
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
9398
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
99+
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
94100
github.com/tidwall/btree v1.6.0 h1:LDZfKfQIBHGHWSwckhXI0RPSXzlo+KYdjK7FWSqOzzg=
95101
github.com/tidwall/btree v1.6.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY=
96102
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=

internal/carddav/uploader.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package carddav
2+
3+
import (
4+
"bytes"
5+
"crypto/tls"
6+
"fmt"
7+
"net/http"
8+
9+
"github.com/emersion/go-vcard"
10+
11+
"github.com/mxguardian/pst-import-tool/internal/pst"
12+
)
13+
14+
const (
15+
CardDAVServer = "https://webmail.mxguardian.net/dav.php/addressbooks/AddressBook/"
16+
)
17+
18+
// Uploader handles uploading contacts to CardDAV
19+
type Uploader struct {
20+
client *http.Client
21+
baseURL string
22+
username string
23+
password string
24+
}
25+
26+
// NewUploader creates a new CardDAV uploader
27+
func NewUploader(username, password string) (*Uploader, error) {
28+
// Create HTTP client with TLS (skip verify like the Python script)
29+
httpClient := &http.Client{
30+
Transport: &http.Transport{
31+
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
32+
},
33+
}
34+
35+
return &Uploader{
36+
client: httpClient,
37+
baseURL: CardDAVServer,
38+
username: username,
39+
password: password,
40+
}, nil
41+
}
42+
43+
// Upload uploads a single contact to CardDAV via HTTP PUT
44+
func (u *Uploader) Upload(contact *pst.Contact) error {
45+
// Encode vCard to bytes
46+
var buf bytes.Buffer
47+
enc := vcard.NewEncoder(&buf)
48+
if err := enc.Encode(contact.Card); err != nil {
49+
return fmt.Errorf("failed to encode vCard: %w", err)
50+
}
51+
52+
// Build the full URL for this contact
53+
url := u.baseURL + contact.UID + ".vcf"
54+
55+
// Create PUT request
56+
req, err := http.NewRequest("PUT", url, &buf)
57+
if err != nil {
58+
return fmt.Errorf("failed to create request: %w", err)
59+
}
60+
61+
req.SetBasicAuth(u.username, u.password)
62+
req.Header.Set("Content-Type", "text/vcard; charset=utf-8")
63+
64+
// Execute request
65+
resp, err := u.client.Do(req)
66+
if err != nil {
67+
return fmt.Errorf("failed to upload: %w", err)
68+
}
69+
defer resp.Body.Close()
70+
71+
// Check response - 201 Created or 204 No Content are success
72+
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
73+
return fmt.Errorf("%d %s", resp.StatusCode, resp.Status)
74+
}
75+
76+
return nil
77+
}
78+
79+
// Close is a no-op for CardDAV (HTTP is stateless)
80+
func (u *Uploader) Close() error {
81+
return nil
82+
}
83+
84+
// TestConnection tests the CardDAV connection with a PROPFIND request
85+
func TestConnection(username, password string) error {
86+
client := &http.Client{
87+
Transport: &http.Transport{
88+
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
89+
},
90+
}
91+
92+
req, err := http.NewRequest("PROPFIND", CardDAVServer, nil)
93+
if err != nil {
94+
return err
95+
}
96+
97+
req.SetBasicAuth(username, password)
98+
req.Header.Set("Depth", "0")
99+
100+
resp, err := client.Do(req)
101+
if err != nil {
102+
return fmt.Errorf("CardDAV connection failed: %w", err)
103+
}
104+
defer resp.Body.Close()
105+
106+
if resp.StatusCode == http.StatusUnauthorized {
107+
return fmt.Errorf("authentication failed")
108+
}
109+
110+
return nil
111+
}

internal/cli/run.go

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"strings"
77

8+
"github.com/mxguardian/pst-import-tool/internal/carddav"
89
"github.com/mxguardian/pst-import-tool/internal/imap"
910
"github.com/mxguardian/pst-import-tool/internal/pst"
1011
"github.com/mxguardian/pst-import-tool/internal/state"
@@ -199,11 +200,66 @@ func Run(opts Options) {
199200
}
200201
fmt.Println()
201202

202-
if totalErrors == 0 {
203+
// Sync contacts to CardDAV
204+
contactsErrors := syncContacts(extractor, username, password)
205+
206+
// Clean up state only if no errors in email or contact sync
207+
if totalErrors == 0 && contactsErrors == 0 {
203208
importState.Clear()
204209
fmt.Println("State cleaned up")
205210
} else {
206211
fmt.Printf("State saved to: %s\n", importState.StatePath())
207212
fmt.Println("Run again to retry")
208213
}
209214
}
215+
216+
// syncContacts uploads contacts from the PST to CardDAV
217+
// Returns the number of errors encountered
218+
func syncContacts(extractor *pst.Extractor, username, password string) int {
219+
fmt.Println("\nSyncing contacts...")
220+
221+
// Connect to CardDAV
222+
cardDAVUploader, err := carddav.NewUploader(username, password)
223+
if err != nil {
224+
fmt.Printf("CardDAV connection failed: %v\n", err)
225+
return 1
226+
}
227+
defer cardDAVUploader.Close()
228+
229+
var (
230+
contactsUploaded int
231+
contactsErrors int
232+
)
233+
234+
err = extractor.ProcessContacts(
235+
func(contact *pst.Contact) error {
236+
if err := cardDAVUploader.Upload(contact); err != nil {
237+
contactsErrors++
238+
return nil
239+
}
240+
contactsUploaded++
241+
if contactsUploaded%10 == 0 {
242+
fmt.Printf(".")
243+
}
244+
return nil
245+
},
246+
nil,
247+
)
248+
249+
if err != nil {
250+
fmt.Printf("\nError syncing contacts: %v\n", err)
251+
contactsErrors++
252+
}
253+
254+
if contactsUploaded > 0 || contactsErrors > 0 {
255+
fmt.Printf("\nContacts: %d uploaded", contactsUploaded)
256+
if contactsErrors > 0 {
257+
fmt.Printf(", %d errors", contactsErrors)
258+
}
259+
fmt.Println()
260+
} else {
261+
fmt.Println("No contacts found")
262+
}
263+
264+
return contactsErrors
265+
}

internal/gui/app.go

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"fyne.io/fyne/v2/storage"
1111
"fyne.io/fyne/v2/widget"
1212

13+
"github.com/mxguardian/pst-import-tool/internal/carddav"
1314
"github.com/mxguardian/pst-import-tool/internal/imap"
1415
"github.com/mxguardian/pst-import-tool/internal/pst"
1516
)
@@ -277,13 +278,64 @@ func (a *App) runImport() {
277278
a.setProgress(1.0)
278279
a.log(fmt.Sprintf("Completed: %d messages uploaded, %d errors", totalUploaded, totalErrors))
279280

281+
// Sync contacts to CardDAV
282+
contactsUploaded, contactsErrors := a.syncContacts(extractor)
283+
280284
fyne.Do(func() {
281-
dialog.ShowInformation("Success",
282-
fmt.Sprintf("PST import completed!\n%d messages uploaded", totalUploaded),
283-
a.mainWindow)
285+
msg := fmt.Sprintf("PST import completed!\n%d messages uploaded", totalUploaded)
286+
if contactsUploaded > 0 {
287+
msg += fmt.Sprintf("\n%d contacts synced", contactsUploaded)
288+
}
289+
if contactsErrors > 0 {
290+
msg += fmt.Sprintf("\n%d contact errors", contactsErrors)
291+
}
292+
dialog.ShowInformation("Success", msg, a.mainWindow)
284293
})
285294
}
286295

296+
func (a *App) syncContacts(extractor *pst.Extractor) (uploaded, errors int) {
297+
a.setStatus("Syncing contacts...")
298+
a.log("Connecting to CardDAV...")
299+
300+
cardDAVUploader, err := carddav.NewUploader(a.usernameEntry.Text, a.passwordEntry.Text)
301+
if err != nil {
302+
a.log("CardDAV connection failed: " + err.Error())
303+
return 0, 0
304+
}
305+
defer cardDAVUploader.Close()
306+
307+
err = extractor.ProcessContacts(
308+
func(contact *pst.Contact) error {
309+
select {
310+
case <-a.cancel:
311+
return fmt.Errorf("cancelled")
312+
default:
313+
}
314+
315+
if err := cardDAVUploader.Upload(contact); err != nil {
316+
errors++
317+
return nil
318+
}
319+
uploaded++
320+
a.setStatus(fmt.Sprintf("Synced %d contacts...", uploaded))
321+
return nil
322+
},
323+
nil,
324+
)
325+
326+
if err != nil {
327+
a.log("Contact sync error: " + err.Error())
328+
}
329+
330+
if uploaded > 0 || errors > 0 {
331+
a.log(fmt.Sprintf("Contacts: %d synced, %d errors", uploaded, errors))
332+
} else {
333+
a.log("No contacts found")
334+
}
335+
336+
return uploaded, errors
337+
}
338+
287339
func (a *App) setUIEnabled(enabled bool) {
288340
fyne.Do(func() {
289341
if enabled {

0 commit comments

Comments
 (0)