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
29 changes: 15 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,23 +104,24 @@ 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).
Many servers key off the request query shape and Outlook-style device metadata. Use `QueryEncoding: client.QueryEncodingPlain` to send `Cmd=...&User=...&DeviceId=...&DeviceType=...`, `DeviceType: "Outlook"` when emulating Outlook, and `Locale: 0x0419` with `AcceptLanguage: "ru-RU"` for ru-RU. First-class device profile fields are sent as `X-MS-Device*` headers; keep `ExtraHeaders` for integration-specific headers that are not modeled directly. 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,
BaseURL: ad.URL,
Auth: &client.BasicAuth{Username: "user@example.com", Password: "pass"},
DeviceID: "stable-device-id",
QueryEncoding: client.QueryEncodingPlain,
DeviceType: "Outlook",
DeviceModel: "Outlook for iOS and Android",
DeviceOS: "iOS 17.5",
DeviceOSLanguage: "ru",
Carrier: "Apple",
DeviceUserAgent: "Outlook-iOS-Android/1.0",
UserAgent: "Outlook-iOS-Android/1.0 (iCloud, Exchange ActiveSync)",
AcceptLanguage: "ru-RU",
Locale: 0x0419,
ForceHTTP11: true,
})
```

Expand Down
123 changes: 105 additions & 18 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"github.com/remdev/go-activesync/wbxml"
)

const defaultDeviceModel = "Outlook for iOS and Android"

// Client is the high-level Exchange ActiveSync client. The zero value is not
// useful; use New to construct a fully wired Client.
type Client struct {
Expand All @@ -39,6 +41,17 @@ type Client struct {
// ForceHTTP11 reflects the config flag; when HTTPClient was supplied to New
// the transport is never altered and this bit is informational only.
ForceHTTP11 bool

QueryEncoding QueryEncoding

DeviceModel string
DeviceOS string
DeviceOSLanguage string
Carrier string
PhoneNumber string
DeviceUserAgent string
DeviceFriendlyName string
DeviceIMEI string
}

// Config bundles the values required to construct a Client.
Expand All @@ -56,18 +69,18 @@ type Config struct {
UserAgent string

// Locale is the LCID placed in the binary query (little-endian uint16), for
// example 0x0409 (en-US) or 0x0419 (ru-RU).
// example 0x0409 (en-US) or 0x0419 (ru-RU). Plain queries carry language
// preferences through Accept-Language instead of a locale field.
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.
// ExtraHeaders optional integrator headers not modeled by first-class
// Config fields. They are merged after mandatory and device-profile headers
// and never replace keys the client already set.
//
// 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.
Expand All @@ -78,6 +91,21 @@ type Config struct {
// 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

// QueryEncoding selects the MS-ASHTTP request URI format. The zero value
// defaults to QueryEncodingBase64 for compatibility with earlier releases.
QueryEncoding QueryEncoding

// Device profile fields are sent as X-MS-Device* headers and in the
// Provision DeviceInformation body.
DeviceModel string
DeviceOS string
DeviceOSLanguage string
Carrier string
PhoneNumber string
DeviceUserAgent string
DeviceFriendlyName string
DeviceIMEI string
Comment on lines +99 to +108
}

// New returns a Client populated with sensible defaults for any unset
Expand All @@ -92,18 +120,36 @@ func New(cfg Config) (*Client, error) {
if cfg.DeviceType == "" {
return nil, errors.New("client: DeviceType is required")
}
queryEncoding := cfg.QueryEncoding
if queryEncoding == "" {
queryEncoding = QueryEncodingBase64
}
Comment on lines +123 to +126
switch queryEncoding {
case QueryEncodingBase64, QueryEncodingPlain:
default:
return nil, fmt.Errorf("client: unsupported QueryEncoding %q", cfg.QueryEncoding)
}
c := &Client{
BaseURL: cfg.BaseURL,
Auth: cfg.Auth,
DeviceID: cfg.DeviceID,
DeviceType: cfg.DeviceType,
UserAgent: cfg.UserAgent,
Locale: cfg.Locale,
ProtocolVersion: eas.ProtocolVersion,
AcceptLanguage: cfg.AcceptLanguage,
PolicyStore: cfg.PolicyStore,
SyncStateStore: cfg.SyncStateStore,
ForceHTTP11: cfg.ForceHTTP11,
BaseURL: cfg.BaseURL,
Auth: cfg.Auth,
DeviceID: cfg.DeviceID,
DeviceType: cfg.DeviceType,
UserAgent: cfg.UserAgent,
Locale: cfg.Locale,
ProtocolVersion: eas.ProtocolVersion,
AcceptLanguage: cfg.AcceptLanguage,
PolicyStore: cfg.PolicyStore,
SyncStateStore: cfg.SyncStateStore,
ForceHTTP11: cfg.ForceHTTP11,
QueryEncoding: queryEncoding,
DeviceModel: cfg.DeviceModel,
DeviceOS: cfg.DeviceOS,
DeviceOSLanguage: cfg.DeviceOSLanguage,
Carrier: cfg.Carrier,
PhoneNumber: cfg.PhoneNumber,
DeviceUserAgent: cfg.DeviceUserAgent,
DeviceFriendlyName: cfg.DeviceFriendlyName,
DeviceIMEI: cfg.DeviceIMEI,
}
if len(cfg.ExtraHeaders) > 0 {
c.ExtraHeaders = cfg.ExtraHeaders.Clone()
Expand All @@ -128,6 +174,24 @@ func New(cfg Config) (*Client, error) {
if c.UserAgent == "" {
c.UserAgent = "go-activesync/0.1"
}
if c.DeviceModel == "" {
c.DeviceModel = defaultDeviceModel
}
if c.DeviceOS == "" {
c.DeviceOS = "iOS 17.5"
}
if c.DeviceOSLanguage == "" {
c.DeviceOSLanguage = "ru"
}
if c.Carrier == "" {
c.Carrier = "Apple"
}
if c.DeviceUserAgent == "" {
c.DeviceUserAgent = "Outlook-iOS-Android/1.0"
}
if c.DeviceFriendlyName == "" {
c.DeviceFriendlyName = c.DeviceModel
}
if c.Locale == 0 {
c.Locale = 0x0409
}
Expand Down Expand Up @@ -197,11 +261,11 @@ func (c *Client) doOnce(ctx context.Context, cmd byte, user string, request, res
if user != "" {
q.Params = append(q.Params, QueryParam{Tag: ParamUser, Value: []byte(user)})
}
encoded, err := q.EncodeBase64()
encoded, plain, err := c.encodeQuery(q, user)
if err != nil {
return fmt.Errorf("client: encode query: %w", err)
}
urlStr, err := BuildURL(c.BaseURL, encoded, false)
urlStr, err := BuildURL(c.BaseURL, encoded, plain)
if err != nil {
return fmt.Errorf("client: build url: %w", err)
}
Expand All @@ -215,6 +279,14 @@ func (c *Client) doOnce(ctx context.Context, cmd byte, user string, request, res
PolicyKey: policyKey,
AcceptLanguage: c.AcceptLanguage,
})
ApplyDeviceProfileHeaders(req.Header, DeviceHeaderOptions{
DeviceModel: c.DeviceModel,
DeviceOS: c.DeviceOS,
DeviceOSLanguage: c.DeviceOSLanguage,
Carrier: c.Carrier,
PhoneNumber: c.PhoneNumber,
DeviceUserAgent: c.DeviceUserAgent,
})
mergeExtraHeaders(req.Header, c.ExtraHeaders)
if c.Auth != nil {
if err := c.Auth.Apply(req); err != nil {
Expand Down Expand Up @@ -246,6 +318,21 @@ func (c *Client) doOnce(ctx context.Context, cmd byte, user string, request, res
return nil
}

func (c *Client) encodeQuery(q Query, user string) (string, bool, error) {
switch c.QueryEncoding {
case "", QueryEncodingBase64:
encoded, err := q.EncodeBase64()
return encoded, false, err
case QueryEncodingPlain:
if user == "" {
return "", true, errors.New("plain query encoding requires user")
}
return q.EncodePlain(), true, nil
default:
return "", false, fmt.Errorf("unsupported QueryEncoding %q", c.QueryEncoding)
}
}

// globalStatus extracts a top-level Status field from a typed response, if
// the response struct exposes one. It is used to translate command-level
// re-provision codes into a StatusError that the retry layer can recognise;
Expand Down
13 changes: 13 additions & 0 deletions client/client_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ func TestNew_RequiresFields(t *testing.T) {
}
}

// SPEC: MS-ASHTTP/client.query-encoding
func TestNew_RejectsUnknownQueryEncoding(t *testing.T) {
_, err := New(Config{
BaseURL: "http://example.invalid/Microsoft-Server-ActiveSync",
DeviceID: "d",
DeviceType: "t",
QueryEncoding: QueryEncoding("xml"),
})
if err == nil {
t.Fatal("expected unsupported QueryEncoding error")
}
}

// SPEC: MS-ASCMD/global.status.codes
func TestGlobalStatus_AllResponses(t *testing.T) {
cases := []any{
Expand Down
101 changes: 101 additions & 0 deletions client/client_profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,104 @@ func TestProvision_OutboundExtraHeaders(t *testing.T) {
t.Fatalf("X-Integration-Probe = %q", saw)
}
}

// SPEC: MS-ASCMD/status.165.device-information-required
// SPEC: MS-ASPROV/provision.device-information
func TestProvision_IncludesDefaultOutlookDeviceInformation(t *testing.T) {
var first eas.ProvisionRequest
var second eas.ProvisionRequest
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req eas.ProvisionRequest
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
if err := wbxml.Unmarshal(body, &req); err != nil {
http.Error(w, err.Error(), 400)
return
}
calls++
if calls == 1 {
first = req
writeWBXML(t, w, &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,
},
}},
},
})
return
}
second = req
writeWBXML(t, w, &eas.ProvisionResponse{
Status: int32(eas.StatusSuccess),
Policies: eas.PoliciesResponse{
Policy: []eas.PolicyResponse{{
PolicyType: eas.PolicyTypeWBXML,
PolicyKey: "final-key",
Status: int32(eas.StatusSuccess),
}},
},
})
}))
t.Cleanup(srv.Close)

c, err := New(Config{
BaseURL: srv.URL + EndpointPath,
HTTPClient: srv.Client(),
Auth: &BasicAuth{Username: "u", Password: "p"},
DeviceID: "DEV",
DeviceType: "Outlook",
})
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 calls != 2 {
t.Fatalf("calls = %d, want 2", calls)
}

if first.DeviceInformation == nil {
t.Fatal("initial ProvisionRequest.DeviceInformation is nil")
}
if second.DeviceInformation == nil {
t.Fatal("ack ProvisionRequest.DeviceInformation is nil")
}
got := first.DeviceInformation.Set
if got.Model != defaultDeviceModel {
t.Errorf("Model = %q", got.Model)
}
if got.FriendlyName != defaultDeviceModel {
t.Errorf("FriendlyName = %q", got.FriendlyName)
}
if got.OS != "iOS 17.5" {
t.Errorf("OS = %q", got.OS)
}
if got.OSLanguage != "ru" {
t.Errorf("OSLanguage = %q", got.OSLanguage)
}
if got.UserAgent != "Outlook-iOS-Android/1.0" {
t.Errorf("UserAgent = %q", got.UserAgent)
}
if got.MobileOperator != "Apple" {
t.Errorf("MobileOperator = %q", got.MobileOperator)
}
}
17 changes: 17 additions & 0 deletions client/cmd_provision.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
// one. The active PolicyKey is persisted in the configured PolicyStore.
func (c *Client) Provision(ctx context.Context, user string) (*eas.EASProvisionDoc, error) {
initial := eas.NewInitialRequest()
c.applyDeviceInformation(&initial)
var first eas.ProvisionResponse
if err := c.do(ctx, CmdProvision, user, &initial, &first); err != nil {
return nil, err
Expand All @@ -40,6 +41,7 @@ func (c *Client) Provision(ctx context.Context, user string) (*eas.EASProvisionD
}

ack := eas.NewAcknowledgeRequest(pol.PolicyKey, int32(eas.StatusSuccess))
c.applyDeviceInformation(&ack)
var second eas.ProvisionResponse
if err := c.do(ctx, CmdProvision, user, &ack, &second); err != nil {
return nil, err
Expand All @@ -62,3 +64,18 @@ func (c *Client) Provision(ctx context.Context, user string) (*eas.EASProvisionD
}
return pol.Data, nil
}

func (c *Client) applyDeviceInformation(req *eas.ProvisionRequest) {
req.DeviceInformation = &eas.DeviceInformation{
Set: eas.DeviceInformationSet{
Model: c.DeviceModel,
IMEI: c.DeviceIMEI,
FriendlyName: c.DeviceFriendlyName,
OS: c.DeviceOS,
OSLanguage: c.DeviceOSLanguage,
PhoneNumber: c.PhoneNumber,
UserAgent: c.DeviceUserAgent,
MobileOperator: c.Carrier,
},
Comment on lines +69 to +79
}
}
Loading
Loading