DEV Community

Miro
Miro

Posted on

1

Downloading GAL as individual vCards using OWA

Some time ago, a friend asked me about syncing Exchanges's GAL (Global Address List) with a mobile phone. So I started looking into Exchange Web Services, but as it turns out I need GAL id in order to query it. And to get it I need either more privileged access to Office 365 tenant or snoop OWA's traffic as described in the article: https://gsexdev.blogspot.com/2013/05/using-ews-findpeople-operation-in.html

Looking at the network log in a browser I figured that I could effectively do everything within a browser ran from Puppeteer and send it to Radicale CardDAV server.

But as it turned out, one required field, "Pager", was not present in Exchange's Persona object. So good old "copy everything from GAL into Contacts" worked, at least for one-off sync, and this idea was abandoned.

Here is JavaScript PoC that can be used to download GAL as vCard files. Bear in mind that it doesn't have any error handling and might not work in every browser. It works in Chrome. Generated vCards are not 100% by specification since I'm serializing whole persona object into it, but I haven't had any issue opening it in Windows (People app), Outlook or on iOS/Android phones.

Before posting this I just cleaned up code a bit and replaced XHR with fetch.

How to use in 5 steps:

  1. Open https://outlook.office365.com/people/ in a browser and sign in with your O365 account
  2. Open browser's console
  3. Paste this script
  4. Run one of the functions, suggestion is to test with OwaGalExtractor.downloadGal(null, 5)
  5. Look at a bunch of file being downloaded (chrome might ask you to allow multiple file download)
/*!
OwaGalExtractor
Copyright (c) 2019-2020 milolav
Released under the MIT License
*/
; (function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
global.OwaGalExtractor = factory()
}(this, (function () {
'use strict';
//#region Standard utility functions
function base64enc(bytes) {
//Copyright (c) 2012 Niklas von Hertzen
//https://github.com/niklasvh/base64-arraybuffer
var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var lookup = new Uint8Array(256);
for (var i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
let len = bytes.length;
let base64 = "";
for (let i = 0; i < len; i += 3) {
base64 += chars[bytes[i] >> 2];
base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
base64 += chars[bytes[i + 2] & 63];
}
if ((len % 3) === 2) {
base64 = base64.substring(0, base64.length - 1) + "=";
} else if (len % 3 === 1) {
base64 = base64.substring(0, base64.length - 2) + "==";
}
return base64;
};
function fnv1a(string) {
//Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
//https://github.com/sindresorhus/fnv1a/
const bytes = new TextEncoder().encode(string); //fix for multibyte strings
let hash = 2166136261;
for (let i = 0; i < bytes.length; i++) {
hash ^= bytes[i];
// 32-bit FNV prime: 2**24 + 2**8 + 0x93 = 16777619
// Using bitshift for accuracy and performance. Numbers in JS suck.
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return hash >>> 0;
}
function downloadFile(fileName, data, type) {
var blob = new Blob([data], { type: type });
var a = document.createElement('a');
a.rel = 'noopener';
a.download = fileName;
a.href = URL.createObjectURL(blob);
a.click();
}
//#endregion
//#region OWA Utility functions
function getOwaCanaryCookie() {
//Copyright (c) 2018 Copyright 2018 Klaus Hartl, Fagner Brack, GitHub Contributors
//https://github.com/js-cookie/js-cookie
const cookies = document.cookie ? document.cookie.split('; ') : [];
for (let i = 0; i < cookies.length; i++) {
let parts = cookies[i].split('=');
let name = parts[0];
let cookie = parts.slice(1).join('=');
if (name.toLowerCase() === 'x-owa-canary') {
return cookie;
}
}
}
async function postAction(action, canaryCookie, data = {}) {
const response = await fetch(`/owa/service.svc?action=${action}`, {
method: 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Action': action,
'X-OWA-CANARY': canaryCookie
},
referrerPolicy: 'no-referrer',
body: JSON.stringify(data)
});
return await response.json();
}
function buildFindPeopleJsonRequest(listId, queryString = null, maxEntriesReturned = 1000) {
return {
'__type': 'FindPeopleJsonRequest:#Exchange',
'Header': null,
'Body': {
'__type': 'FindPeopleRequest:#Exchange',
'IndexedPageItemView': {
'__type': 'IndexedPageView:#Exchange',
'BasePoint': 'Beginning',
'Offset': 0,
'MaxEntriesReturned': maxEntriesReturned
},
'QueryString': queryString,
'ParentFolderId': {
'__type': 'TargetFolderId:#Exchange',
'BaseFolderId': {
'__type': 'AddressListId:#Exchange',
'Id': listId
}
},
'PersonaShape': {
'__type': 'PersonaResponseShape:#Exchange',
'BaseShape': 'IdOnly'
}
}
}
}
function buildGetPersonaJsonRequest(personaId) {
return {
'__type': 'GetPersonaJsonRequest:#Exchange',
'Header': null,
'Body': {
'__type': 'GetPersonaRequest:#Exchange',
'PersonaId': {
'__type': 'ItemId:#Exchange',
'Id': personaId
}
}
}
}
function extractGlobalAddressListId(peopleFilters) {
for (let i = 0; i < peopleFilters.length; i++) {
if (peopleFilters[i].DisplayName == "Default Global Address List") {
return peopleFilters[i].FolderId.Id;
}
}
}
function extractPersonaEmailAddress(persona) {
return persona.EmailAddress.EmailAddress;
}
async function getPeopleFilters() {
return await postAction('GetPeopleFilters', OWA_CANARY);
}
async function findPeople(listId, query, maxEntriesReturned) {
const req = buildFindPeopleJsonRequest(listId, query || null, maxEntriesReturned || 1000);
return await postAction('FindPeople', OWA_CANARY, req);
}
async function getPersona(personaId) {
const req = buildGetPersonaJsonRequest(personaId);
return await postAction('GetPersona', OWA_CANARY, req);
}
async function getPersonaPhoto(email) {
const response = await fetch('/owa/service.svc/s/GetPersonaPhoto?email=' + encodeURIComponent(email) + '&size=HR648x648', {
method: 'GET',
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
referrerPolicy: 'no-referrer'
});
return new Uint8Array(await response.arrayBuffer());
}
//#endregion
//#region vCard creation function
function makevCard(entry) {
var p = entry.persona;
var vcard = [
'BEGIN:VCARD',
'VERSION:3.0',
];
if (p.Surname || p.GivenName) {
vcard.push('N:' + (p.Surname || '') + ';' + (p.GivenName || ''));
}
if (p.DisplayName) {
vcard.push('FN:' + p.DisplayName);
}
if (p.CompanyName || p.Department) {
vcard.push('ORG:' + (p.CompanyName || '') + ';' + (p.Department || ''));
}
if (p.Title) {
vcard.push('TITLE:' + p.Title);
}
if (p.EmailAddress) {
vcard.push('EMAIL:' + p.EmailAddress.EmailAddress);
}
if (p.MobilePhonesArray) {
vcard.push('TEL;TYPE=CELL,VOICE:' + p.MobilePhonesArray[0].Value.NormalizedNumber);
}
if (p.BusinessPhoneNumbersArray) {
vcard.push('TEL;TYPE=WORK,VOICE:' + p.BusinessPhoneNumbersArray[0].Value.NormalizedNumber);
}
if (p.HomePhonesArray) {
vcard.push('TEL;TYPE=HOME,VOICE:' + p.HomePhonesArray[0].Value.NormalizedNumber);
}
if (p.WorkFaxesArray) {
vcard.push('TEL;TYPE=WORK,FAX:' + p.WorkFaxesArray[0].Value.NormalizedNumber);
}
if (p.BusinessAddressesArray) {
var v = p.BusinessAddressesArray[0].Value;
var addrLabel = '';
if (v.Street) { addrLabel += v.Street; }
if (v.PostalCode) { addrLabel += '\\n' + v.PostalCode; }
if (v.City) { addrLabel += ((v.PostalCode) ? ' ' : '\\n') + v.City; }
if (v.Country) { addrLabel += "\\n" + v.Country };
vcard.push('ADR;TYPE=WORK;LABEL="' + addrLabel.trim() + '":' + (v.PostOfficeBox || '') + ';;' + (v.Street || '') + ';' + (v.City || '') + ';' + (v.State || '') + ';' + (v.PostalCode || '') + ';' + (v.Country || ''));
}
if (entry.photo !== null) {
vcard.push('PHOTO;TYPE=JPEG;ENCODING=BASE64:' + entry.photo);
}
vcard.push('X-OWA-PERSONAID:' + p.PersonaId.Id);
vcard.push('X-OWA-ADOBJECTID:' + p.ADObjectId);
vcard.push('X-OWA-SRC-PERSONA:' + JSON.stringify(entry.persona));
vcard.push('X-OWA-HASH:' + fnv1a(JSON.stringify(entry)).toString(16));
vcard.push('REV:' + (new Date()).toISOString().replace(/[-:.]/gi, ''));
vcard.push('UID:' + p.PersonaId.Id);
vcard.push('END:VCARD');
return vcard.join("\r\n");
}
//#endregion
//#region exported functions
async function retrieveSinglePersona(personaId, skipPhoto = false) {
const personaResponse = await getPersona(personaId);
if (personaResponse.Body.ResponseClass != "Success") {
console.error('Error getting persona: ' + personaId + "\r\n" + JSON.stringify(personaResponse));
return null;
}
let photo = null;
if (!skipPhoto) {
photo = base64enc(await getPersonaPhoto(extractPersonaEmailAddress(personaResponse.Body.Persona)));
if (photo.length < 100) { photo = null; }
}
return {
persona: personaResponse.Body.Persona,
photo: photo
}
}
async function* retrieveGal(query, maxEntriesReturned) {
const listId = extractGlobalAddressListId(await getPeopleFilters());
const findPeopleResponse = await findPeople(listId, query, maxEntriesReturned);
const peopleList = findPeopleResponse.Body.ResultSet;
for (let i = 0; i < peopleList.length; i++) {
yield await retrieveSinglePersona(peopleList[i].PersonaId.Id);
}
}
async function downloadGal(query, maxEntriesReturned) {
const personas = retrieveGal(query, maxEntriesReturned);
for await (const p of personas) {
if (p == null) { continue; }
let email = p.persona.EmailAddress.EmailAddress;
let vcard = makevCard(p);
downloadFile(email + '.vcf', vcard, 'text/vcard');
}
}
//#endregion
const OWA_CANARY = getOwaCanaryCookie();
return {
retrieveSinglePersona: retrieveSinglePersona,
retrieveGal: retrieveGal,
downloadGal: downloadGal
};
})));
/*
How to use in 5 steps:
1. Open https://outlook.office365.com/people/ in a browser and sign in with your O365 account
2. Open browser's console
3. Paste this script
4. Run one of the functions, suggestion is to test with OwaGalExtractor.downloadGal(null, 5)
5. Look at a bunch of file being downloaded (chrome might ask to allow multiple download)
*** download first 1000 entries Global Address List as separate vCards
OwaGalExtractor.downloadGal();
*** download first 5 entries from Global Address List as separate vCards
OwaGalExtractor.downloadGal(null, 5);
*** download user@email.com vCard
OwaGalExtractor.downloadGal('user@email.com');
*** retrieve single persona with id ABCDEFGHIJKLMNOPQRSTUVWYZ without photo (skipPhoto = true)
OwaGalExtractor.retrieveSinglePersona('ABCDEFGHIJKLMNOPQRSTUVWYZ', true);
*** user defined persona processing from GAL
const personas = OwaGalExtractor.retrieveGal();
for await (const p of personas) {
if (p == null) { continue; }
console.log(p.persona.EmailAddress.EmailAddress);
}
*/

Heroku

Simplify your DevOps and maximize your time.

Since 2007, Heroku has been the go-to platform for developers as it monitors uptime, performance, and infrastructure concerns, allowing you to focus on writing code.

Learn More

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay