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:
- Open https://outlook.office365.com/people/ in a browser and sign in with your O365 account
- Open browser's console
- Paste this script
- Run one of the functions, suggestion is to test with OwaGalExtractor.downloadGal(null, 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); | |
} | |
*/ |
Top comments (0)