Stop Paying for Email Signature Management – Build It Yourself for Free
Stop Paying for Email Signature Management – Build It Yourself for Free
by Patrik Samko, CEO @ Bizztreat
We had email signature management for free.
It was bundled as a benefit through our Google Workspace distributor. It just worked – everyone had a consistent, on-brand signature, nobody thought about it.
Then we switched partners. Better pricing, better support. But we lost that benefit.
I started looking at standalone SaaS alternatives:
Tool | Price |
|---|---|
Exclaimer | $0.90 – $1.75 / user / month (Starter to Pro, annual billing) |
WiseStamp | from $1.90 / user / month |
BulkSignature | from $1.45 / user / month |
At 25 people, that's roughly $25–50/month for a tool whose only job is to push some HTML into Gmail.
I decided we weren't going to pay for that.
I opened Claude, described what I needed – centralized signature management via Google Apps Script, service account auth, Domain-wide Delegation, weekly auto-trigger. An hour and a half of debugging later (mostly one tricky OAuth/DWD gotcha that most online tutorials completely ignore), it was done.
This guide is the result. Everything you need to replicate it.
How It Works
The system uses Google Apps Script with a Service Account that has Domain-wide Delegation (DWD). This lets the script act on behalf of any user in your domain and set their Gmail signature – no individual logins, no user interaction required.
Apps Script → reads user data from Admin SDK (name, title, phone, photo) → authenticates as each user via Service Account + OAuth2 JWT → calls Gmail API to set the signature → repeats for all active users → trigger fires automatically every Monday at 8am
The key gotcha: Standard Apps Script OAuth cannot use Domain-wide Delegation with the Gmail API. You must use a Service Account with JWT authentication. This is by Google's design and most tutorials skip it entirely.
What You Need
Google Workspace (any plan)
Access to Google Cloud Console (free)
Super Admin role in Workspace
~30 minutes for first-time setup
Part 1 – Google Cloud Console
1.1 Create a GCP Project
Go to console.cloud.google.com
Click the project dropdown → New Project
Name it company-signatures → Create
1.2 Enable Required APIs
Left menu → APIs & Services → Library
Search Admin SDK API → Enable
Search Gmail API → Enable
1.3 Configure OAuth Consent Screen
Left menu → APIs & Services → Credentials
Click Configure consent screen (yellow banner)
Select Internal → Create
Fill in App name, support email, developer contact → Save and Continue × 3
1.4 Create a Service Account
Left menu → IAM & Admin → Service Accounts
+ Create Service Account
Name: signatures-sa → Create and Continue
Skip the Grant access sections → Done
1.5 Get the Client ID
Click on the newly created service account
Scroll down → expand Advanced settings
Under Domain-wide Delegation, copy the Client ID number
ℹ️ The "Enable Domain-wide Delegation" checkbox was removed from the GCP UI. DWD is now activated through the Workspace Admin console (Part 2).
1.6 Download the JSON Key
Service account page → Keys tab
Add Key → Create new key → JSON → Create
Save the downloaded file securely – never commit it to Git or share it
⚠️ This JSON key grants impersonation access to your entire domain. Store it in a password manager and treat it like a master password.
Part 2 – Google Workspace Admin
This grants the service account permission to act on behalf of all users.
Go to admin.google.com
Navigate to: Security → Access and data control → API controls
Domain wide delegation → Manage Domain Wide Delegation
Add new
Client ID: paste the number from step 1.5
OAuth Scopes – paste exactly:
Authorize
ℹ️ Changes can take up to 15 minutes to propagate. If you get a Delegation denied error right after setup, wait a moment and retry.
Part 3 – Apps Script Setup
3.1 Create the Project
Go to script.google.com → New project
Rename to Company Signatures
3.2 Add the Code
Delete the default content in Code.gs → paste the Code.gs content (see below)
Click + next to Files → Script → name it Template
Paste the Template.gs content
3.3 Add the OAuth2 Library
Left menu → + next to Libraries
Paste this Script ID:
1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF
Look up → select the highest version → keep Identifier as OAuth2 → Add
3.4 Add Admin SDK Service
Left menu → + next to Services
Select Admin SDK Directory API → Add
⚠️ Do NOT add Gmail API as a service – it's called directly via the OAuth2 library.
3.5 Link the GCP Project
⚙️ Project Settings → Google Cloud Platform (GCP) Project
Change project → enter your GCP project number → Set project
3.6 Store the JSON Key
The JSON key is too long for the Script Properties UI. Use this workaround:
Create a temporary file called Setup.gs in your project
Paste this and replace the placeholder with your full JSON content:
/**
* ╔══════════════════════════════════════════════════════════════╗
* ║ Google Workspace Email Signature Manager ║
* ║ Apps Script + Service Account ║
* ╚══════════════════════════════════════════════════════════════╝
*
* SETUP (follow this order exactly):
*
* ① Set CONFIG below (logo, icons, address, website, social links)
* ② Paste this code into Code.gs, Template.gs goes in a separate file
* ③ Add the OAuth2 library (Script ID in the guide)
* ④ Add service: Admin SDK Directory API
* (do NOT add Gmail API as a service – it's called directly via OAuth2)
* ⑤ Save service account JSON key using the temporary saveJson() function
* ⑥ Run step2_testOnlyMe – verify the signature in Gmail
* ⑦ Run step3_deployEveryone – deploys signature to all users
*/
// ════════════════════════════════════════════════════════════════
// KONFIGURACE – UPRAV TUTO SEKCI
// ════════════════════════════════════════════════════════════════
const CONFIG = {
// Your admin email – used for testing in step2_testOnlyMe
myEmail: "admin@vasafirma.cz",
// Company logo URL (PNG/SVG, publicly accessible, ideally ~300px wide)
logoUrl: "https://vasafirma.cz/logo.png",
// Icon URLs – using signaturehound.com free API
// Replace the HEX code (after the last slash) with your brand color
// Examples: ffffff = white, 000000 = black, 0078d4 = Microsoft blue
iconUrls: {
email: "https://signaturehound.com/api/v1/png/email/default/000000.png",
phone: "https://signaturehound.com/api/v1/png/mobile/default/000000.png",
location: "https://signaturehound.com/api/v1/png/map/default/000000.png",
web: "https://signaturehound.com/api/v1/png/website/default/000000.png",
linkedin: "https://signaturehound.com/api/v1/png/linkedin/default/000000.png",
youtube: "https://signaturehound.com/api/v1/png/youtube/default/000000.png",
// More icons available: twitter, facebook, instagram, github, phone, fax, ...
// See: https://signaturehound.com/api
},
// Company details (same for all users)
companyName: "Your Company Ltd.",
address: "Ulice 123, 110 00 Praha 1",
website: "https://vasafirma.cz",
// Social links – set to "" (empty string) to hide the icon
linkedinUrl: "https://www.linkedin.com/company/vasafirma",
youtubeUrl: "", // empty = icon will be hidden
};
// ════════════════════════════════════════════════════════════════
// STEP 1 – Save the JSON key (run once, then delete)
// ════════════════════════════════════════════════════════════════
/**
* How to use:
* 1. Create a new file called Setup.gs
* 2. Paste this function there
* 3. Replace the placeholder with the full contents of your JSON key file
* 4. Run saveJson()
* 5. Delete Setup.gs immediately
*
* WHY: The Script Properties UI cannot accept the JSON key
* because it contains a multi-line private_key (UI limit is ~500 chars).
*/
function saveJson() {
const json = "VLOZ_SEM_CELY_OBSAH_JSON_KLICE_ZE_SERVICE_ACCOUNTU";
PropertiesService.getScriptProperties()
.setProperty("SERVICE_ACCOUNT_JSON", json);
Logger.log("✅ JSON key saved to Script Properties.");
Logger.log("⚠️ Remember to DELETE this Setup.gs file!");
}
// ════════════════════════════════════════════════════════════════
// STEP 2 – Test on your own account first
// ════════════════════════════════════════════════════════════════
function step2_testOnlyMe() {
Logger.log("🧪 Testuju podpis pro: " + CONFIG.myEmail);
_deploySignature(CONFIG.myEmail);
Logger.log("\n✅ Done! Check your signature in Gmail:");
Logger.log(" Gmail → ⚙️ → See all settings → General → Signature");
}
// ════════════════════════════════════════════════════════════════
// STEP 3 – Deploy signature to all users in the domain
// ════════════════════════════════════════════════════════════════
function step3_deployEveryone() {
Logger.log("🚀 Deploying signatures to all users in the domain...\n");
// Get the domain from the logged-in admin's email
const domain = Session.getActiveUser().getEmail().split("@")[1];
// Loop through all active users (Admin SDK returns max 500 at once → pagination)
let pageToken, allUsers = [];
do {
const res = AdminDirectory.Users.list({
domain,
maxResults: 500,
pageToken,
query: "isSuspended=false", // skip suspended/deactivated accounts
projection: "basic",
});
allUsers = allUsers.concat(res.users || []);
pageToken = res.nextPageToken;
} while (pageToken);
Logger.log("Found " + allUsers.length + " active users\n");
let ok = 0, fail = 0;
for (const user of allUsers) {
try {
_deploySignature(user.primaryEmail);
ok++;
} catch (e) {
Logger.log(" ✗ " + user.primaryEmail + " – " + e.message);
fail++;
}
Utilities.sleep(150); // short pause to avoid hitting Gmail API rate limits
}
Logger.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
Logger.log("✅ OK: " + ok + " ❌ Errors: " + fail + " Total: " + (ok + fail));
}
// ════════════════════════════════════════════════════════════════
// INTERNAL – service account authentication (DWD)
// ════════════════════════════════════════════════════════════════
function _getServiceForUser(email) {
const props = PropertiesService.getScriptProperties();
const json = props.getProperty("SERVICE_ACCOUNT_JSON");
if (!json) {
throw new Error(
"SERVICE_ACCOUNT_JSON is not set! Run saveJson() first as described in the guide."
);
}
// We intentionally do NOT use JSON.parse() here:
// The private_key contains multi-line \n sequences.
// When stored via setProperty(), escaping can shift and JSON.parse() throws
// "SyntaxError: Bad control character". We extract values with regex instead.
const clientEmail = json.match(/"client_email"\s*:\s*"([^"]+)"/)[1];
const privateKey = json.match(/"private_key"\s*:\s*"([\s\S]+?)"\s*[,}]/)[1]
.replace(/\\n/g, "\n");
// Create an OAuth2 service for a specific user
// .setSubject(email) = "impersonate this user" (Domain-wide Delegation)
return OAuth2.createService("gmail:" + email)
.setTokenUrl("https://oauth2.googleapis.com/token")
.setPrivateKey(privateKey)
.setIssuer(clientEmail)
.setSubject(email)
.setScope([
"https://www.googleapis.com/auth/gmail.settings.basic",
"https://www.googleapis.com/auth/gmail.settings.sharing",
].join(" "))
.setPropertyStore(PropertiesService.getScriptProperties())
.setCache(CacheService.getScriptCache());
}
// ════════════════════════════════════════════════════════════════
// INTERNAL – build and deploy signature for a single user
// ════════════════════════════════════════════════════════════════
function _getIcons() {
// Icons as direct URLs (not base64) – Gmail loads them as external images.
// REASON: base64 embedded icons push the HTML over Gmail's 10,000 char limit → HTTP 400.
return {
logo: CONFIG.logoUrl,
email: CONFIG.iconUrls.email,
phone: CONFIG.iconUrls.phone,
location: CONFIG.iconUrls.location,
web: CONFIG.iconUrls.web,
linkedin: CONFIG.iconUrls.linkedin,
youtube: CONFIG.iconUrls.youtube,
};
}
function _getPhotoUrl(user) {
// Admin SDK returns a direct URL to the user's profile photo.
// If the user has no photo, we return null → Template.gs shows a placeholder.
return user.thumbnailPhotoUrl || null;
}
function _buildHtml(user, icons) {
// Assemble user data from Admin SDK
const name = user.name?.fullName || user.primaryEmail;
const title = user.organizations?.[0]?.title || "";
const email = user.primaryEmail;
// Look for work phone first, fall back to any available number
const phone = (user.phones || []).find(p => p.type === "work")?.value
|| user.phones?.[0]?.value || "";
const photo = _getPhotoUrl(user);
// The actual HTML is defined in Template.gs – edit there, not here
return buildSignatureHtml(name, title, email, phone, photo, icons);
}
function _deploySignature(email) {
const icons = _getIcons();
// projection: "full" loads all fields (phone, photo, organizations...)
const user = AdminDirectory.Users.get(email, {
projection: "full",
viewType: "admin_view",
});
const html = _buildHtml(user, icons);
const service = _getServiceForUser(email);
if (!service.hasAccess()) {
throw new Error(
"Cannot get OAuth token for " + email + ": " + service.getLastError() + "\nCheck DWD settings in Google Workspace Admin."
);
}
// PATCH the sendAs endpoint – sets the signature for the user's primary address
const url = "https://gmail.googleapis.com/gmail/v1/users/me/settings/sendAs/" + encodeURIComponent(email);
const resp = UrlFetchApp.fetch(url, {
method: "PATCH",
contentType: "application/json",
headers: { Authorization: "Bearer " + service.getAccessToken() },
payload: JSON.stringify({ signature: html }),
muteHttpExceptions: true, // we handle errors ourselves, not as exceptions
});
const code = resp.getResponseCode();
if (code !== 200) {
throw new Error("HTTP " + code + ": " + resp.getContentText());
}
Logger.log(" ✓ " + email + " (" + (user.name?.fullName || "") + ")");
}
Run saveJson → verify the log says Saved!
Delete the Setup.gs file immediately – the key must not stay visible in the code
Part 4 – Run & Deploy
Test on your own account first
Select function step2_testOnlyMe → Run
Expected output:
🧪 Testing signature for: you@yourdomain.com ✓ you@yourdomain.com (Your Name) ✅ Done! Check your Gmail signature.
Verify: Gmail → ⚙️ → See all settings → General → Signature
Deploy to everyone
Select function step3_deployEveryone → Run
🚀 Deploying signatures to all domain users... Found 23 active users
✓ jane.smith@company.com (Jane Smith)
✓ john.doe@company.com (John Doe)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ OK: 23 ❌ Errors: 0 Total: 23
Set up the weekly trigger
Left menu → ⏰ Triggers → + Add Trigger
Function: step3_deployEveryone, Event source: Time-driven, Type: Week timer, Day: Monday, Time: 8am–9am
Save
New employees get their signature automatically on the next Monday. For immediate deployment, just run step3_deployEveryone manually.
Troubleshooting
Error | Solution |
|---|---|
SERVICE_ACCOUNT_JSON not set | JSON key wasn't saved. Redo step 3.6. |
Delegation denied | Wait 15 min after DWD setup. Check Client ID. |
HTTP 400 – Signature exceeds 10,000 chars | Icons must be URLs, not base64. |
Photo not showing | User has no profile photo in Google Workspace. |
Service not enabled | Enable Admin SDK API in Cloud Console (1.2). |
Where to Set Employee Data
All data is pulled automatically from Google Workspace:
Field in signature | Where to set it |
|---|---|
Name | Admin Console → Users → Name |
Job title | Admin Console → Users → Employee info → Job title |
Phone | Admin Console → Users → Phone numbers → type "Work" |
Profile photo | User sets it at myaccount.google.com, or admin uploads in Admin Console |
If a user has no phone number, that row is automatically hidden in the signature.
Final Result
Total cost: $0/month
Setup time: ~30 minutes
Maintenance: zero – new employees get signatures automatically
I used Claude to help build this. I described the architecture, we worked through the OAuth gotcha together, and the whole thing was done in an afternoon. The code is clean, well-commented and easy to modify.
Feel free to use it, adapt it, and share it. If you improve it, I'd love to hear about it.
Patrik Samko – CEO @ Bizztreat, the data detectives
LinkedIn
Part 5 – Code: Code.gs
What this file does
Code.gs is the brain of the system. It contains:
CONFIG – the only place you configure logo, icons, address, website, and social links
saveJson() – a one-time helper to store the service account JSON key
step2_testOnlyMe() – test the signature on your own account first
step3_deployEveryone() – deploy signatures to all users in the domain
Internal functions for OAuth2 authentication and HTML assembly
What to change in CONFIG
Field | What to change |
|---|---|
myEmail | Your admin email (used for testing) |
logoUrl | Your logo URL (PNG, publicly accessible) |
iconUrls.* | Replace the hex color at the end of the URL (e.g. 0078d4 = blue) |
companyName | Your company name |
address | Company address |
website | Company website URL |
linkedinUrl | LinkedIn company page URL (or "" to hide) |
youtubeUrl | YouTube channel URL (or "" to hide) |
Icons are generated for free by signaturehound.com – just change the hex color in the URL.
Code
/**
* ╔══════════════════════════════════════════════════════════════╗
* ║ Google Workspace Email Signature Manager ║
* ║ Apps Script + Service Account ║
* ╚══════════════════════════════════════════════════════════════╝
*
* SETUP (follow this order exactly):
*
* ① Set CONFIG below (logo, icons, address, website, social links)
* ② Paste this code into Code.gs, Template.gs goes in a separate file
* ③ Add the OAuth2 library (Script ID in the guide)
* ④ Add service: Admin SDK Directory API
* (do NOT add Gmail API as a service – it's called directly via OAuth2)
* ⑤ Save service account JSON key using the temporary saveJson() function
* ⑥ Run step2_testOnlyMe – verify the signature in Gmail
* ⑦ Run step3_deployEveryone – deploys signature to all users
*/
// ════════════════════════════════════════════════════════════════
// KONFIGURACE – UPRAV TUTO SEKCI
// ════════════════════════════════════════════════════════════════
const CONFIG = {
// Your admin email – used for testing in step2_testOnlyMe
myEmail: "admin@vasafirma.cz",
// Company logo URL (PNG/SVG, publicly accessible, ideally ~300px wide)
logoUrl: "https://vasafirma.cz/logo.png",
// Icon URLs – using signaturehound.com free API
// Replace the HEX code (after the last slash) with your brand color
// Examples: ffffff = white, 000000 = black, 0078d4 = Microsoft blue
iconUrls: {
email: "https://signaturehound.com/api/v1/png/email/default/000000.png",
phone: "https://signaturehound.com/api/v1/png/mobile/default/000000.png",
location: "https://signaturehound.com/api/v1/png/map/default/000000.png",
web: "https://signaturehound.com/api/v1/png/website/default/000000.png",
linkedin: "https://signaturehound.com/api/v1/png/linkedin/default/000000.png",
youtube: "https://signaturehound.com/api/v1/png/youtube/default/000000.png",
// More icons available: twitter, facebook, instagram, github, phone, fax, ...
// See: https://signaturehound.com/api
},
// Company details (same for all users)
companyName: "Your Company Ltd.",
address: "Ulice 123, 110 00 Praha 1",
website: "https://vasafirma.cz",
// Social links – set to "" (empty string) to hide the icon
linkedinUrl: "https://www.linkedin.com/company/vasafirma",
youtubeUrl: "", // empty = icon will be hidden
};
// ════════════════════════════════════════════════════════════════
// STEP 1 – Save the JSON key (run once, then delete)
// ════════════════════════════════════════════════════════════════
/**
* How to use:
* 1. Create a new file called Setup.gs
* 2. Paste this function there
* 3. Replace the placeholder with the full contents of your JSON key file
* 4. Run saveJson()
* 5. Delete Setup.gs immediately
*
* WHY: The Script Properties UI cannot accept the JSON key
* because it contains a multi-line private_key (UI limit is ~500 chars).
*/
function saveJson() {
const json = "VLOZ_SEM_CELY_OBSAH_JSON_KLICE_ZE_SERVICE_ACCOUNTU";
PropertiesService.getScriptProperties()
.setProperty("SERVICE_ACCOUNT_JSON", json);
Logger.log("✅ JSON key saved to Script Properties.");
Logger.log("⚠️ Remember to DELETE this Setup.gs file!");
}
// ════════════════════════════════════════════════════════════════
// STEP 2 – Test on your own account first
// ════════════════════════════════════════════════════════════════
function step2_testOnlyMe() {
Logger.log("🧪 Testuju podpis pro: " + CONFIG.myEmail);
_deploySignature(CONFIG.myEmail);
Logger.log("\n✅ Done! Check your signature in Gmail:");
Logger.log(" Gmail → ⚙️ → See all settings → General → Signature");
}
// ════════════════════════════════════════════════════════════════
// STEP 3 – Deploy signature to all users in the domain
// ════════════════════════════════════════════════════════════════
function step3_deployEveryone() {
Logger.log("🚀 Deploying signatures to all users in the domain...\n");
// Get the domain from the logged-in admin's email
const domain = Session.getActiveUser().getEmail().split("@")[1];
// Loop through all active users (Admin SDK returns max 500 at once → pagination)
let pageToken, allUsers = [];
do {
const res = AdminDirectory.Users.list({
domain,
maxResults: 500,
pageToken,
query: "isSuspended=false", // skip suspended/deactivated accounts
projection: "basic",
});
allUsers = allUsers.concat(res.users || []);
pageToken = res.nextPageToken;
} while (pageToken);
Logger.log("Found " + allUsers.length + " active users\n");
let ok = 0, fail = 0;
for (const user of allUsers) {
try {
_deploySignature(user.primaryEmail);
ok++;
} catch (e) {
Logger.log(" ✗ " + user.primaryEmail + " – " + e.message);
fail++;
}
Utilities.sleep(150); // short pause to avoid hitting Gmail API rate limits
}
Logger.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
Logger.log("✅ OK: " + ok + " ❌ Errors: " + fail + " Total: " + (ok + fail));
}
// ════════════════════════════════════════════════════════════════
// INTERNAL – service account authentication (DWD)
// ════════════════════════════════════════════════════════════════
function _getServiceForUser(email) {
const props = PropertiesService.getScriptProperties();
const json = props.getProperty("SERVICE_ACCOUNT_JSON");
if (!json) {
throw new Error(
"SERVICE_ACCOUNT_JSON is not set! Run saveJson() first as described in the guide."
);
}
// We intentionally do NOT use JSON.parse() here:
// The private_key contains multi-line \n sequences.
// When stored via setProperty(), escaping can shift and JSON.parse() throws
// "SyntaxError: Bad control character". We extract values with regex instead.
const clientEmail = json.match(/"client_email"\s*:\s*"([^"]+)"/)[1];
const privateKey = json.match(/"private_key"\s*:\s*"([\s\S]+?)"\s*[,}]/)[1]
.replace(/\\n/g, "\n");
// Create an OAuth2 service for a specific user
// .setSubject(email) = "impersonate this user" (Domain-wide Delegation)
return OAuth2.createService("gmail:" + email)
.setTokenUrl("https://oauth2.googleapis.com/token")
.setPrivateKey(privateKey)
.setIssuer(clientEmail)
.setSubject(email)
.setScope([
"https://www.googleapis.com/auth/gmail.settings.basic",
"https://www.googleapis.com/auth/gmail.settings.sharing",
].join(" "))
.setPropertyStore(PropertiesService.getScriptProperties())
.setCache(CacheService.getScriptCache());
}
// ════════════════════════════════════════════════════════════════
// INTERNAL – build and deploy signature for a single user
// ════════════════════════════════════════════════════════════════
function _getIcons() {
// Icons as direct URLs (not base64) – Gmail loads them as external images.
// REASON: base64 embedded icons push the HTML over Gmail's 10,000 char limit → HTTP 400.
return {
logo: CONFIG.logoUrl,
email: CONFIG.iconUrls.email,
phone: CONFIG.iconUrls.phone,
location: CONFIG.iconUrls.location,
web: CONFIG.iconUrls.web,
linkedin: CONFIG.iconUrls.linkedin,
youtube: CONFIG.iconUrls.youtube,
};
}
function _getPhotoUrl(user) {
// Admin SDK returns a direct URL to the user's profile photo.
// If the user has no photo, we return null → Template.gs shows a placeholder.
return user.thumbnailPhotoUrl || null;
}
function _buildHtml(user, icons) {
// Assemble user data from Admin SDK
const name = user.name?.fullName || user.primaryEmail;
const title = user.organizations?.[0]?.title || "";
const email = user.primaryEmail;
// Look for work phone first, fall back to any available number
const phone = (user.phones || []).find(p => p.type === "work")?.value
|| user.phones?.[0]?.value || "";
const photo = _getPhotoUrl(user);
// The actual HTML is defined in Template.gs – edit there, not here
return buildSignatureHtml(name, title, email, phone, photo, icons);
}
function _deploySignature(email) {
const icons = _getIcons();
// projection: "full" loads all fields (phone, photo, organizations...)
const user = AdminDirectory.Users.get(email, {
projection: "full",
viewType: "admin_view",
});
const html = _buildHtml(user, icons);
const service = _getServiceForUser(email);
if (!service.hasAccess()) {
throw new Error(
"Cannot get OAuth token for " + email + ": " + service.getLastError() + "\nCheck DWD settings in Google Workspace Admin."
);
}
// PATCH the sendAs endpoint – sets the signature for the user's primary address
const url = "https://gmail.googleapis.com/gmail/v1/users/me/settings/sendAs/" + encodeURIComponent(email);
const resp = UrlFetchApp.fetch(url, {
method: "PATCH",
contentType: "application/json",
headers: { Authorization: "Bearer " + service.getAccessToken() },
payload: JSON.stringify({ signature: html }),
muteHttpExceptions: true, // we handle errors ourselves, not as exceptions
});
const code = resp.getResponseCode();
if (code !== 200) {
throw new Error("HTTP " + code + ": " + resp.getContentText());
}
Logger.log(" ✓ " + email + " (" + (user.name?.fullName || "") + ")");
}
Part 6 – Code: Template.gs
What this file does
Template.gs contains a single function buildSignatureHtml() that accepts user data and returns the signature HTML string. Edit this file to change the visual design – layout, colors, fonts, rows.
Code.gs calls this function automatically.
Most common customizations
What to change | Where in the code |
|---|---|
Separator bar color | background-color:#000000 – replace hex |
Website link color | color:#000000 – in the Web section |
Photo size | width="96" height="96" |
Logo size | width="124" |
Add Twitter/X | Copy the youtubeIcon block, add CONFIG.twitterUrl to CONFIG |
After each change, run step2_testOnlyMe and verify the result in Gmail.
Code
/**
* ╔══════════════════════════════════════════════════════════════╗
* ║ Email Signature Template – Template.gs ║
* ╚══════════════════════════════════════════════════════════════╝
*
* Edit this file to change the visual design. After each change,
* run step2_testOnlyMe in Code.gs and verify the result in Gmail.
*
* Available variables (auto-populated from Workspace profile):
* name – user's full name
* title – job title (Employee info → Job title)
* email – primary email address
* phone – work phone, empty string if not set
* photo – profile photo URL, null if user has no photo
*
* Available objects from Code.gs:
* icons.logo – company logo
* icons.email – email icon
* icons.phone – phone icon
* icons.location – address/location icon
* icons.web – website icon
* icons.linkedin – LinkedIn icon
* icons.youtube – YouTube icon
* CONFIG.address – company address
* CONFIG.website – company website
* CONFIG.companyName – company name
* CONFIG.linkedinUrl – company LinkedIn page URL
* CONFIG.youtubeUrl – company YouTube channel URL
*
* IMPORTANT LIMITS:
* Gmail has a 10,000 character limit per signature.
* Icons must be loaded as URLs (not base64) – otherwise you'll exceed the limit.
* Avoid external fonts (Google Fonts) – Gmail ignores them.
* Safe fonts: Arial, Helvetica, Verdana, Georgia, Times New Roman
*/
function buildSignatureHtml(name, title, email, phone, photo, icons) {
// ── PHOTO ──────────────────────────────────────────────────────
// Show profile photo as a circle, or a grey placeholder if none
var photoCell = photo
? '<img src="' + photo + '" width="96" height="96" style="border-radius:50%;width:96px;height:96px;object-fit:cover;display:block;margin:0 auto;" alt="' + name + '">'
: '<div style="width:96px;height:96px;border-radius:50%;background:#e8e8e8;margin:0 auto;"></div>';
// ── PHONE ──────────────────────────────────────────────────────
// Only show the phone row if the user has a phone number
var phoneRow = phone
? '<tr><td style="padding:2px 8px 2px 0;vertical-align:middle;"><img src="' + icons.phone + '" width="14" height="14" style="display:block;"></td><td style="font-size:12px;color:#888888;padding:2px 0;vertical-align:middle;">' + phone + '</td></tr>'
: "";
// ── SOCIAL LINKS ────────────────────────────────────────────────
// Only show icons for networks with a non-empty URL in CONFIG
var linkedinIcon = CONFIG.linkedinUrl
? '<td style="padding-right:6px;"><a href="' + CONFIG.linkedinUrl + '" style="text-decoration:none;"><img src="' + icons.linkedin + '" width="26" height="26" alt="LinkedIn" style="display:block;"></a></td>'
: "";
var youtubeIcon = CONFIG.youtubeUrl
? '<td><a href="' + CONFIG.youtubeUrl + '" style="text-decoration:none;"><img src="' + icons.youtube + '" width="26" height="26" alt="YouTube" style="display:block;"></a></td>'
: "";
// ── MAIN HTML TABLE ─────────────────────────────────────────────
// We use HTML tables instead of CSS flexbox/grid – tables are the only
// layout method reliably supported across all email clients.
var html = [];
html.push('<table cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#888888;max-width:540px;">');
html.push('<tr>');
// Left column: photo + logo
html.push('<td style="vertical-align:top;padding-right:18px;width:130px;text-align:center;">');
html.push('<table cellpadding="0" cellspacing="0" style="width:130px;">');
html.push('<tr><td style="padding-bottom:10px;text-align:center;">' + photoCell + '</td></tr>');
html.push('<tr><td style="text-align:center;"><img src="' + icons.logo + '" width="124" height="auto" alt="' + CONFIG.companyName + '" style="display:block;margin:0 auto;max-width:124px;"></td></tr>');
html.push('</table></td>');
// Divider – change #000000 to your brand color
html.push('<td style="width:1px;background-color:#000000;padding:0;"> </td>');
// Right column
html.push('<td style="vertical-align:top;padding-left:18px;">');
// Name, title, company
html.push('<table cellpadding="0" cellspacing="0">');
html.push('<tr><td style="font-size:15px;font-weight:bold;color:#1a1a1a;font-family:Arial,Helvetica,sans-serif;line-height:1.3;padding-bottom:1px;">' + name + '</td></tr>');
html.push('<tr><td style="font-size:12px;color:#888888;padding-bottom:1px;">' + title + '</td></tr>');
html.push('<tr><td style="font-size:12px;color:#888888;padding-bottom:10px;">' + CONFIG.companyName + '</td></tr>');
html.push('</table>');
// Contact details
html.push('<table cellpadding="0" cellspacing="0" style="margin-bottom:8px;">');
// Email
html.push('<tr>');
html.push('<td style="padding:2px 8px 2px 0;vertical-align:middle;"><img src="' + icons.email + '" width="14" height="14" style="display:block;"></td>');
html.push('<td style="font-size:12px;color:#888888;padding:2px 0;vertical-align:middle;"><a href="mailto:' + email + '" style="color:#888888;text-decoration:none;">' + email + '</a></td>');
html.push('</tr>');
// Phone (empty if not set)
html.push(phoneRow);
// Address
html.push('<tr>');
html.push('<td style="padding:2px 8px 2px 0;vertical-align:middle;"><img src="' + icons.location + '" width="14" height="14" style="display:block;"></td>');
html.push('<td style="font-size:12px;color:#888888;padding:2px 0;vertical-align:middle;">' + CONFIG.address + '</td>');
html.push('</tr>');
// Website – change color (#000000) to your brand color
html.push('<tr>');
html.push('<td style="padding:2px 8px 2px 0;vertical-align:middle;"><img src="' + icons.web + '" width="14" height="14" style="display:block;"></td>');
html.push('<td style="font-size:12px;padding:2px 0;vertical-align:middle;"><a href="' + CONFIG.website + '" style="color:#000000;font-weight:bold;text-decoration:none;">' + CONFIG.website.replace("https://", "").replace("http://", "") + '</a></td>');
html.push('</tr>');
html.push('</table>');
// Social links
html.push('<table cellpadding="0" cellspacing="0"><tr>' + linkedinIcon + youtubeIcon + '</tr></table>');
html.push('</td></tr></table>');
return html.join("\n");
}

