Last active
May 22, 2024 12:04
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/*! | |
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