Skip to content
Merged
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
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ if _, err := c.Provision(ctx, "user@example.com"); err != nil { /* handle */ }
### Sync new e-mails from the inbox

```go
import "github.com/remdev/go-activesync/eas"
import (
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updated Sync example now calls log.Printf but the snippet’s import list doesn’t include the standard library log package. Please either add the missing import(s) in the example or avoid using log in the snippet so it compiles as shown.

Suggested change
import (
import (
"log"

Copilot uses AI. Check for mistakes.
"log"

"github.com/remdev/go-activesync/client"
"github.com/remdev/go-activesync/eas"
)

initial, _ := c.Sync(ctx, user, &eas.SyncRequest{
Collections: eas.SyncCollections{
Expand All @@ -58,7 +63,7 @@ initial, _ := c.Sync(ctx, user, &eas.SyncRequest{
})
syncKey := initial.Collections.Collection[0].SyncKey

resp, _ := c.Sync(ctx, user, &eas.SyncRequest{
resp, _ := client.SyncTyped[eas.Email](ctx, c, user, &eas.SyncRequest{
Collections: eas.SyncCollections{
Collection: []eas.SyncCollection{{
SyncKey: syncKey,
Expand All @@ -69,13 +74,20 @@ resp, _ := c.Sync(ctx, user, &eas.SyncRequest{
},
})

for _, col := range resp.Collections.Collection {
for _, add := range col.Commands.Add {
// add.ApplicationData → typed eas.Email
for _, col := range resp.Collections {
for _, add := range col.Add {
if add.ApplicationData == nil {
continue
}
log.Printf("new mail %s: %s", add.ServerID, add.ApplicationData.Subject)
}
}
```

For mixed-class collections, call `c.Sync` directly and use the
`SyncAdd.Email()` / `Appointment()` / `Contact()` / `Task()` helpers, or
project a single collection with `eas.NewTypedSyncResponse[T]`.

### Long-poll for changes with Ping

```go
Expand Down Expand Up @@ -119,6 +131,7 @@ Implemented and covered by the test suite:
| Auth | HTTP Basic; pluggable `Authenticator` interface |
| Provisioning | Two-pass MS-ASPROV with auto re-provision on Status 142/143 |
| Commands | `Provision`, `FolderSync`, `Sync`, `Ping` |
| Typed Sync | `client.SyncTyped[T]`, `eas.UnmarshalApplicationData[T]`, four helpers |
| PIM data models | `MS-ASEMAIL`, `MS-ASCAL`, `MS-ASCNTC`, `MS-ASTASK` |
| Stores | In-memory `PolicyStore` and `SyncStateStore`; pluggable interfaces |
| Hardening | Bounded decoder allocations + `FuzzDecode` over the WBXML reader |
Expand Down
27 changes: 27 additions & 0 deletions client/cmd_sync_typed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package client

import (
"context"

"github.com/remdev/go-activesync/eas"
)

// SyncTyped is a generic wrapper around Client.Sync that decodes every
// SyncAdd / SyncChange ApplicationData into the requested type T. It is
// intended for the common case of a Sync request that targets a single
// collection of a single Class (Email, Calendar, Contacts, Tasks).
//
// SyncTyped does not surface the raw eas.SyncResponse; callers that need
// access to the wire-level structure (e.g. to introspect Class / Status per
// collection while still picking the right T per collection at runtime)
// should call (*Client).Sync directly and project the response with
// eas.NewTypedSyncResponse[T] themselves.
//
// SPEC: MS-ASCMD/sync.typed
func SyncTyped[T any](ctx context.Context, c *Client, user string, req *eas.SyncRequest) (*eas.TypedSyncResponse[T], error) {
resp, err := c.Sync(ctx, user, req)
if err != nil {
return nil, err
}
return eas.NewTypedSyncResponse[T](resp)
}
171 changes: 171 additions & 0 deletions client/integration_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package client

import (
"bytes"
"context"
"io"
"net/http"
Expand Down Expand Up @@ -270,6 +271,176 @@ func TestSync_RoundTrip(t *testing.T) {
}
}

// SPEC: MS-ASCMD/sync.applicationdata.raw
// SPEC: MS-ASCMD/sync.applicationdata.typed
func TestSync_TypedApplicationData(t *testing.T) {
emailIn := &eas.Email{
Subject: "hello",
From: "alice@example.com",
To: "bob@example.com",
}
body := mustEmailBody(t, emailIn)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req eas.SyncRequest
decodeWBXML(t, r, &req)
writeWBXML(t, w, &eas.SyncResponse{
Collections: eas.SyncCollections{
Collection: []eas.SyncCollection{{
SyncKey: "S-1",
CollectionID: "1",
Class: "Email",
Status: int32(eas.SyncStatusSuccess),
Commands: &eas.SyncCommands{
Add: []eas.SyncAdd{{
ServerID: "1:1",
ApplicationData: &wbxml.RawElement{
Page: wbxml.PageAirSync,
Bytes: body,
},
}},
Change: []eas.SyncChange{{
ServerID: "1:1",
ApplicationData: &wbxml.RawElement{
Page: wbxml.PageAirSync,
Bytes: body,
},
}},
},
}},
},
})
}))
t.Cleanup(srv.Close)

c := newTestClient(t, srv)
resp, err := c.Sync(context.Background(), "user@example.com", &eas.SyncRequest{
Collections: eas.SyncCollections{
Collection: []eas.SyncCollection{{SyncKey: "0", CollectionID: "1", GetChanges: 1}},
},
})
if err != nil {
t.Fatalf("Sync: %v", err)
}
col := resp.Collections.Collection[0]
if col.Commands == nil || len(col.Commands.Add) != 1 {
t.Fatalf("missing Add: %+v", col.Commands)
}
add := col.Commands.Add[0]
if add.ApplicationData == nil {
t.Fatal("ApplicationData missing on Add")
}
got, err := add.Email()
if err != nil {
t.Fatalf("add.Email: %v", err)
}
if got.Subject != emailIn.Subject || got.From != emailIn.From || got.To != emailIn.To {
t.Fatalf("Email decode mismatch: %+v", got)
}
chg := col.Commands.Change[0]
got2, err := chg.Email()
if err != nil {
t.Fatalf("change.Email: %v", err)
}
if got2.Subject != emailIn.Subject {
t.Fatalf("change Email mismatch: %+v", got2)
}
}

// mustEmailBody marshals an Email value and strips the WBXML header plus
// the AirSync.ApplicationData wrapper, returning just the bytes that would
// appear inside ApplicationData on the wire.
func mustEmailBody(t *testing.T, e *eas.Email) []byte {
t.Helper()
data, err := wbxml.Marshal(e)
if err != nil {
t.Fatalf("marshal email: %v", err)
}
dec := wbxml.NewDecoder(bytes.NewReader(data))
if _, err := dec.ReadHeader(); err != nil {
t.Fatalf("read header: %v", err)
}
tok, err := dec.NextToken()
if err != nil {
t.Fatalf("next token: %v", err)
}
if tok.Kind != wbxml.KindTag {
t.Fatalf("expected tag, got %s", tok.Kind)
}
body, err := dec.CaptureRaw(tok.HasContent)
if err != nil {
t.Fatalf("capture raw: %v", err)
}
return body
}

// SPEC: MS-ASCMD/sync.typed
func TestSyncTyped_Email(t *testing.T) {
in := []eas.Email{
{Subject: "first", From: "a@example.com", To: "b@example.com"},
{Subject: "second", From: "c@example.com", To: "d@example.com"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req eas.SyncRequest
decodeWBXML(t, r, &req)
adds := make([]eas.SyncAdd, 0, len(in))
for i, e := range in {
adds = append(adds, eas.SyncAdd{
ServerID: serverIDFor(i),
ApplicationData: &wbxml.RawElement{
Page: wbxml.PageAirSync,
Bytes: mustEmailBody(t, &e),
},
})
}
writeWBXML(t, w, &eas.SyncResponse{
Status: int32(eas.SyncStatusSuccess),
Collections: eas.SyncCollections{
Collection: []eas.SyncCollection{{
SyncKey: "S-2",
CollectionID: "1",
Class: "Email",
Status: int32(eas.SyncStatusSuccess),
Commands: &eas.SyncCommands{Add: adds},
}},
},
})
}))
t.Cleanup(srv.Close)

c := newTestClient(t, srv)
resp, err := SyncTyped[eas.Email](context.Background(), c, "user@example.com", &eas.SyncRequest{
Collections: eas.SyncCollections{
Collection: []eas.SyncCollection{{SyncKey: "0", CollectionID: "1", GetChanges: 1}},
},
})
if err != nil {
t.Fatalf("SyncTyped: %v", err)
}
if len(resp.Collections) != 1 {
t.Fatalf("collections: %d", len(resp.Collections))
}
col := resp.Collections[0]
if col.Class != "Email" || col.SyncKey != "S-2" {
t.Fatalf("collection metadata: %+v", col)
}
if len(col.Add) != len(in) {
t.Fatalf("Add count = %d, want %d", len(col.Add), len(in))
}
for i, item := range col.Add {
if item.ApplicationData == nil {
t.Fatalf("Add[%d] ApplicationData nil", i)
}
if item.ApplicationData.Subject != in[i].Subject {
t.Fatalf("Add[%d] Subject %q, want %q", i, item.ApplicationData.Subject, in[i].Subject)
}
}
}

func serverIDFor(i int) string {
return "1:" + string(rune('0'+i))
}

// SPEC: MS-ASCMD/ping.response
// SPEC: MS-ASCMD/ping.status.changes
func TestPing_ChangesAvailable(t *testing.T) {
Expand Down
26 changes: 13 additions & 13 deletions eas/airsync.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package eas

import "github.com/remdev/go-activesync/wbxml"

// SyncRequest is the MS-ASCMD Sync command request payload.
type SyncRequest struct {
XMLName struct{} `wbxml:"AirSync.Sync"`
Expand Down Expand Up @@ -60,16 +62,22 @@ type SyncCommands struct {
}

// SyncAdd carries a server-pushed addition or a client-side new item.
//
// ApplicationData is left as a raw WBXML element because the concrete payload
// type (Email, Appointment, Contact, Task, …) depends on the collection's
// Class. Use the convenience methods on SyncAdd / SyncChange (Email,
// Appointment, Contact, Task) or UnmarshalApplicationData[T] to decode the
// payload into a typed value.
type SyncAdd struct {
ServerID string `wbxml:"AirSync.ServerId,omitempty"`
ClientID string `wbxml:"AirSync.ClientId,omitempty"`
ApplicationData *AppRaw `wbxml:"AirSync.ApplicationData,omitempty"`
ServerID string `wbxml:"AirSync.ServerId,omitempty"`
ClientID string `wbxml:"AirSync.ClientId,omitempty"`
ApplicationData *wbxml.RawElement `wbxml:"AirSync.ApplicationData,omitempty,raw"`
}

// SyncChange carries an item modification.
type SyncChange struct {
ServerID string `wbxml:"AirSync.ServerId"`
ApplicationData *AppRaw `wbxml:"AirSync.ApplicationData,omitempty"`
ServerID string `wbxml:"AirSync.ServerId"`
ApplicationData *wbxml.RawElement `wbxml:"AirSync.ApplicationData,omitempty,raw"`
}

// SyncDelete carries an item deletion notification.
Expand All @@ -81,11 +89,3 @@ type SyncDelete struct {
type SyncFetch struct {
ServerID string `wbxml:"AirSync.ServerId"`
}

// AppRaw is an opaque carrier for AirSync.ApplicationData.
//
// Real domain types (Email/Appointment/Contact/Task) marshal independently
// from AirSync.ApplicationData; for command-level round trips we only need
// the wrapper element to be preserved. Concrete decoding is done by the
// caller after extracting the raw bytes if needed.
type AppRaw struct{}
Loading
Loading