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
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. Header-backed profile fields are sent as `X-MS-Device*` headers and, when any profile field is set, in the Provision `DeviceInformation` body; `DeviceFriendlyName` and `DeviceIMEI` are body-only. 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
104 changes: 86 additions & 18 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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 +67,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 +89,22 @@ 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 in the Provision DeviceInformation body
// when at least one field is set. Header-backed fields are also sent as
// X-MS-Device* request headers.
DeviceModel string
DeviceOS string
DeviceOSLanguage string
Carrier string
PhoneNumber string
DeviceUserAgent string
DeviceFriendlyName string
DeviceIMEI string
}

// New returns a Client populated with sensible defaults for any unset
Expand All @@ -92,18 +119,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 +122 to +125
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 Down Expand Up @@ -197,11 +242,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 +260,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 +299,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
Loading
Loading