From 0cf3795902c0ddd673444d44ce282720d10d57da Mon Sep 17 00:00:00 2001 From: Neil Rashbrook Date: Fri, 27 Sep 2024 22:59:49 +0100 Subject: [PATCH] ActiveSync: Contacts --- .../Calendar/ActiveSync/ActiveSyncCalendar.ts | 12 +- .../ActiveSync/ActiveSyncAddressbook.ts | 123 +++++++++++++++++- .../Contacts/ActiveSync/ActiveSyncGroup.ts | 4 - .../Contacts/ActiveSync/ActiveSyncPerson.ts | 121 ++++++++++++++++- .../Mail/ActiveSync/ActiveSyncAccount.ts | 48 ++++++- app/logic/Mail/ActiveSync/WBXML.ts | 8 +- 6 files changed, 297 insertions(+), 19 deletions(-) delete mode 100644 app/logic/Contacts/ActiveSync/ActiveSyncGroup.ts diff --git a/app/logic/Calendar/ActiveSync/ActiveSyncCalendar.ts b/app/logic/Calendar/ActiveSync/ActiveSyncCalendar.ts index 3e085fa5..96c418ce 100644 --- a/app/logic/Calendar/ActiveSync/ActiveSyncCalendar.ts +++ b/app/logic/Calendar/ActiveSync/ActiveSyncCalendar.ts @@ -1,10 +1,18 @@ import { Calendar } from "../Calendar"; import { ActiveSyncEvent } from "./ActiveSyncEvent"; -import type { ActiveSyncAccount } from "../../Mail/ActiveSync/ActiveSyncAccount"; +import type { ActiveSyncAccount, ActiveSyncPingable } from "../../Mail/ActiveSync/ActiveSyncAccount"; -export class ActiveSyncCalendar extends Calendar { +export class ActiveSyncCalendar extends Calendar implements ActiveSyncPingable { readonly protocol: string = "calendar-activesync"; account: ActiveSyncAccount; + readonly folderClass = "Calendar"; + + get serverID() { + return new URL(this.url).searchParams.get("serverID"); + } + + async ping() { + } newEvent(parentEvent?: ActiveSyncEvent): ActiveSyncEvent { return new ActiveSyncEvent(this, parentEvent); diff --git a/app/logic/Contacts/ActiveSync/ActiveSyncAddressbook.ts b/app/logic/Contacts/ActiveSync/ActiveSyncAddressbook.ts index d7abee9c..1efbc479 100644 --- a/app/logic/Contacts/ActiveSync/ActiveSyncAddressbook.ts +++ b/app/logic/Contacts/ActiveSync/ActiveSyncAddressbook.ts @@ -1,16 +1,129 @@ import { Addressbook } from "../Addressbook"; +import { SQLAddressbook } from '../SQL/SQLAddressbook'; +import { SQLPerson } from '../SQL/SQLPerson'; import { ActiveSyncPerson } from "./ActiveSyncPerson"; -import { ActiveSyncGroup } from "./ActiveSyncGroup"; -import type { ActiveSyncAccount } from "../../Mail/ActiveSync/ActiveSyncAccount"; +import { EASError, type ActiveSyncAccount, type ActiveSyncPingable } from "../../Mail/ActiveSync/ActiveSyncAccount"; +import { kMaxCount, ensureArray } from "../../Mail/ActiveSync/ActiveSyncFolder"; +import { NotSupported } from "../../util/util"; +import type { ArrayColl } from "svelte-collections"; -export class ActiveSyncAddressbook extends Addressbook { +export class ActiveSyncAddressbook extends Addressbook implements ActiveSyncPingable { readonly protocol: string = "addressbook-activesync"; + readonly persons: ArrayColl; account: ActiveSyncAccount; + syncKeyBusy: Promise | null; + readonly folderClass = "Contacts"; + + get serverID() { + return new URL(this.url).searchParams.get("serverID"); + } + + async ping() { + await this.listContacts(); + } newPerson(): ActiveSyncPerson { return new ActiveSyncPerson(this); } - newGroup(): ActiveSyncGroup { - return new ActiveSyncGroup(this); + newGroup(): never { + throw new NotSupported("ActiveSync does not support distribution lists"); + } + + async queuedSyncRequest(data: any): Promise { + if (!this.syncState && !this.syncKeyBusy) try { + // First request must be an empty request. + this.syncKeyBusy = this.makeSyncRequest(); + await this.syncKeyBusy; + } finally { + this.syncKeyBusy = null; + } + while (this.syncKeyBusy) try { + await this.syncKeyBusy; + } catch (ex) { + // If the function currently holding the sync key throws, we don't care. + } + try { + this.syncKeyBusy = this.makeSyncRequest(data); + return await this.syncKeyBusy; + } finally { + this.syncKeyBusy = null; + } + } + + protected async makeSyncRequest(data?: any): Promise { + let request = { + Collections: { + Collection: Object.assign({ + SyncKey: this.syncState || "0", + CollectionId: this.serverID, + }, data), + }, + }; + let response = await this.account.callEAS("Sync", request); + if (!response) { + return null; + } + if (response.Collections.Collection.Status == "3") { + // Out of sync. + this.syncState = null; + await SQLAddressbook.save(this); + } + if (response.Collections.Collection.Status != "1") { + throw new EASError("Sync", response.Collections.Collection.Status); + } + this.syncState = response.Collections.Collection.SyncKey; + await SQLAddressbook.save(this); + return response.Collections.Collection; + } + + async listContacts() { + if (!this.dbID) { + await SQLAddressbook.save(this); + } + + let data = { + WindowSize: String(kMaxCount), + Options: { + BodyPreference: { + Type: "1", + }, + }, + }; + let response: any = { MoreAvailable: "" }; + while (response.MoreAvailable == "") { + response = await this.queuedSyncRequest(data); + if (!response) { + // No changes at all. + break; + } + for (let item of ensureArray(response.Commands?.Add).concat(ensureArray(response.Commands?.Change))) { + try { + let person = this.getPersonByServerID(item.ServerId); + if (person) { + person.fromWBXML(item.ApplicationData); + await SQLPerson.save(person); + } else { + person = this.newPerson(); + person.serverID = item.ServerId; + person.fromWBXML(item.ApplicationData); + await SQLPerson.save(person); + this.persons.add(person); + } + } catch (ex) { + this.account.errorCallback(ex); + } + } + for (let item of ensureArray(response.Commands?.Delete)) { + let person = this.getPersonByServerID(item.ServerId); + if (person) { + await SQLPerson.deleteIt(person); + } + } + } + this.account.addPingable(this); + } + + getPersonByServerID(id: string): ActiveSyncPerson | void { + return this.persons.find(p => p.serverID == id); } } diff --git a/app/logic/Contacts/ActiveSync/ActiveSyncGroup.ts b/app/logic/Contacts/ActiveSync/ActiveSyncGroup.ts deleted file mode 100644 index 22faf909..00000000 --- a/app/logic/Contacts/ActiveSync/ActiveSyncGroup.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Group } from '../../Abstract/Group'; - -export class ActiveSyncGroup extends Group { -} diff --git a/app/logic/Contacts/ActiveSync/ActiveSyncPerson.ts b/app/logic/Contacts/ActiveSync/ActiveSyncPerson.ts index 6573bda7..cfc5c541 100644 --- a/app/logic/Contacts/ActiveSync/ActiveSyncPerson.ts +++ b/app/logic/Contacts/ActiveSync/ActiveSyncPerson.ts @@ -1,4 +1,123 @@ -import { Person } from '../../Abstract/Person'; +import { Person, ContactEntry } from '../../Abstract/Person'; +import type { ActiveSyncAddressbook } from './ActiveSyncAddressbook'; +import { SQLPerson } from '../SQL/SQLPerson'; +import { EASError } from "../../Mail/ActiveSync/ActiveSyncAccount"; +import { assert } from "../../util/util"; +import { sanitize } from "../../../../lib/util/sanitizeDatatypes"; +import { parseOneAddress, type ParsedMailbox } from "email-addresses"; + +const PhysicalAddressElements = ["Street", "City", "PostalCode", "State", "Country"]; +const PhoneMapping: [string, string, number][] = [ + ["home", "tel", 2], + ["work", "tel", 2], + ["home", "fax", 1], + ["work", "fax", 1], + ["mobile", "tel", 1], +]; + +enum ContactElements { + home = "Home", + work = "Business", + other = "Other", + mobile = "Mobile", + tel = "Phone", + fax = "Fax", +}; export class ActiveSyncPerson extends Person { + addressbook: ActiveSyncAddressbook | null; + + get serverID() { + return this.id; + } + set serverID(val) { + this.id = val; + } + + fromWBXML(wbxmljs: any) { + this.firstName = sanitize.nonemptystring(wbxmljs.FirstName, ""); + this.lastName = sanitize.nonemptystring(wbxmljs.LastName, ""); + this.name = this.firstName ? this.lastName ? `${this.firstName} ${this.lastName}` : this.firstName : this.lastName; + this.emailAddresses.replaceAll([wbxmljs.Email1Address, wbxmljs.Email2Address, wbxmljs.Email3Address].filter(Boolean).map(address => new ContactEntry((parseOneAddress(address) as ParsedMailbox).address, "work", "mailto"))); + this.phoneNumbers.replaceAll(PhoneMapping.flatMap(([purpose, protocol, count]) => ["", "2"].slice(0, count).map(index => wbxmljs[`${ContactElements[purpose]}${index}${ContactElements[protocol]}Number`]).filter(Boolean).map(value => new ContactEntry(value, purpose, protocol)))); + this.chatAccounts.replaceAll([wbxmljs.IMAddress, wbxmljs.IMAddress2, wbxmljs.IMAddress3].filter(Boolean).map(address => new ContactEntry(address, "other"))); + this.streetAddresses.replaceAll(["home", "work", "other"].filter(purpose => PhysicalAddressElements.some(element => wbxmljs[`${ContactElements[purpose]}Address${element}`])).map(purpose => new ContactEntry(PhysicalAddressElements.map(element => sanitize.nonemptystring(wbxmljs[`${ContactElements[purpose]}Address${element}`], "")).join("\n"), purpose))); + this.notes = sanitize.nonemptystring(wbxmljs.Body?.Data, ""); + this.company = sanitize.nonemptystring(wbxmljs.CompanyName, ""); + this.department = sanitize.nonemptystring(wbxmljs.Department, ""); + this.position = sanitize.nonemptystring(wbxmljs.JobTitle, ""); + } + + async save() { + let fields: Record = { + FirstName: this.firstName, + LastName: this.lastName, + Body: { + Type: "1", + Data: this.notes || {}, // Special case for empty notes + }, + JobTitle: this.position || "", + Department: this.department || "", + CompanyName: this.company || "", + } + this.emailAddresses.contents.slice(0, 3).forEach((entry, i) => fields[`Email${i+1}Address`] = entry.value); + for (let [purpose, protocol, count] of PhoneMapping) { + this.phoneNumbers.contents.filter(entry => entry.purpose == purpose && (entry.protocol || "tel") == protocol).slice(0, count).forEach((entry, i) => fields[`${ContactElements[purpose]}${"2".slice(0, i)}${ContactElements[protocol]}Number`] = entry.value); + } + this.chatAccounts.contents.slice(0, 3).forEach((entry, i) => fields[`IMAddress${i ? i + 1 : ""}`] = entry.value); + for (let entry of this.streetAddresses) { + if (entry.purpose in ContactElements && entry.value) { + let values = entry.value.split("\n"); + assert(values.length == 5, "Street address must have exactly 5 lines: Street and house, City, ZIP Code, State, Country"); + PhysicalAddressElements.forEach((element, index) => fields[`${ContactElements[entry.purpose]}Address${element}`] = values[index]); + } + } + let data = this.serverID ? { + GetChanges: "0", + Commands: { + Change: { + ServerId: this.serverID, + ApplicationData: fields, + }, + }, + } : { + GetChanges: "0", + Commands: { + Add: { + ClientId: await this.addressbook.account.nextClientID(), + ApplicationData: fields, + }, + }, + }; + let response = await this.addressbook.queuedSyncRequest(data); + if (response.Responses) { + if (response.Responses.Change) { + throw new EASError("Sync", response.Responses.Change.Status); + } + if (response.Responses.Add) { + if (response.Responses.Add.Status != "1") { + throw new EASError("Sync", response.Responses.Add.Status); + } + this.serverID = response.Responses.Add.ServerId; + } + } + await SQLPerson.save(this); + } + + async deleteIt() { + let data = { + DeletesAsMoves: "1", + GetChanges: "0", + Commands: { + Delete: { + ServerId: this.serverID, + }, + }, + }; + let response = await this.addressbook.queuedSyncRequest(data); + if (response.Responses) { + throw new EASError("Sync", response.Responses.Delete.Status); + } + await super.deleteIt(); + } } diff --git a/app/logic/Mail/ActiveSync/ActiveSyncAccount.ts b/app/logic/Mail/ActiveSync/ActiveSyncAccount.ts index a6801c85..d46f0098 100644 --- a/app/logic/Mail/ActiveSync/ActiveSyncAccount.ts +++ b/app/logic/Mail/ActiveSync/ActiveSyncAccount.ts @@ -2,6 +2,8 @@ import { AuthMethod, MailAccount, TLSSocketType } from "../MailAccount"; import type { EMail } from "../EMail"; import { kMaxCount, ActiveSyncFolder, FolderType, ensureArray } from "./ActiveSyncFolder"; import { SMTPAccount } from "../SMTP/SMTPAccount"; +import { ActiveSyncAddressbook } from "../../Contacts/ActiveSync/ActiveSyncAddressbook"; +import { ActiveSyncCalendar } from "../../Calendar/ActiveSync/ActiveSyncCalendar"; import { OAuth2 } from "../../Auth/OAuth2"; import { OAuth2URLs } from "../../Auth/OAuth2URLs"; import { request2WBXML, WBXML2JSON } from "./WBXML"; @@ -70,6 +72,13 @@ export class ActiveSyncAccount extends MailAccount { } else { await this.verifyLogin(); } + + for (let addressbook of appGlobal.addressbooks) { + if (addressbook.protocol == "addressbook-activesync" && addressbook.url.startsWith(this.url + "?") && addressbook.username == this.emailAddress) { + (addressbook as ActiveSyncAddressbook).account = this; + await (addressbook as ActiveSyncAddressbook).listContacts(); + } + } } async logout(): Promise { @@ -285,7 +294,9 @@ export class ActiveSyncAccount extends MailAccount { async listFolders(): Promise { let response = await this.queuedRequest("FolderSync"); + let url = new URL(this.url); for (let change of ensureArray(response.Changes?.Add).concat(ensureArray(response.Changes?.Update))) { + url.searchParams.set("serverID", change.ServerId); switch (change.Type) { case FolderType.OtherSpecialFolder: case FolderType.Inbox: @@ -310,11 +321,31 @@ export class ActiveSyncAccount extends MailAccount { break; case FolderType.Calendar: case FolderType.UserCalendar: - // (Re)create calendar account here. + let calendar = appGlobal.calendars.find((calendar: ActiveSyncCalendar) => calendar.protocol == "addressbook-activesync" && calendar.url == url.toString() && calendar.username == this.emailAddress) as ActiveSyncCalendar | void; + if (calendar) { + calendar.name = change.DisplayName; + } else { + calendar = new ActiveSyncCalendar(); + calendar.name = change.DisplayName; + calendar.url = url.toString(); + calendar.username = this.emailAddress; + calendar.workspace = this.workspace; + appGlobal.calendars.add(calendar); + } break; case FolderType.Contacts: case FolderType.UserContacts: - // (Re)create contacts account here. + let addressbook = appGlobal.addressbooks.find((addressbook: ActiveSyncAddressbook) => addressbook.protocol == "addressbook-activesync" && addressbook.url == url.toString() && addressbook.username == this.emailAddress) as ActiveSyncAddressbook | void; + if (addressbook) { + addressbook.name = change.DisplayName; + } else { + addressbook = new ActiveSyncAddressbook(); + addressbook.name = change.DisplayName; + addressbook.url = url.toString(); + addressbook.username = this.emailAddress; + addressbook.workspace = this.workspace; + appGlobal.addressbooks.add(addressbook); + } break; } } @@ -325,7 +356,18 @@ export class ActiveSyncAccount extends MailAccount { await this.storage.deleteFolder(folder); folder.removeFromParent(); } - // Delete user calendar/contacts accounts. + let url = new URL(this.url); + url.searchParams.set("serverID", deletion.ServerId); + let addressbook = appGlobal.addressbooks.find((addressbook: ActiveSyncAddressbook) => addressbook.protocol == "addressbook-activesync" && addressbook.url == url.toString() && addressbook.username == this.emailAddress) as ActiveSyncAddressbook | void; + if (addressbook) { + this.removePingable(addressbook); + addressbook.deleteIt(); + } + let calendar = appGlobal.calendars.find((calendar: ActiveSyncCalendar) => calendar.protocol == "calendar-activesync" && calendar.url == url.toString() && calendar.username == this.emailAddress) as ActiveSyncCalendar | void; + if (calendar) { + this.removePingable(calendar); + calendar.deleteIt(); + } } } diff --git a/app/logic/Mail/ActiveSync/WBXML.ts b/app/logic/Mail/ActiveSync/WBXML.ts index 1ba592d4..6bbe566b 100644 --- a/app/logic/Mail/ActiveSync/WBXML.ts +++ b/app/logic/Mail/ActiveSync/WBXML.ts @@ -8,8 +8,8 @@ const kInlineCDATA = 0x0C3; const kHasContent = 0x40; const kTags = [ [,,,,, "Sync", "Responses", "Add", "Change", "Delete",, "SyncKey", "ClientId", "ServerId", "Status", "Collection", "Class",, "CollectionId", "GetChanges", "MoreAvailable", "WindowSize", "Commands", "Options",,,,, "Collections", "ApplicationData", "DeletesAsMoves",, "Supported",, "MIMESupport"], - [,,,,,,,, "Birthday",,,,, "BusinessAddressCity", "BusinessAddressCountry", "BusinessAddressPostalCode", "BusinessAddressState", "BusinessAddressStreet", "BusinessFaxNumber", "BusinessPhoneNumber",,,,,, "CompanyName", "Department", "Email1Address", "Email2Address",, "FileAs", "FirstName",, "HomeAddressCity", "HomeAddressCountry", "HomeAddressPostalCode", "HomeAddressState", "HomeAddressStreet",, "HomePhoneNumber", "JobTitle", "LastName",, "MobilePhoneNumber",,,,,,, "PagerNumber",,,,, "WebPage"], - [,,,,,,,,,,,,,,,"DateReceived",,"DisplayTo", "Importance", "MessageClass", "Subject", "Read", "To", "Cc", "From", "ReplyTo", "AllDayEvent", "Categories", "Category", "DtStamp", "EndTime", "InstanceType", "BusyStatus", "Location", "MeetingRequest", "Organizer", "RecurrenceId", "Reminder", "ResponseRequested", "Recurrences", "Recurrence", "Type", "Until", "Occurrences", "Interval", "DayOfWeek", "DayOfMonth", "WeekOfMonth", "MonthOfYear", "StartTime", "Sensitivity", "TimeZone", "GlobalObjId", "ThreadTopic",,,, "InternetCPID", "Flag", "Status", "ContentClass", "FlagType", "CompleteTime", "DisallowNewTimeProposal"], + [,,,,,,,, "Birthday",,,, "Business2PhoneNumber", "BusinessAddressCity", "BusinessAddressCountry", "BusinessAddressPostalCode", "BusinessAddressState", "BusinessAddressStreet", "BusinessFaxNumber", "BusinessPhoneNumber",,,,,, "CompanyName", "Department", "Email1Address", "Email2Address", "Email3Address", "FileAs", "FirstName", "Home2PhoneNumber", "HomeAddressCity", "HomeAddressCountry", "HomeAddressPostalCode", "HomeAddressState", "HomeAddressStreet",, "HomePhoneNumber", "JobTitle", "LastName",, "MobilePhoneNumber",, "OtherAddressCity", "OtherAddressCountry", "OtherAddressPostalCode", "OtherAddressState", "OtherAddressStreet", "PagerNumber",,,,, "WebPage"], + [,,,,,,,,,,,,,,, "DateReceived",, "DisplayTo", "Importance", "MessageClass", "Subject", "Read", "To", "Cc", "From", "ReplyTo", "AllDayEvent", "Categories", "Category", "DtStamp", "EndTime", "InstanceType", "BusyStatus", "Location", "MeetingRequest", "Organizer", "RecurrenceId", "Reminder", "ResponseRequested", "Recurrences", "Recurrence", "Type", "Until", "Occurrences", "Interval", "DayOfWeek", "DayOfMonth", "WeekOfMonth", "MonthOfYear", "StartTime", "Sensitivity", "TimeZone", "GlobalObjId", "ThreadTopic",,,, "InternetCPID", "Flag", "Status", "ContentClass", "FlagType", "CompleteTime", "DisallowNewTimeProposal"], [], [,,,,, "Timezone", "AllDayEvent", "Attendees", "Attendee", "Email", "Name",,, "BusyStatus", "Categories", "Category",, "DtStamp", "EndTime", "Exception", "Exceptions", "Deleted", "ExceptionStartTime", "Location", "MeetingStatus", "OrganizerEmail", "OrganizerName", "Recurrence", "Type", "Until", "Occurrences", "Interval", "DayOfWeek", "DayOfMonth", "WeekOfMonth", "MonthOfYear", "Reminder", "Sensitivity", "Subject", "StartTime", "UID", "AttendeeStatus", "AttendeeType",,,,,,,,, "DisallowNewTimeProposal", "ResponseRequested", "AppointmentReplyTime", "ResponseType", "CalendarType", "IsLeapMonth", "FirstDayOfWeek", "OnlineMeetingConfLink", "OnlineMeetingExternalLink"], [,,,,, "MoveItems", "Move", "SrcMsgId", "SrcFldId", "DstFldId", "Response", "Status", "DstMsgId"], @@ -19,9 +19,9 @@ const kTags = [ [,,,,,,,, "Categories", "Category", "Complete", "DateCompleted", "DueDate", "UtcDueDate", "Importance", "Recurrence", "Type", "Start", "Until", "Occurrences", "Interval", "DayOfMonth", "DayOfWeek", "WeekOfMonth", "MonthOfYear", "Regenerate", "DeadOccur", "ReminderSet", "ReminderTime", "Sensitivity", "StartDate", "UtcStartDate", "Subject",, "OrdinalDate", "SubOrdinalDate", "CalendarType", "IsLeapMonth", "FirstDayOfWeek"], [,,,,, "ResolveRecipients", "Response", "Status", "Type", "Recipient", "DisplayName", "EmailAddress",,,, "Options", "To",,,,,, "Availability", "StartTime", "EndTime", "MergedFreeBusy"], [], - [,,,,,,, "IMAddress",,,,,, "NickName"], + [,,,,,,, "IMAddress", "IMAddress2", "IMAddress3",,,, "NickName"], [,,,,, "Ping",, "Status", "HeartbeatInterval", "Folders", "Folder", "Id", "Class", "MaxFolders"], - [,,,,, "Provision", "Policies", "Policy", "PolicyType", "PolicyKey","Data", "Status", "RemoteWipe", "EASProvisionDoc", "DevicePasswordEnabled", "AlphanumericDevicePasswordRequired", "RequireStorageCardEncryption", "PasswordRecoveryEnabled",, "AttachmentsEnabled", "MinDevicePasswordLength", "MaxInactivityTimeDeviceLock", "MaxDevicePasswordFailedAttempts", "MaxAttachmentSize", "AllowSimpleDevicePassword", "DevicePasswordExpiration", "DevicePasswordHistory", "AllowStorageCard", "AllowCamera", "RequireDeviceEncryption", "AllowUnsignedApplications", "AllowUnsignedInstallationPackages", "MinDevicePasswordComplexCharacters", "AllowWiFi", "AllowTextMessaging", "AllowPOPIMAPEmail", "AllowBluetooth", "AllowIrDA", "RequireManualSyncWhenRoaming", "AllowDesktopSync", "MaxCalendarAgeFilter", "AllowHTMLEmail", "MaxEmailAgeFilter", "MaxEmailBodyTruncationSize", "MaxEmailHTMLBodyTruncationSize", "RequireSignedSMIMEMessages", "RequireEncryptedSMIMEMessages", "RequireSignedSMIMEAlgorithm", "RequireEncryptionSMIMEAlgorithm", "AlowSMIMEEncryptionAlgorithmNegotiation", "AllowSMIMESoftCerts", "AllowBrowser", "AllowConsumerEmail", "AllowRemoteDesktop", "AllowInternetSharing", "UnapprovedInROMApplicationList", "ApplicationName", "ApprovedApplicationList"], + [,,,,, "Provision", "Policies", "Policy", "PolicyType", "PolicyKey", "Data", "Status", "RemoteWipe", "EASProvisionDoc", "DevicePasswordEnabled", "AlphanumericDevicePasswordRequired", "RequireStorageCardEncryption", "PasswordRecoveryEnabled",, "AttachmentsEnabled", "MinDevicePasswordLength", "MaxInactivityTimeDeviceLock", "MaxDevicePasswordFailedAttempts", "MaxAttachmentSize", "AllowSimpleDevicePassword", "DevicePasswordExpiration", "DevicePasswordHistory", "AllowStorageCard", "AllowCamera", "RequireDeviceEncryption", "AllowUnsignedApplications", "AllowUnsignedInstallationPackages", "MinDevicePasswordComplexCharacters", "AllowWiFi", "AllowTextMessaging", "AllowPOPIMAPEmail", "AllowBluetooth", "AllowIrDA", "RequireManualSyncWhenRoaming", "AllowDesktopSync", "MaxCalendarAgeFilter", "AllowHTMLEmail", "MaxEmailAgeFilter", "MaxEmailBodyTruncationSize", "MaxEmailHTMLBodyTruncationSize", "RequireSignedSMIMEMessages", "RequireEncryptedSMIMEMessages", "RequireSignedSMIMEAlgorithm", "RequireEncryptionSMIMEAlgorithm", "AlowSMIMEEncryptionAlgorithmNegotiation", "AllowSMIMESoftCerts", "AllowBrowser", "AllowConsumerEmail", "AllowRemoteDesktop", "AllowInternetSharing", "UnapprovedInROMApplicationList", "ApplicationName", "ApprovedApplicationList"], [,,,,, "Search",, "Store", "Name", "Query", "Options", "Range", "Status", "Response", "Result", "Properties", "Total"], [,,,,, "DisplayName", "Phone", "Office", "Title", "Company", "Alias", "FirstName", "LastName", "HomePhone", "MobilePhone", "EmailAddress"], [,,,,, "BodyPreference", "Type", "TruncationSize",,, "Body", "Data", "EstimatedDataSize", "Truncated", "Attachments", "Attachment", "DisplayName", "FileReference", "Method", "ContentId", "ContentLocation", "IsInline", "NativeBodyType",, "Preview"],