Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ActiveSync: Contacts #221

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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
12 changes: 10 additions & 2 deletions app/logic/Calendar/ActiveSync/ActiveSyncCalendar.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
123 changes: 118 additions & 5 deletions app/logic/Contacts/ActiveSync/ActiveSyncAddressbook.ts
Original file line number Diff line number Diff line change
@@ -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<ActiveSyncPerson>;
account: ActiveSyncAccount;
syncKeyBusy: Promise<any> | 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<any> {
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<any> {
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);
}
}
4 changes: 0 additions & 4 deletions app/logic/Contacts/ActiveSync/ActiveSyncGroup.ts

This file was deleted.

121 changes: 120 additions & 1 deletion app/logic/Contacts/ActiveSync/ActiveSyncPerson.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | { Type: string, Data: string | {} }> = {
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();
}
}
48 changes: 45 additions & 3 deletions app/logic/Mail/ActiveSync/ActiveSyncAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> {
Expand Down Expand Up @@ -285,7 +294,9 @@ export class ActiveSyncAccount extends MailAccount {

async listFolders(): Promise<void> {
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:
Expand All @@ -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;
}
}
Expand All @@ -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();
}
}
}

Expand Down
Loading