Skip to content

Instantly share code, notes, and snippets.

@EionRobb
Forked from lart2150/roamingSignature.ts
Last active March 3, 2026 11:21
Show Gist options
  • Select an option

  • Save EionRobb/21dd89b05417b247bface6373fb380fb to your computer and use it in GitHub Desktop.

Select an option

Save EionRobb/21dd89b05417b247bface6373fb380fb to your computer and use it in GitHub Desktop.
Office 365 Roaming Signature
<?php
// Create a new roaming signature for a user
// To configure:
// Create a new Enterprise Application at https://portal.azure.com
// Make sure it has the API permissions for Microsoft Graph to be:
// Directory.Read.All
// email
// EWS.AccessAsUser.All
// Files.Read.All
// GroupMember.Read.All
// Mail.ReadWrite
// MailboxSettings.ReadWrite
// offline_access
// openid
// profile
// User.Read.All
// and grant admin consent
// Then get the client ID, client secret, and tenant ID from the application
// and set them as environment variables
// OAUTH_CLIENT_ID
// OAUTH_CLIENT_SECRET
// OAUTH_CLIENT_TENANT
// Based on https://gist.github.com/lart2150/8b4707a933bb70377f1594e4c658df39
class RoamingSignatureManager {
private $accessToken;
private $refreshToken;
private $outlookAccessToken;
private static $TIMESTAMP_BASE_TICKS = 621355968000000000; // Ticks between 0001-01-01 and 1970-01-01
public function __construct() {
$this->accessToken = $this->getAccessToken();
}
private function getTimestampTicks() {
$microtime = microtime(true);
$ticks = (int)($microtime * 10000000) + self::$TIMESTAMP_BASE_TICKS;
return $ticks;
}
/**
* Get OAuth access token using authorization code flow
*/
private function getAccessToken() {
$tenantId = $_ENV['OAUTH_CLIENT_TENANT'];
$clientId = $_ENV['OAUTH_CLIENT_ID'];
$clientSecret = $_ENV['OAUTH_CLIENT_SECRET'];
$redirectUri = 'http://localhost:56523';
if (empty($tenantId) || empty($clientId) || empty($clientSecret)) {
throw new Exception('Missing OAuth credentials. Please set OAUTH_CLIENT_TENANT, OAUTH_CLIENT_ID, and OAUTH_CLIENT_SECRET environment variables.');
}
$scope = 'https://graph.microsoft.com/.default openid profile offline_access';
$authUrl = "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?" . http_build_query([
'client_id' => $clientId,
'response_type' => 'code',
'redirect_uri' => $redirectUri,
'scope' => $scope,
'client-request-id' => 'e9865a68-7cb4-4fa4-8cab-d964573492c9'
]);
echo "Starting local server for OAuth callback on {$redirectUri}...\n";
$server = stream_socket_server("tcp://127.0.0.1:56523", $errno, $errorMessage);
if ($server === false) {
throw new Exception("Could not bind to socket: $errorMessage");
}
echo "Opening browser to $authUrl\n";
// Attempt to open the default browser on Windows
pclose(popen('start "" "' . str_replace('&', '&', $authUrl) . '"', 'r'));
echo "Waiting for authentication in browser...\n";
$client = stream_socket_accept($server, 300); // 5 minute timeout
$code = null;
if ($client) {
$request = fread($client, 2048);
preg_match('/GET \/\?.*?code=([^ &\r\n]+)/', $request, $matches);
$code = $matches[1] ?? null;
$response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n";
if ($code) {
$response .= "<h1>Authentication Successful</h1><p>You can close this window and return to the application.</p><script>setTimeout(function(){window.close();}, 1500);</script>";
} else {
$response .= "<h1>Authentication Failed</h1><p>No code parameter found in the request. Please try again.</p>";
}
fwrite($client, $response);
fclose($client);
} else {
fclose($server);
throw new Exception("Timed out waiting for authentication.");
}
fclose($server);
if (!$code) {
throw new Exception("Did not receive authorization code.");
}
echo "Exchanging authorization code for token...\n";
$tokenUrl = "https://login.microsoftonline.com/organizations/oauth2/v2.0/token";
$postData = http_build_query([
'client_id' => $clientId,
'scope' => $scope,
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $redirectUri
]);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/x-www-form-urlencoded',
'content' => $postData,
'proxy' => 'localhost:8888',
],
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
]
]);
$response = file_get_contents($tokenUrl, false, $context);
if ($response === false) {
throw new Exception('Failed to get access token');
}
$tokenData = json_decode($response, true);
if (!isset($tokenData['access_token'])) {
throw new Exception('Invalid token response: ' . $response);
}
$this->refreshToken = $tokenData['refresh_token'];
$this->accessToken = $tokenData['access_token'];
$scope = 'https://outlook.office.com/.default offline_access openid profile';
$postData = http_build_query([
'client_id' => $clientId,
'scope' => $scope,
'grant_type' => 'refresh_token',
'refresh_token' => $this->refreshToken,
'client_info' => 1,
]);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/x-www-form-urlencoded',
'content' => $postData,
'proxy' => 'localhost:8888',
],
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
]
]);
$response = file_get_contents($tokenUrl, false, $context);
if ($response === false) {
throw new Exception('Failed to get access token');
}
$tokenData = json_decode($response, true);
if (!isset($tokenData['access_token'])) {
throw new Exception('Invalid token response: ' . $response);
}
$this->outlookAccessToken = $tokenData['access_token'];
return $this->accessToken;
}
/**
* Get all users from Microsoft Graph API
*/
public function getUsers() {
$url = "https://graph.microsoft.com/v1.0/users?\$filter=" . rawurlencode('accountEnabled eq true and not(employeeId eq null)');
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => [
"Authorization: Bearer {$this->accessToken}",
'ConsistencyLevel: eventual'
],
'proxy' => 'localhost:8888',
],
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
]
]);
$response = file_get_contents($url, false, $context);
if ($response === false) {
throw new Exception('Failed to get users from Microsoft Graph');
}
$data = json_decode($response, true);
if (!isset($data['value'])) {
throw new Exception('Invalid users response: ' . $response);
}
return $data['value'];
}
public function getUser($email) {
$url = "https://graph.microsoft.com/v1.0/users/{$email}?\$select=businessPhones%2Ccity%2CcompanyName%2Ccountry%2Cdepartment%2CdisplayName%2CfaxNumber%2CgivenName%2CjobTitle%2Cmail%2CmailNickname%2CmobilePhone%2CofficeLocation%2ConPremisesExtensionAttributes%2CpostalCode%2CproxyAddresses%2Cstate%2CstreetAddress%2Csurname%2Cid%2ConPremisesDistinguishedName%2ConPremisesDomainName%2ConPremisesImmutableId%2ConPremisesSamAccountName%2ConPremisesSecurityIdentifier%2ConPremisesUserPrincipalName%2CsecurityIdentifier%2CuserPrincipalName&\$expand=manager(\$select=id,displayName)";
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => [
"Authorization: Bearer {$this->accessToken}",
'ConsistencyLevel: eventual'
],
'proxy' => 'localhost:8888',
],
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
]
]);
$response = file_get_contents($url, false, $context);
if ($response === false) {
throw new Exception('Failed to get user from Microsoft Graph');
}
$data = json_decode($response, true);
return $data;
}
/**
* Generate signature text format
*/
private function generateSignatureText($user) {
$businessPhone = isset($user['businessPhones'][0]) ? $user['businessPhones'][0] : '';
return "{$user['displayName']}\n" .
"{$user['jobTitle']}\n" .
"{$businessPhone}\n" .
"{$user['mail']}";
}
/**
* Generate signature HTML format
*/
private function generateSignatureHtml($user) {
$businessPhone = isset($user['businessPhones'][0]) ? $user['businessPhones'][0] : '';
return '
<p style="padding: 5px 10px 0 0; margin: 0; color: #254F59; font-family: Arial; font-size: 17px; font-weight: bold; text-transform: uppercase"><span style="color: #254F59;">' . htmlspecialchars($user['displayName']) . '</span></p>
<p style="padding: 0px 10px 4px 0; margin: 0; color: #262626; font-family: Arial; font-size: 13px; font-weight: normal; line-height: 1.6; text-align: left; vertical-align: top;"><span style="color: #254F59;">' . htmlspecialchars($user['jobTitle']) . '</span></p>
<p style="padding: 6px 0 5px 0; margin: 0; color: #000000; font-family: Arial; font-size: 13px; font-weight: normal;">
' . htmlspecialchars($businessPhone) . '<br>
<a href="mailto:' . htmlspecialchars($user['mail']) . '" style="color: rgba(37, 79, 80, 0.82); line-height: 1.6;">' . htmlspecialchars($user['mail']) . '</a><br>
</p>
';
}
/**
* Create signature list for user
*/
private function createList($user, $name) {
$email = $user['mail'];
$data = json_encode([
[
"itemClass" => "RoamingSetting",
"name" => "roaming_signature_list",
"scope" => $email,
"secondaryKey" => "roaming_signature_list",
"type" => "BlobArray",
"value" => $name
]
]);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => [
"Authorization: Bearer {$this->outlookAccessToken}",
'Content-Type: application/json',
'Content-Length: ' . strlen($data),
'x-owa-explicitlogonuser: ' . $email,
'x-anchormailbox: ' . $email,
'x-islargesetting: false',
'x-overridetimestamp: false',
],
'content' => $data,
'proxy' => 'localhost:8888',
],
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
]
]);
$url = "https://outlook.office.com/ows/v1/OutlookCloudSettings/settings/account";
$response = file_get_contents($url, false, $context);
if ($response !== false) {
echo "Created roaming signature list for {$email}\n";
return true;
}
return false;
}
private function getExistingSignatureNames($user) {
$email = $user['mail'];
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => [
"Authorization: Bearer {$this->outlookAccessToken}",
'x-owa-explicitlogonuser: ' . $email,
'x-anchormailbox: ' . $email,
'x-islargesetting: false',
'x-overridetimestamp: false',
],
'proxy' => 'localhost:8888',
],
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
]
]);
$url = "https://outlook.office.com/ows/v1/OutlookCloudSettings/settings/account?settingname=roaming_signature_list";
$response = file_get_contents($url, false, $context);
if ($response === false) {
return false;
}
$signResponse = json_decode($response, true);
foreach ($signResponse as $item) {
if (strtolower($item['name']) === 'roaming_signature_list') {
$roamingSigList = $item;
break;
}
}
if (!$roamingSigList) {
return [];
}
return explode(',', $roamingSigList['value']);
}
/**
* Get signature name for user
*/
private function getSignatureName($user) {
$signatureNames = $this->getExistingSignatureNames($user);
if (count($signatureNames) === 1) {
return $signatureNames[0];
}
// Check for roaming_new_signature
foreach ($signResponse as $item) {
if (strtolower($item['name']) === 'roaming_new_signature' && !empty($item['value'])) {
return $item['value'];
}
}
// Check for roaming_reply_signature
foreach ($signResponse as $item) {
if (strtolower($item['name']) === 'roaming_reply_signature' && !empty($item['value'])) {
return $item['value'];
}
}
return $signatureNames[0];
}
/**
* Change user signature
*/
private function changeUserSignature($user, $signatureName) {
$email = $user['mail'];
$data = json_encode([
[
"itemClass" => "RoamingSetting",
"metadata" => "encoding:utf-8",
"name" => $signatureName,
"parentSetting" => "roaming_signature_list",
"scope" => $email,
"secondaryKey" => "htm",
"timestamp" => $this->getTimestampTicks(),
"type" => "Blob",
"value" => $this->generateSignatureHtml($user),
"value@is.Large" => true
],
[
"itemClass" => "RoamingSetting",
"metadata" => "encoding:utf-8",
"name" => $signatureName,
"parentSetting" => "roaming_signature_list",
"scope" => $email,
"secondaryKey" => "txt",
"timestamp" => $this->getTimestampTicks(),
"type" => "Blob",
"value" => $this->generateSignatureText($user),
"value@is.Large" => true
]
]);
$context = stream_context_create([
'http' => [
'method' => 'PATCH',
'header' => [
"Authorization: Bearer {$this->outlookAccessToken}",
'Content-Type: application/json',
'x-owa-explicitlogonuser: ' . $email,
'x-anchormailbox: ' . $email,
'x-islargesetting: true',
'x-overridetimestamp: true',
'Content-Length: ' . strlen($data)
],
'content' => $data
]
]);
$url = "https://outlook.office.com/ows/v1/OutlookCloudSettings/settings/account";
$response = file_get_contents($url, false, $context);
if ($response === false) {
// Get error details from HTTP response headers
$error = error_get_last();
echo "Failed to set settings for {$email}: " . ($error['message'] ?? 'Unknown error') . "\n";
return false;
}
$signatureNames = $this->getExistingSignatureNames($user);
$newSignatureNames = array_merge($signatureNames, [$signatureName]);
// Update the list of signatures with a PATCH request
$data = json_encode([
[
"itemClass" => "RoamingSetting",
"name" => "roaming_signature_list",
"scope" => $email,
"secondaryKey" => "roaming_signature_list",
"type" => "BlobArray",
"value" => implode(',', $newSignatureNames),
]
]);
return true;
}
/**
* Run signature update for a single user
*/
public function runForUser($user) {
$signatureName = $this->getSignatureName($user);
$signatureName = 'Signature_1';
if (!$signatureName) {
return false;
}
echo "User has signature {$user['mail']}: {$signatureName}\n";
echo "Setting {$signatureName} for {$user['mail']}\n";
return $this->changeUserSignature($user, $signatureName);
}
/**
* Run signature update for all users
*/
public function runForAllUsers() {
$users = $this->getUsers();
foreach ($users as $user) {
$this->runForUser($user);
}
}
/**
* Run signature update for a specific user by email
*/
public function runForSpecificUser($targetEmail) {
$user = $this->getUser($targetEmail);
return $this->runForUser($user);
}
}
// Usage example:
try {
$signatureManager = new RoamingSignatureManager();
// Run for one specific user
$signatureManager->runForSpecificUser('eion@example.com');
// Uncomment to run for all users:
// $signatureManager->runForAllUsers();
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
exit(1);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment