diff --git a/README.md b/README.md index 22dcbc8..367c874 100644 --- a/README.md +++ b/README.md @@ -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, }) ``` diff --git a/client/client.go b/client/client.go index c2f3931..d3328cd 100644 --- a/client/client.go +++ b/client/client.go @@ -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 { @@ -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. @@ -56,7 +69,8 @@ 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 @@ -64,10 +78,9 @@ type Config struct { 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. @@ -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 } // New returns a Client populated with sensible defaults for any unset @@ -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 + } + 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() @@ -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 } @@ -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) } @@ -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 { @@ -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; diff --git a/client/client_extra_test.go b/client/client_extra_test.go index 55ae3e5..b6a4a51 100644 --- a/client/client_extra_test.go +++ b/client/client_extra_test.go @@ -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{ diff --git a/client/client_profile_test.go b/client/client_profile_test.go index ea01961..80fd8d1 100644 --- a/client/client_profile_test.go +++ b/client/client_profile_test.go @@ -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) + } +} diff --git a/client/cmd_provision.go b/client/cmd_provision.go index fbee08e..57ffc6e 100644 --- a/client/cmd_provision.go +++ b/client/cmd_provision.go @@ -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 @@ -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 @@ -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, + }, + } +} diff --git a/client/headers.go b/client/headers.go index 8aff887..b0cf13e 100644 --- a/client/headers.go +++ b/client/headers.go @@ -14,6 +14,16 @@ type HeaderOptions struct { AcceptLanguage string // optional, e.g. "en-US" } +// DeviceHeaderOptions describes optional first-class device profile headers. +type DeviceHeaderOptions struct { + DeviceModel string + DeviceOS string + DeviceOSLanguage string + Carrier string + PhoneNumber string + DeviceUserAgent string +} + // ApplyMandatoryHeaders sets the mandatory MS-ASHTTP headers on h. Any // existing values for managed headers are overwritten so the contract is // deterministic for upstream code. @@ -30,6 +40,28 @@ func ApplyMandatoryHeaders(h http.Header, opts HeaderOptions) { } } +// ApplyDeviceProfileHeaders sets optional device profile headers when present. +func ApplyDeviceProfileHeaders(h http.Header, opts DeviceHeaderOptions) { + if opts.DeviceModel != "" { + h.Set("X-MS-DeviceModel", opts.DeviceModel) + } + if opts.DeviceOS != "" { + h.Set("X-MS-DeviceOS", opts.DeviceOS) + } + if opts.DeviceOSLanguage != "" { + h.Set("X-MS-DeviceOSLanguage", opts.DeviceOSLanguage) + } + if opts.Carrier != "" { + h.Set("X-MS-DeviceCarrier", opts.Carrier) + } + if opts.PhoneNumber != "" { + h.Set("X-MS-DevicePhoneNumber", opts.PhoneNumber) + } + if opts.DeviceUserAgent != "" { + h.Set("X-MS-DeviceUserAgent", opts.DeviceUserAgent) + } +} + // 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 diff --git a/client/integration_test.go b/client/integration_test.go index 4d9f5d4..99b28aa 100644 --- a/client/integration_test.go +++ b/client/integration_test.go @@ -134,6 +134,86 @@ func TestProvision_TwoPhase(t *testing.T) { } } +// SPEC: MS-ASHTTP/client.query-encoding +func TestProvision_QueryEncodingPlainUsesURIParameters(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req eas.ProvisionRequest + decodeWBXML(t, r, &req) + calls++ + + q := r.URL.Query() + if got := q.Get("Cmd"); got != "Provision" { + t.Errorf("Cmd = %q, want Provision", got) + } + if got := q.Get("User"); got != "user@example.com" { + t.Errorf("User = %q, want user@example.com", got) + } + if got := q.Get("DeviceId"); got != "PLAINDEV1" { + t.Errorf("DeviceId = %q, want PLAINDEV1", got) + } + if got := q.Get("DeviceType"); got != "Outlook" { + t.Errorf("DeviceType = %q, want Outlook", got) + } + + switch calls { + case 1: + writeWBXML(t, w, &eas.ProvisionResponse{ + Status: int32(eas.StatusSuccess), + Policies: eas.PoliciesResponse{ + Policy: []eas.PolicyResponse{{ + PolicyType: eas.PolicyTypeWBXML, + PolicyKey: "plain-temp", + 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: + writeWBXML(t, w, &eas.ProvisionResponse{ + Status: int32(eas.StatusSuccess), + Policies: eas.PoliciesResponse{ + Policy: []eas.PolicyResponse{{ + PolicyType: eas.PolicyTypeWBXML, + PolicyKey: "plain-final", + Status: int32(eas.StatusSuccess), + }}, + }, + }) + default: + http.Error(w, "unexpected call", 500) + } + })) + t.Cleanup(srv.Close) + + c, err := New(Config{ + BaseURL: srv.URL + EndpointPath, + HTTPClient: srv.Client(), + DeviceID: "PLAINDEV1", + DeviceType: "Outlook", + QueryEncoding: QueryEncodingPlain, + }) + 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.Errorf("calls = %d, want 2", calls) + } +} + // SPEC: MS-ASHTTP/client.profile.extra-headers func TestFolderSync_OutgoingExtraHeaders(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -174,6 +254,55 @@ func TestFolderSync_OutgoingExtraHeaders(t *testing.T) { } } +// SPEC: MS-ASHTTP/client.profile.device-headers +func TestFolderSync_DeviceProfileHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req eas.FolderSyncRequest + decodeWBXML(t, r, &req) + want := map[string]string{ + "X-MS-DeviceModel": "Outlook for iOS and Android", + "X-MS-DeviceOS": "iOS 17.5", + "X-MS-DeviceOSLanguage": "ru", + "X-MS-DeviceCarrier": "Apple", + "X-MS-DevicePhoneNumber": "+70000000000", + "X-MS-DeviceUserAgent": "Outlook-iOS-Android/1.0", + } + for name, wantValue := range want { + if got := r.Header.Get(name); got != wantValue { + t.Errorf("%s = %q, want %q", name, got, wantValue) + } + } + writeWBXML(t, w, &eas.FolderSyncResponse{ + Status: int32(eas.StatusSuccess), + SyncKey: "FS-PROFILE", + }) + })) + t.Cleanup(srv.Close) + + extra := http.Header{ + "X-MS-DeviceModel": []string{"should-not-override-first-class-field"}, + } + c, err := New(Config{ + BaseURL: srv.URL + EndpointPath, + HTTPClient: srv.Client(), + DeviceID: "TESTDEVICE", + DeviceType: "Outlook", + DeviceModel: "Outlook for iOS and Android", + DeviceOS: "iOS 17.5", + DeviceOSLanguage: "ru", + Carrier: "Apple", + PhoneNumber: "+70000000000", + DeviceUserAgent: "Outlook-iOS-Android/1.0", + ExtraHeaders: extra, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, err := c.FolderSync(context.Background(), "user@example.com", "0"); err != nil { + t.Fatalf("FolderSync: %v", err) + } +} + // SPEC: MS-ASCMD/scenario.full // SPEC: MS-ASCMD/foldersync.response func TestFolderSync_Initial(t *testing.T) { diff --git a/client/request.go b/client/request.go index 7972ede..74566cb 100644 --- a/client/request.go +++ b/client/request.go @@ -58,6 +58,16 @@ type QueryParam struct { Value []byte } +// QueryEncoding selects the MS-ASHTTP request query representation. +type QueryEncoding string + +const ( + // QueryEncodingBase64 uses the compact binary query encoded as URL-safe base64. + QueryEncodingBase64 QueryEncoding = "base64" + // QueryEncodingPlain uses Cmd=...&User=...&DeviceId=...&DeviceType=... URI parameters. + QueryEncodingPlain QueryEncoding = "plain" +) + // Query is the abstract representation of the MS-ASHTTP request query, used // in both base64 (binary) and plain (URL key=value) encodings. type Query struct { @@ -175,37 +185,53 @@ func ParseBase64(s string) (Query, error) { // EncodePlain returns the URL-encoded plain query (Cmd=Foo&User=...&...). func (q Query) EncodePlain() string { - v := url.Values{} - v.Set("Cmd", commandName(q.Cmd)) - v.Set("DeviceId", q.DeviceID) - v.Set("DeviceType", q.DeviceType) + type pair struct { + name string + value string + } + user := "" + params := make([]pair, 0, len(q.Params)) for _, p := range q.Params { switch p.Tag { case ParamUser: - v.Set("User", string(p.Value)) + user = string(p.Value) case ParamCollectionID: - v.Set("CollectionId", string(p.Value)) + params = append(params, pair{"CollectionId", string(p.Value)}) case ParamCollectionName: - v.Set("CollectionName", string(p.Value)) + params = append(params, pair{"CollectionName", string(p.Value)}) case ParamItemID: - v.Set("ItemId", string(p.Value)) + params = append(params, pair{"ItemId", string(p.Value)}) case ParamLongID: - v.Set("LongId", string(p.Value)) + params = append(params, pair{"LongId", string(p.Value)}) case ParamParentID: - v.Set("ParentId", string(p.Value)) + params = append(params, pair{"ParentId", string(p.Value)}) case ParamOccurrence: - v.Set("Occurrence", string(p.Value)) + params = append(params, pair{"Occurrence", string(p.Value)}) case ParamOptions: - v.Set("Options", string(p.Value)) + params = append(params, pair{"Options", string(p.Value)}) case ParamSaveInSent: - v.Set("SaveInSent", string(p.Value)) + params = append(params, pair{"SaveInSent", string(p.Value)}) case ParamAttachmentName: - v.Set("AttachmentName", string(p.Value)) + params = append(params, pair{"AttachmentName", string(p.Value)}) case ParamAcceptMultipart: - v.Set("AcceptMultiPart", string(p.Value)) + params = append(params, pair{"AcceptMultiPart", string(p.Value)}) } } - return v.Encode() + + parts := make([]string, 0, 4+len(params)) + add := func(name, value string) { + parts = append(parts, url.QueryEscape(name)+"="+url.QueryEscape(value)) + } + add("Cmd", commandName(q.Cmd)) + if user != "" { + add("User", user) + } + add("DeviceId", q.DeviceID) + add("DeviceType", q.DeviceType) + for _, p := range params { + add(p.name, p.value) + } + return strings.Join(parts, "&") } func commandName(code byte) string { diff --git a/client/request_extra_test.go b/client/request_extra_test.go index 7ec13bb..f6669ef 100644 --- a/client/request_extra_test.go +++ b/client/request_extra_test.go @@ -117,6 +117,9 @@ func TestQueryPlain_AllParamTags(t *testing.T) { }, } got := q.EncodePlain() + if wantPrefix := "Cmd=Sync&User=u&DeviceId=id&DeviceType=PC"; !strings.HasPrefix(got, wantPrefix) { + t.Fatalf("plain query prefix = %q, want prefix %q", got, wantPrefix) + } for _, want := range []string{"User=u", "CollectionId=c", "CollectionName=cn", "ItemId=i", "LongId=l", "ParentId=p", "Occurrence=o", "Options=opt", "SaveInSent=s", "AttachmentName=a", "AcceptMultiPart=am"} { diff --git a/eas/provision.go b/eas/provision.go index 4b47fb2..06d813e 100644 --- a/eas/provision.go +++ b/eas/provision.go @@ -8,8 +8,28 @@ const PolicyTypeWBXML = "MS-EAS-Provisioning-WBXML" // element shape is used both for the initial download request and for the // acknowledgement step (MS-ASPROV §3.2.5.1). type ProvisionRequest struct { - XMLName struct{} `wbxml:"Provision.Provision"` - Policies PoliciesRequest `wbxml:"Provision.Policies"` + XMLName struct{} `wbxml:"Provision.Provision"` + DeviceInformation *DeviceInformation `wbxml:"Settings.DeviceInformation,omitempty"` + Policies PoliciesRequest `wbxml:"Provision.Policies"` +} + +// DeviceInformation carries the Settings:DeviceInformation payload some +// Exchange policies require during Provision (status 165). +type DeviceInformation struct { + Set DeviceInformationSet `wbxml:"Settings.Set"` +} + +// DeviceInformationSet is the Settings:Set content nested inside +// DeviceInformation. +type DeviceInformationSet struct { + Model string `wbxml:"Settings.Model,omitempty"` + IMEI string `wbxml:"Settings.IMEI,omitempty"` + FriendlyName string `wbxml:"Settings.FriendlyName,omitempty"` + OS string `wbxml:"Settings.OS,omitempty"` + OSLanguage string `wbxml:"Settings.OSLanguage,omitempty"` + PhoneNumber string `wbxml:"Settings.PhoneNumber,omitempty"` + UserAgent string `wbxml:"Settings.UserAgent,omitempty"` + MobileOperator string `wbxml:"Settings.MobileOperator,omitempty"` } // PoliciesRequest wraps the Policy entries inside a ProvisionRequest. diff --git a/internal/spec/coverage.csv b/internal/spec/coverage.csv index a8e4a25..3aab743 100644 --- a/internal/spec/coverage.csv +++ b/internal/spec/coverage.csv @@ -110,3 +110,7 @@ MS-ASCMD/retry.142,MS-ASCMD,§2.2.4,Status 142 triggers automatic re-provision w MS-ASCMD/scenario.full,MS-ASCMD,§3.1,End-to-end Provision -> FolderSync -> Sync -> Ping scenario over HTTP exchanges WBXML payloads with the negotiated PolicyKey and SyncKey,required MS-ASHTTP/client.profile.extra-headers,MS-ASHTTP,§2.2.2,Config.ExtraHeaders are merged after mandatory MS-ASHTTP headers without overwriting any header key already present on the request,required MS-ASHTTP/client.profile.force-http11,MS-ASHTTP,§2.2.1,When HTTPClient is nil and ForceHTTP11 is true New configures a client transport cloned from DefaultTransport with TLSNextProto set to a non-nil empty map to disable HTTP/2,required +MS-ASHTTP/client.query-encoding,MS-ASHTTP,§2.2.1.1.1,Config.QueryEncoding selects base64 or plain text query encoding while defaulting to base64,required +MS-ASHTTP/client.profile.device-headers,MS-ASHTTP,§2.2.2,Config device profile fields emit X-MS-DeviceModel; X-MS-DeviceOS; X-MS-DeviceOSLanguage; X-MS-DeviceCarrier; X-MS-DevicePhoneNumber; X-MS-DeviceUserAgent when set,required +MS-ASCMD/status.165.device-information-required,MS-ASCMD,§2.2.4,Status 165 indicates the client must send DeviceInformation during Provision,required +MS-ASPROV/provision.device-information,MS-ASPROV,§3.2.5.1,Provision requests carry Settings DeviceInformation with client model OS language user-agent and mobile operator metadata,required