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

  1. Go to console.cloud.google.com

  2. Click the project dropdown → New Project

  3. Name it company-signatures → Create

1.2 Enable Required APIs

  1. Left menu → APIs & ServicesLibrary

  2. Search Admin SDK API → Enable

  3. Search Gmail API → Enable

1.3 Configure OAuth Consent Screen

  1. Left menu → APIs & ServicesCredentials

  2. Click Configure consent screen (yellow banner)

  3. Select InternalCreate

  4. Fill in App name, support email, developer contact → Save and Continue × 3

1.4 Create a Service Account

  1. Left menu → IAM & AdminService Accounts

  2. + Create Service Account

  3. Name: signatures-sa → Create and Continue

  4. Skip the Grant access sections → Done

1.5 Get the Client ID

  1. Click on the newly created service account

  2. Scroll down → expand Advanced settings

  3. 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

  1. Service account page → Keys tab

  2. Add KeyCreate new keyJSONCreate

  3. 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.

  1. Go to admin.google.com

  2. Navigate to: Security → Access and data control → API controls

  3. Domain wide delegationManage Domain Wide Delegation

  4. Add new

  5. Client ID: paste the number from step 1.5

  6. OAuth Scopes – paste exactly:

https://www.googleapis.com/auth/gmail.settings.basic,https://www.googleapis.com/auth/gmail.settings.sharing,https://www.googleapis.com/auth/admin.directory.user.readonly

  1. 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

  1. Go to script.google.comNew project

  2. Rename to Company Signatures

3.2 Add the Code

  1. Delete the default content in Code.gs → paste the Code.gs content (see below)

  2. Click + next to Files → Script → name it Template

  3. Paste the Template.gs content

3.3 Add the OAuth2 Library

  1. Left menu → + next to Libraries

  2. Paste this Script ID:

1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF

  1. Look up → select the highest version → keep Identifier as OAuth2Add

3.4 Add Admin SDK Service

  1. Left menu → + next to Services

  2. Select Admin SDK Directory APIAdd

⚠️ Do NOT add Gmail API as a service – it's called directly via the OAuth2 library.

3.5 Link the GCP Project

  1. ⚙️ Project SettingsGoogle Cloud Platform (GCP) Project

  2. 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:

  1. Create a temporary file called Setup.gs in your project

  2. 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 || "") + ")");
}

  1. Run saveJson → verify the log says Saved!

  2. 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_testOnlyMeRun

Expected output:

🧪 Testing signature for: you@yourdomain.comyou@yourdomain.com (Your Name) ✅ Done! Check your Gmail signature.

Verify: Gmail → ⚙️ → See all settingsGeneralSignature

Deploy to everyone

Select function step3_deployEveryoneRun

🚀 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

  1. Left menu → ⏰ Triggers+ Add Trigger

  2. Function: step3_deployEveryone, Event source: Time-driven, Type: Week timer, Day: Monday, Time: 8am–9am

  3. 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;">&nbsp;</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");
}