#!/usr/bin/env python3 """ Signature Buddy - Google Workspace deploy Sets every person's Gmail signature from the signatures.json file exported by Signature Buddy's "Copy deploy file" button. One-time setup (Google Workspace super admin): 1. Google Cloud Console -> create a project -> enable the Gmail API. 2. IAM & Admin -> Service accounts -> create one -> Keys -> Add key -> JSON. Save it next to this script as service-account.json. 3. admin.google.com -> Security -> Access and data control -> API controls -> Manage domain-wide delegation -> Add new: Client ID: the service account's numeric client ID Scope: https://www.googleapis.com/auth/gmail.settings.basic 4. pip install google-auth google-api-python-client Run: python deploy_google.py signatures.json # dry run, shows what would change python deploy_google.py signatures.json --apply # actually sets signatures Scheduled campaigns: the deploy file holds each person's signature for the next 90 days. Run this every morning (cron: 0 6 * * * python3 deploy_google.py /path/signatures.json --apply) and banners switch on and off by themselves. Unchanged mailboxes are skipped, so daily runs are cheap. Images: Gmail rejects signatures with in-browser (data:) images. Signature Buddy normally hosts them before you copy the deploy file. If any are left, pass your image service and upload key and this script hosts them first: python deploy_google.py signatures.json --host-url https://sig.yourcompany.com --upload-key KEY (or set SIGNATURE_BUDDY_HOST_URL and SIGNATURE_BUDDY_UPLOAD_KEY). """ import argparse import datetime import json import os import re import sys import urllib.error import urllib.request GMAIL_SIGNATURE_LIMIT = 10000 # characters; Gmail refuses longer signatures DATA_IMG = re.compile(r'src="(data:image/[^"]+)"') def host_images(people, host_url, key): """Upload any data: images to the Signature Buddy image service and swap in their addresses. The service stores by content hash, so repeats cost nothing.""" cache, failed = {}, [] api = host_url.rstrip("/") + "/api/upload" def upload(data): if data in cache: return cache[data] req = urllib.request.Request(api, data=json.dumps({"data": data}).encode(), method="POST", headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"}) try: with urllib.request.urlopen(req, timeout=30) as r: cache[data] = json.load(r)["url"] except urllib.error.HTTPError as e: try: msg = json.load(e).get("error", "") except Exception: msg = "" failed.append(f"{e.code} {msg}".strip().rstrip(".")) cache[data] = None except Exception as e: failed.append(str(e)) cache[data] = None return cache[data] def swap(m): u = upload(m.group(1)) return f'src="{u}"' if u else m.group(0) out = [(email, DATA_IMG.sub(swap, html)) for email, html in people] hosted = sum(1 for v in cache.values() if v) if cache: print(f"Hosted {hosted} image(s){f', {len(set(failed))} failed: ' + '; '.join(sorted(set(failed))) if failed else ''}.\n") return out from google.oauth2 import service_account from googleapiclient.discovery import build from googleapiclient.errors import HttpError SCOPES = ["https://www.googleapis.com/auth/gmail.settings.basic"] def gmail_for(user_email, key_file): creds = service_account.Credentials.from_service_account_file( key_file, scopes=SCOPES ).with_subject(user_email) return build("gmail", "v1", credentials=creds, cache_discovery=False) def todays_signatures(data, today): """Return [(email, html)] due today. Supports format 2 (scheduled) and format 1.""" if "people" not in data: # format 1: one signature per person return [(p["email"], p["html"]) for p in data.get("signatures", [])] out = [] for p in data["people"]: for w in p["windows"]: if w["from"] <= today and (w["to"] is None or today <= w["to"]): out.append((p["email"], w["html"])) break return out def main(): ap = argparse.ArgumentParser(description="Push Signature Buddy signatures to Gmail.") ap.add_argument("deploy_file", help="signatures.json from Signature Buddy") ap.add_argument("--key", default="service-account.json", help="service account key file") ap.add_argument("--apply", action="store_true", help="write signatures (default is a dry run)") ap.add_argument("--date", help="act as if today is YYYY-MM-DD (for testing a schedule)") ap.add_argument("--host-url", default=os.environ.get("SIGNATURE_BUDDY_HOST_URL"), help="Signature Buddy image service, to host any in-browser images first") ap.add_argument("--upload-key", default=os.environ.get("SIGNATURE_BUDDY_UPLOAD_KEY"), help="upload key for the image service") args = ap.parse_args() with open(args.deploy_file, encoding="utf-8") as f: data = json.load(f) today = args.date or datetime.date.today().isoformat() people = todays_signatures(data, today) if not people: sys.exit("No signatures in the deploy file. Add people in the Team tab and copy it again.") if any(DATA_IMG.search(html) for _, html in people): if args.host_url and args.upload_key: people = host_images(people, args.host_url, args.upload_key) else: print("Note: some signatures have in-browser images, which Gmail rejects. Turn on Image hosting" " in Signature Buddy, or pass --host-url and --upload-key.\n") if data.get("valid_until") and today > data["valid_until"]: print(f"Note: this deploy file was scheduled through {data['valid_until']}. " "Copy a fresh one from Signature Buddy so new campaigns are included.\n") mode = "APPLY" if args.apply else "DRY RUN" print(f"{mode} for {today}: {len(people)} signature(s) for {data.get('company') or 'your company'}\n") ok = failed = unchanged = 0 for email, html in people: email = email.strip() if DATA_IMG.search(html) or len(html) > GMAIL_SIGNATURE_LIMIT: failed += 1 why = "has in-browser images Gmail rejects" if DATA_IMG.search(html) else \ f"is {len(html):,} characters; Gmail's limit is {GMAIL_SIGNATURE_LIMIT:,}" print(f" ✗ {email} signature {why}") continue try: gmail = gmail_for(email, args.key) current = gmail.users().settings().sendAs().get( userId=email, sendAsEmail=email ).execute() if current.get("signature", "") == html: unchanged += 1 print(f" = {email} (already up to date)") continue if args.apply: gmail.users().settings().sendAs().patch( userId=email, sendAsEmail=email, body={"signature": html} ).execute() print(f" ✓ {email}") else: print(f" ~ {email} (would update)") ok += 1 except HttpError as e: failed += 1 reason = e.reason if hasattr(e, "reason") else str(e) print(f" ✗ {email} {e.resp.status}: {reason}") except Exception as e: # bad key file, delegation not set up, etc. failed += 1 print(f" ✗ {email} {e}") verb = "updated" if args.apply else "to update" print(f"\n{ok} {verb}, {unchanged} unchanged, {failed} failed.") if failed: print("Failures are usually: email not in your Workspace domain, Gmail API not enabled," " or domain-wide delegation missing the gmail.settings.basic scope.") if not args.apply and ok: print("Run again with --apply to make these changes.") sys.exit(1 if failed else 0) if __name__ == "__main__": main()