Skip to content
Open
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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ sprinkling suppressions.

- Module path: `github.com/remdev/go-activesync`. Do not introduce other
paths in imports, examples, or docs.
- Append new exported fields at the **end** of configuration structs (for
example `client.Config`) so positional composite literals in downstream code
remain compatible across minor updates.
- Commit author / committer is governed by the local git config. Do not
rewrite history that has already been pushed to `origin`.

Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,28 @@ if eas.PingHasChanges(resp.Status) {
}
```

### Outlook-like client profile

Many servers key off `DeviceType` and `Locale` (LCID), and expect additional metadata via headers rather than MS-ASHTTP query fields. Use `DeviceType: "Outlook"` when emulating Outlook; set `Locale` to `0x0409` for en-US or `0x0419` for ru-RU. Device model, OS version, or other vendor-specific strings are not separate `Config` fields—supply them with `ExtraHeaders` so they merge after the mandatory headers without replacing `User-Agent`, `MS-ASProtocolVersion`, and other values the client sets. If you must avoid HTTP/2 to match an older appliance or proxy, pass `ForceHTTP11: true` with `HTTPClient: nil`; if you inject your own `HTTPClient`, tune its transport yourself (`ForceHTTP11` is ignored).

```go
import "net/http"

_, err := client.New(client.Config{
BaseURL: ad.URL,
Auth: &client.BasicAuth{Username: "user@example.com", Password: "pass"},
DeviceID: "stable-device-id",
DeviceType: "Outlook",
Locale: 0x0409,
UserAgent: "Microsoft Office/16.0 (Windows NT 10.0; Microsoft Outlook 16.0.1)",
ExtraHeaders: http.Header{
"X-MS-Device-MachineName": []string{"WORKSTATION1"},
"X-OS-Type": []string{"Windows"},
},
ForceHTTP11: true,
})
```

Runnable end-to-end programs live under [`examples/`](examples/):
[`login`](examples/login), [`inbox-sync`](examples/inbox-sync),
[`calendar-sync`](examples/calendar-sync), [`ping`](examples/ping).
Expand Down
71 changes: 62 additions & 9 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package client
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
Expand All @@ -29,20 +30,54 @@ type Client struct {

PolicyStore PolicyStore
SyncStateStore SyncStateStore

// ExtraHeaders are merged into each request after mandatory headers without
// overwriting keys already set (see Config.ExtraHeaders). Do not mutate this
// map after New while the Client is in use; concurrent writes race with requests.
ExtraHeaders http.Header

// ForceHTTP11 reflects the config flag; when HTTPClient was supplied to New
// the transport is never altered and this bit is informational only.
ForceHTTP11 bool
Comment on lines +34 to +41
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.

Client.ExtraHeaders is an exported http.Header (map) that is read on every request. If a caller mutates this map while requests are in-flight, it can cause data races or panics due to concurrent map iteration/writes. Consider keeping the headers unexported (store an internal clone) and exposing them via a method that returns a clone, or clearly documenting that the field must be treated as immutable after New (and not modified concurrently).

Copilot uses AI. Check for mistakes.
}

// Config bundles the values required to construct a Client.
type Config struct {
BaseURL string
HTTPClient *http.Client
Auth Authenticator
DeviceID string
DeviceType string
UserAgent string
Locale uint16
BaseURL string
HTTPClient *http.Client
Auth Authenticator

DeviceID string
// DeviceType is the device class in the MS-ASHTTP query (e.g. "SmartPhone").
// For an Outlook-style profile many servers expect DeviceType "Outlook".
DeviceType string

// UserAgent is sent as the mandatory User-Agent header.
UserAgent string

// Locale is the LCID placed in the binary query (little-endian uint16), for
// example 0x0409 (en-US) or 0x0419 (ru-RU).
Locale uint16

AcceptLanguage string

PolicyStore PolicyStore
SyncStateStore SyncStateStore

// ExtraHeaders optional integrator headers (device model, OS, or other
// vendor expectations). They are merged after mandatory headers and never
// replace keys the client already set; device model/OS are not separate
// Config fields because MS-ASHTTP only standardizes the query DeviceType.
//
// Avoid mutating this header map after passing Config to New if other
// goroutines still hold a reference to it; New clones into the Client when non-empty.
ExtraHeaders http.Header

// ForceHTTP11, when true and HTTPClient is nil, builds an HTTP client whose
// transport clones http.DefaultTransport and disables HTTP/2 by setting
// TLSNextProto to a non-nil empty map. When HTTPClient is non-nil,
// ForceHTTP11 is ignored and the caller's transport is not modified.
ForceHTTP11 bool
}

// New returns a Client populated with sensible defaults for any unset
Expand All @@ -59,7 +94,6 @@ func New(cfg Config) (*Client, error) {
}
c := &Client{
BaseURL: cfg.BaseURL,
HTTPClient: cfg.HTTPClient,
Auth: cfg.Auth,
DeviceID: cfg.DeviceID,
DeviceType: cfg.DeviceType,
Expand All @@ -69,8 +103,26 @@ func New(cfg Config) (*Client, error) {
AcceptLanguage: cfg.AcceptLanguage,
PolicyStore: cfg.PolicyStore,
SyncStateStore: cfg.SyncStateStore,
ForceHTTP11: cfg.ForceHTTP11,
}
if len(cfg.ExtraHeaders) > 0 {
c.ExtraHeaders = cfg.ExtraHeaders.Clone()
}
if c.HTTPClient == nil {
switch {
case cfg.HTTPClient != nil:
c.HTTPClient = cfg.HTTPClient
case cfg.ForceHTTP11:
dt, ok := http.DefaultTransport.(*http.Transport)
if !ok {
c.HTTPClient = http.DefaultClient
break
}
tr := dt.Clone()
tr.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)
hc := *http.DefaultClient
hc.Transport = tr
c.HTTPClient = &hc
default:
c.HTTPClient = http.DefaultClient
}
if c.UserAgent == "" {
Expand Down Expand Up @@ -163,6 +215,7 @@ func (c *Client) doOnce(ctx context.Context, cmd byte, user string, request, res
PolicyKey: policyKey,
AcceptLanguage: c.AcceptLanguage,
})
mergeExtraHeaders(req.Header, c.ExtraHeaders)
if c.Auth != nil {
if err := c.Auth.Apply(req); err != nil {
return fmt.Errorf("client: auth: %w", err)
Expand Down
49 changes: 49 additions & 0 deletions client/client_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package client
import (
"context"
"errors"
"net/http"
"strings"
"testing"

Expand Down Expand Up @@ -72,3 +73,51 @@ type errStore struct{}

func (errStore) Get(context.Context) (string, error) { return "", errors.New("boom") }
func (errStore) Set(context.Context, string) error { return nil }

// SPEC: MS-ASHTTP/client.profile.force-http11
func TestNew_ForceHTTP11_DefaultTransportDisablesHTTP2(t *testing.T) {
c, err := New(Config{
BaseURL: "http://example.invalid/Microsoft-Server-ActiveSync",
DeviceID: "d",
DeviceType: "t",
ForceHTTP11: true,
})
if err != nil {
t.Fatalf("New: %v", err)
}
tr, ok := c.HTTPClient.Transport.(*http.Transport)
if !ok {
t.Fatalf("Transport type %T", c.HTTPClient.Transport)
}
if tr.TLSNextProto == nil {
t.Fatal("TLSNextProto is nil, want non-nil empty map")
}
if len(tr.TLSNextProto) != 0 {
t.Fatalf("TLSNextProto len = %d, want 0", len(tr.TLSNextProto))
}
}

// SPEC: MS-ASHTTP/client.profile.force-http11
func TestNew_ForceHTTP11_CustomHTTPClientUnchanged(t *testing.T) {
base := &http.Transport{}
hc := &http.Client{Transport: base}
c, err := New(Config{
BaseURL: "http://example.invalid/Microsoft-Server-ActiveSync",
HTTPClient: hc,
DeviceID: "d",
DeviceType: "t",
ForceHTTP11: true,
})
if err != nil {
t.Fatalf("New: %v", err)
}
if c.HTTPClient != hc {
t.Fatal("HTTPClient replaced")
}
if c.HTTPClient.Transport != base {
t.Fatal("Transport replaced")
}
if base.TLSNextProto != nil {
t.Fatalf("custom transport TLSNextProto = %v, want nil", base.TLSNextProto)
}
}
142 changes: 142 additions & 0 deletions client/client_profile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package client

import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"

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

// SPEC: MS-ASHTTP/client.transport.force-http11
func TestNew_ForceHTTP11_TLSNextProtoEmptyMap(t *testing.T) {
c, err := New(Config{
BaseURL: "https://example.invalid/Microsoft-Server-ActiveSync",
DeviceID: "d",
DeviceType: "t",
ForceHTTP11: true,
})
if err != nil {
t.Fatalf("New: %v", err)
}
tr, ok := c.HTTPClient.Transport.(*http.Transport)
if !ok {
t.Fatalf("Transport type got %T", c.HTTPClient.Transport)
}
if tr.TLSNextProto == nil {
t.Fatal("TLSNextProto is nil; expected non-nil empty map to disable HTTP/2 ALPN")
}
if len(tr.TLSNextProto) != 0 {
t.Fatalf("TLSNextProto len = %d; want 0", len(tr.TLSNextProto))
}
}

// SPEC: MS-ASHTTP/client.transport.force-http11
func TestNew_ForceHTTP11_WithCustomHTTPClientIgnored(t *testing.T) {
custom := &http.Client{Transport: http.DefaultTransport}
c, err := New(Config{
BaseURL: "http://example.invalid/Microsoft-Server-ActiveSync",
DeviceID: "d",
DeviceType: "t",
HTTPClient: custom,
ForceHTTP11: true,
})
if err != nil {
t.Fatalf("New: %v", err)
}
if c.HTTPClient != custom {
t.Fatal("ForceHTTP11 must not replace a caller-supplied HTTPClient")
}
}

// SPEC: MS-ASHTTP/client.extra-headers-merge
func TestProvision_OutboundExtraHeaders(t *testing.T) {
var saw string
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
saw = r.Header.Get("X-Integration-Probe")
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
var req eas.ProvisionRequest
if err := wbxml.Unmarshal(body, &req); err != nil {
http.Error(w, err.Error(), 400)
return
}
calls++
var resp eas.ProvisionResponse
Comment on lines +59 to +72
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.

This test only asserts X-Integration-Probe after Provision completes, but Provision issues two HTTP requests; the current check could pass even if the header is missing on the first phase (or only present on one of the calls). Consider asserting the header value per-call inside the handler (e.g., check on both calls 1 and 2) so the requirement is covered for every outbound request.

Copilot uses AI. Check for mistakes.
switch calls {
case 1:
resp = eas.ProvisionResponse{
Status: int32(eas.StatusSuccess),
Policies: eas.PoliciesResponse{
Policy: []eas.PolicyResponse{{
PolicyType: eas.PolicyTypeWBXML,
PolicyKey: "temp-key",
Status: int32(eas.StatusSuccess),
Data: &eas.EASProvisionDoc{
DevicePasswordEnabled: 1,
MinDevicePasswordLength: 4,
MaxInactivityTimeDeviceLock: 900,
MaxDevicePasswordFailedAttempts: 8,
AllowSimpleDevicePassword: 1,
AllowStorageCard: 1,
AllowCamera: 1,
RequireDeviceEncryption: 0,
AlphanumericDevicePasswordRequired: 0,
},
}},
},
}
case 2:
resp = eas.ProvisionResponse{
Status: int32(eas.StatusSuccess),
Policies: eas.PoliciesResponse{
Policy: []eas.PolicyResponse{{
PolicyType: eas.PolicyTypeWBXML,
PolicyKey: "final-key",
Status: int32(eas.StatusSuccess),
}},
},
}
default:
http.Error(w, "unexpected call", 500)
return
}
data, err := wbxml.Marshal(&resp)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", ContentTypeWBXML)
_, _ = w.Write(data)
}))
t.Cleanup(srv.Close)

h := http.Header{}
h.Set("X-Integration-Probe", "present")
c, err := New(Config{
BaseURL: srv.URL + EndpointPath,
HTTPClient: srv.Client(),
Auth: &BasicAuth{Username: "u", Password: "p"},
DeviceID: "DEV",
DeviceType: "Outlook",
UserAgent: "ua-test/1",
Locale: 0x0419,
ExtraHeaders: h,
})
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := c.Provision(context.Background(), "user@example.com"); err != nil {
t.Fatalf("Provision: %v", err)
}
if saw != "present" {
t.Fatalf("X-Integration-Probe = %q", saw)
}
}
27 changes: 27 additions & 0 deletions client/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,30 @@ func ApplyMandatoryHeaders(h http.Header, opts HeaderOptions) {
h.Set("Accept-Language", opts.AcceptLanguage)
}
}

// mergeExtraHeaders merges src into dst for integrator-specific headers. Each
// header name is normalized with http.CanonicalHeaderKey. If dst already
// contains any value for that name, the entire key is skipped so mandatory
// client headers cannot be overwritten. Otherwise every value from src for
// that key is added with Add (preserving duplicates from src).
func mergeExtraHeaders(dst, src http.Header) {
if len(src) == 0 {
return
}
grouped := make(map[string][]string)
for k, vals := range src {
if k == "" {
continue
}
ck := http.CanonicalHeaderKey(k)
grouped[ck] = append(grouped[ck], vals...)
}
for ck, vals := range grouped {
if len(dst.Values(ck)) > 0 {
continue
}
for _, v := range vals {
dst.Add(ck, v)
}
}
}
Loading
Loading