Skip to content

Instantly share code, notes, and snippets.

@milolav
Last active May 22, 2024 12:04
Show Gist options
  • Save milolav/11f41263f3c9653a97b3530d42bafccd to your computer and use it in GitHub Desktop.
Save milolav/11f41263f3c9653a97b3530d42bafccd to your computer and use it in GitHub Desktop.
Download Global Address List (GAL) entries as .vcf files from browser. With photos too. Can be used with Puppeteer (headless Chrome) to integrate with other systems. Doesn’t require any administrative privileges. Log in to https://outlook.office.com/people paste into console window and run one of the functions at the bottom.
/*!
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 <[email protected]> (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 [email protected] vCard
OwaGalExtractor.downloadGal('[email protected]');
*** 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);
}
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment