diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0d7ba5a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,14 @@
+FROM node:20-alpine
+
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm ci --omit=dev
+
+COPY . .
+
+RUN mkdir -p /app/data /app/secrets
+
+EXPOSE 3000
+
+CMD ["node", "src/index.js"]
diff --git a/README.md b/README.md
index c9e02a4..34f88c6 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,208 @@
-# email-sigs
\ No newline at end of file
+# Email Signature Manager
+
+A self-hosted, Dockerized Google Workspace email signature manager.
+Runs as a single container — designed for Unraid but works on any Docker host.
+
+## Features
+
+- Pulls user data automatically from Google Workspace Directory API
+- Renders per-user HTML signatures via Handlebars templates
+- Pushes signatures directly to Gmail via `sendAs` API (web + mobile)
+- Nightly batch push via configurable cron schedule
+- Web admin UI — live template editor with real-time preview
+- Single-user push for testing and onboarding
+- SQLite audit log of every push event
+- Basic auth protected UI
+- Zero external services — single container, no database server
+
+---
+
+## Environment Variables
+
+All secrets and configuration are passed as **environment variables at runtime**.
+No `.env` file is committed to this repo — see `.env.example` for all variable names and descriptions.
+
+| Variable | Description | Default |
+|---|---|---|
+| `GOOGLE_ADMIN_EMAIL` | Workspace admin email for Directory API | *(required)* |
+| `GOOGLE_CUSTOMER_ID` | Use `my_customer` for primary domain | `my_customer` |
+| `SERVICE_ACCOUNT_KEY_PATH` | Path to sa.json inside container | `/app/secrets/sa.json` |
+| `ADMIN_USERNAME` | Web UI login username | *(required)* |
+| `ADMIN_PASSWORD` | Web UI login password | *(required)* |
+| `PORT` | Internal app port | `3000` |
+| `CRON_SCHEDULE` | Cron expression for nightly push | `0 2 * * *` |
+| `NODE_ENV` | Node environment | `production` |
+
+### On Unraid
+Set each variable directly in the **Docker container template UI** under the
+"Variables" section. No file needed on the host.
+
+### For Local Development
+Copy `.env.example` to `.env`, fill in your values, then run:
+```bash
+docker-compose --env-file .env up -d --build
+```
+
+---
+
+## Google Workspace Setup (One-Time)
+
+### 1. Create GCP Project & Enable APIs
+1. Go to https://console.cloud.google.com → New Project
+2. Enable **Admin SDK API** and **Gmail API**
+
+### 2. Create Service Account
+1. APIs & Services → Credentials → Create Credentials → Service Account
+2. Name it `email-sig-manager` → Create
+3. Open the service account → Keys tab → Add Key → JSON
+4. Save the downloaded file as `sa.json`
+5. Place it at: `secrets/sa.json` on the Docker host (mounted as a volume, never in git)
+
+### 3. Enable Domain-Wide Delegation
+1. Edit the service account → check **Enable Google Workspace Domain-wide Delegation** → Save
+2. Note the **Client ID** (long numeric string)
+
+### 4. Authorize Scopes in Google Admin
+1. Go to https://admin.google.com
+2. Security → Access and data control → API controls → Manage Domain Wide Delegation
+3. Add new entry:
+ - **Client ID:** *(your service account client ID)*
+ - **OAuth Scopes:**
+ ```
+ https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/gmail.settings.basic
+ ```
+
+---
+
+## Unraid Deployment
+
+### 1. Clone the repo
+```bash
+cd /mnt/user/appdata
+git clone https://github.com/YOURUSERNAME/email-sig-manager.git
+cd email-sig-manager
+```
+
+### 2. Place the service account key
+```bash
+# Copy sa.json to the secrets folder (NOT tracked by git)
+cp /path/to/sa.json secrets/sa.json
+```
+
+### 3. Add your company logo
+```
+public/assets/logo.png
+```
+
+### 4. Add container in Unraid Docker UI
+- **Repository:** *(or build from path)*
+- **Port:** Map host port → `3000`
+- **Volumes:**
+ - `/mnt/user/appdata/email-sig-manager/secrets` → `/app/secrets`
+ - `/mnt/user/appdata/email-sig-manager/data` → `/app/data`
+ - `/mnt/user/appdata/email-sig-manager/public/assets` → `/app/public/assets`
+- **Variables:** Add all variables from the table above
+
+### 5. Build and start
+```bash
+docker-compose up -d --build
+```
+Or use the Unraid UI to start the container after configuring it.
+
+### Access the UI
+```
+http://UNRAID-IP:3000
+```
+
+---
+
+## First Run Checklist
+
+- [ ] `secrets/sa.json` is in place on the host
+- [ ] All environment variables are set in Unraid Docker UI
+- [ ] Logo placed at `public/assets/logo.png`
+- [ ] Open UI → Template Editor → preview looks correct
+- [ ] Push single user (yourself) to verify Gmail signature
+- [ ] Push to all users
+
+---
+
+## Updating
+
+```bash
+cd /mnt/user/appdata/email-sig-manager
+git pull
+docker-compose up -d --build
+```
+
+`secrets/`, `data/`, and any host-managed files are gitignored and unaffected by pulls.
+
+---
+
+## Project Structure
+
+```
+email-sig-manager/
+├── src/
+│ ├── index.js # Express app entry
+│ ├── routes/
+│ │ ├── admin.js # Template, logs, users API
+│ │ └── push.js # Signature push logic + batch runner
+│ ├── services/
+│ │ ├── googleAdmin.js # Directory API — fetch all users
+│ │ ├── gmailApi.js # Gmail sendAs patch
+│ │ ├── renderer.js # Handlebars template renderer
+│ │ └── scheduler.js # node-cron nightly job
+│ └── db/
+│ ├── sqlite.js # DB init and connection
+│ └── audit.js # Audit log read/write
+├── templates/
+│ └── default.hbs # 2-column HTML signature template
+├── public/
+│ ├── dashboard.html # Admin dashboard UI
+│ ├── editor.html # Template editor with live preview
+│ └── assets/
+│ └── logo.png # Company logo (add your own)
+├── secrets/ # Gitignored — sa.json goes here
+├── data/ # Gitignored — SQLite DB lives here
+├── Dockerfile
+├── docker-compose.yml
+├── package.json
+└── .env.example # Variable reference — safe to commit
+```
+
+---
+
+## Cron Schedule Reference
+
+| Expression | Meaning |
+|---|---|
+| `0 2 * * *` | 2:00 AM every day *(default)* |
+| `0 6 * * *` | 6:00 AM every day |
+| `0 2 * * 1` | 2:00 AM every Monday |
+| `0 2 1 * *` | 2:00 AM on the 1st of each month |
+
+---
+
+## Troubleshooting
+
+**Container won't start**
+Run `docker logs email-sig-manager` — most likely a missing env variable or malformed `sa.json`.
+
+**"No primary sendAs" error**
+The user may not have an active Gmail account, or domain-wide delegation scopes weren't saved correctly.
+
+**Signatures not showing on mobile**
+Gmail iOS/Android automatically uses the web signature. Have the user force-close and reopen the app.
+
+**Template changes not saving**
+Verify the container has write access to the `templates/` directory. A `.bak` file is created on every save.
+
+---
+
+## Security Notes
+
+- `sa.json` grants impersonation rights to every user in your domain — treat it like a master key
+- Never commit `sa.json` or a populated `.env` to GitHub
+- Change `ADMIN_PASSWORD` before first deployment
+- Consider placing the UI behind HTTPS if accessible outside your LAN
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..ec535ab
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,32 @@
+version: "3.8"
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Email Signature Manager
+#
+# Environment variables are passed in at runtime — not stored in this file.
+#
+# On Unraid: Set each variable in the Docker container template UI
+# For local dev: Create a .env file (see .env.example) and run:
+# docker-compose --env-file .env up -d --build
+# ─────────────────────────────────────────────────────────────────────────────
+
+services:
+ email-sig-manager:
+ build: .
+ container_name: email-sig-manager
+ restart: unless-stopped
+ ports:
+ - "${PORT:-3000}:3000"
+ volumes:
+ - ./secrets:/app/secrets:ro
+ - ./data:/app/data
+ - ./public/assets:/app/public/assets
+ environment:
+ - GOOGLE_ADMIN_EMAIL
+ - GOOGLE_CUSTOMER_ID
+ - SERVICE_ACCOUNT_KEY_PATH=${SERVICE_ACCOUNT_KEY_PATH:-/app/secrets/sa.json}
+ - ADMIN_USERNAME
+ - ADMIN_PASSWORD
+ - PORT=${PORT:-3000}
+ - CRON_SCHEDULE=${CRON_SCHEDULE:-0 2 * * *}
+ - NODE_ENV=${NODE_ENV:-production}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..5a1072f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "email-sig-manager",
+ "version": "1.0.0",
+ "description": "Google Workspace email signature manager",
+ "main": "src/index.js",
+ "scripts": {
+ "start": "node src/index.js",
+ "dev": "nodemon src/index.js"
+ },
+ "dependencies": {
+ "better-sqlite3": "^9.4.3",
+ "express": "^4.18.3",
+ "express-basic-auth": "^1.2.1",
+ "googleapis": "^140.0.1",
+ "handlebars": "^4.7.8",
+ "node-cron": "^3.0.3"
+ },
+ "devDependencies": {
+ "nodemon": "^3.1.0"
+ }
+}
diff --git a/public/dashboard.html b/public/dashboard.html
new file mode 100644
index 0000000..5f5d1b1
--- /dev/null
+++ b/public/dashboard.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+ Signature Manager — Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Timestamp | User | Name | Status | Message |
+ | Loading... |
+
+
+
+
+
diff --git a/public/editor.html b/public/editor.html
new file mode 100644
index 0000000..3a259d5
--- /dev/null
+++ b/public/editor.html
@@ -0,0 +1,99 @@
+
+
+
+
+
+ Signature Manager — Template Editor
+
+
+
+
+
+
+
+
Handlebars Template (HTML)
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/db/audit.js b/src/db/audit.js
new file mode 100644
index 0000000..967220e
--- /dev/null
+++ b/src/db/audit.js
@@ -0,0 +1,18 @@
+const { getDb } = require('./sqlite');
+const crypto = require('crypto');
+
+function logPush({ userEmail, displayName, status, message, sigHtml }) {
+ const db = getDb();
+ const sigHash = sigHtml ? crypto.createHash('md5').update(sigHtml).digest('hex') : null;
+ db.prepare(`
+ INSERT INTO audit_log (timestamp, user_email, display_name, status, message, sig_hash)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `).run(new Date().toISOString(), userEmail, displayName || null, status, message || null, sigHash);
+}
+
+function getRecentLogs(limit = 200) {
+ const db = getDb();
+ return db.prepare(`SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ?`).all(limit);
+}
+
+module.exports = { logPush, getRecentLogs };
diff --git a/src/db/sqlite.js b/src/db/sqlite.js
new file mode 100644
index 0000000..bf53584
--- /dev/null
+++ b/src/db/sqlite.js
@@ -0,0 +1,41 @@
+const Database = require('better-sqlite3');
+const path = require('path');
+
+const DB_PATH = path.join(__dirname, '../../data/signatures.db');
+let db;
+
+function getDb() {
+ if (!db) db = new Database(DB_PATH);
+ return db;
+}
+
+function initDb() {
+ const db = getDb();
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS audit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp TEXT NOT NULL,
+ user_email TEXT NOT NULL,
+ display_name TEXT,
+ status TEXT NOT NULL,
+ message TEXT,
+ sig_hash TEXT
+ );
+ CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS template_overrides (
+ user_email TEXT PRIMARY KEY,
+ custom_html TEXT,
+ updated_at TEXT
+ );
+ `);
+ const existing = db.prepare("SELECT value FROM settings WHERE key='template_name'").get();
+ if (!existing) {
+ db.prepare("INSERT INTO settings (key, value) VALUES (?, ?)").run('template_name', 'default');
+ }
+ console.log('SQLite database initialized');
+}
+
+module.exports = { getDb, initDb };
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..3df14f5
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,43 @@
+require('dotenv').config();
+const express = require('express');
+const path = require('path');
+const basicAuth = require('express-basic-auth');
+const { initDb } = require('./db/sqlite');
+const adminRoutes = require('./routes/admin');
+const pushRoutes = require('./routes/push');
+const { startScheduler } = require('./services/scheduler');
+
+const app = express();
+const PORT = process.env.PORT || 3000;
+
+initDb();
+
+const auth = basicAuth({
+ users: { [process.env.ADMIN_USERNAME || 'admin']: process.env.ADMIN_PASSWORD || 'changeme' },
+ challenge: true,
+ realm: 'Email Signature Manager'
+});
+
+app.use(express.json());
+app.use(express.static(path.join(__dirname, '../public')));
+
+app.use('/api/admin', auth, adminRoutes);
+app.use('/api/push', auth, pushRoutes);
+
+app.get('/dashboard', auth, (req, res) => {
+ res.sendFile(path.join(__dirname, '../public/dashboard.html'));
+});
+app.get('/editor', auth, (req, res) => {
+ res.sendFile(path.join(__dirname, '../public/editor.html'));
+});
+app.get('/', auth, (req, res) => {
+ res.redirect('/dashboard');
+});
+
+startScheduler();
+
+app.listen(PORT, () => {
+ console.log(`Email Signature Manager running on port ${PORT}`);
+ console.log(`Admin: ${process.env.ADMIN_USERNAME || 'admin'}`);
+ console.log(`Cron: ${process.env.CRON_SCHEDULE || '0 2 * * *'}`);
+});
diff --git a/src/routes/admin.js b/src/routes/admin.js
new file mode 100644
index 0000000..3a26b37
--- /dev/null
+++ b/src/routes/admin.js
@@ -0,0 +1,58 @@
+const express = require('express');
+const router = express.Router();
+const fs = require('fs');
+const path = require('path');
+const { getRecentLogs } = require('../db/audit');
+const { getAllUsers } = require('../services/googleAdmin');
+
+router.get('/logs', (req, res) => {
+ const logs = getRecentLogs(parseInt(req.query.limit) || 200);
+ res.json(logs);
+});
+
+router.get('/users', async (req, res) => {
+ try {
+ const users = await getAllUsers();
+ res.json(users);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+router.get('/template', (req, res) => {
+ const templatePath = path.join(__dirname, '../../templates/default.hbs');
+ const content = fs.readFileSync(templatePath, 'utf8');
+ res.json({ content });
+});
+
+router.post('/template', (req, res) => {
+ const { content } = req.body;
+ if (!content) return res.status(400).json({ error: 'content required' });
+ const templatePath = path.join(__dirname, '../../templates/default.hbs');
+ fs.writeFileSync(templatePath + '.bak', fs.readFileSync(templatePath));
+ fs.writeFileSync(templatePath, content);
+ res.json({ ok: true });
+});
+
+router.post('/preview', (req, res) => {
+ const { templateHtml, userData } = req.body;
+ try {
+ const Handlebars = require('handlebars');
+ Handlebars.registerHelper('if_val', function(val, options) {
+ return val && val.trim() !== '' ? options.fn(this) : options.inverse(this);
+ });
+ const template = Handlebars.compile(templateHtml);
+ const rendered = template(userData || {
+ fullName: 'Jason Stedwell',
+ title: 'Director of Technical Services',
+ email: 'jstedwell@messagepointmedia.com',
+ phone: '334-707-2550',
+ cellPhone: ''
+ });
+ res.json({ html: rendered });
+ } catch (err) {
+ res.status(400).json({ error: err.message });
+ }
+});
+
+module.exports = router;
diff --git a/src/routes/push.js b/src/routes/push.js
new file mode 100644
index 0000000..e2d7271
--- /dev/null
+++ b/src/routes/push.js
@@ -0,0 +1,54 @@
+const express = require('express');
+const router = express.Router();
+const { getAllUsers } = require('../services/googleAdmin');
+const { pushSignatureToUser } = require('../services/gmailApi');
+const { renderSignature } = require('../services/renderer');
+const { logPush } = require('../db/audit');
+
+async function runBatchPush(targetEmail = null) {
+ const users = await getAllUsers();
+ const targets = targetEmail ? users.filter(u => u.email === targetEmail) : users;
+ const results = [];
+
+ for (const user of targets) {
+ if (user.suspended) {
+ results.push({ email: user.email, status: 'skipped', message: 'Account suspended' });
+ continue;
+ }
+ try {
+ const sigHtml = renderSignature(user);
+ await pushSignatureToUser(user.email, sigHtml);
+ logPush({ userEmail: user.email, displayName: user.fullName, status: 'success', sigHtml });
+ results.push({ email: user.email, status: 'success' });
+ } catch (err) {
+ logPush({ userEmail: user.email, displayName: user.fullName, status: 'error', message: err.message });
+ results.push({ email: user.email, status: 'error', message: err.message });
+ }
+ }
+
+ console.log(`Batch push complete: ${results.filter(r => r.status === 'success').length} success, ${results.filter(r => r.status === 'error').length} errors`);
+ return results;
+}
+
+router.post('/all', async (req, res) => {
+ try {
+ const results = await runBatchPush();
+ res.json({ ok: true, results });
+ } catch (err) {
+ res.status(500).json({ ok: false, error: err.message });
+ }
+});
+
+router.post('/user', async (req, res) => {
+ const { email } = req.body;
+ if (!email) return res.status(400).json({ ok: false, error: 'email required' });
+ try {
+ const results = await runBatchPush(email);
+ res.json({ ok: true, results });
+ } catch (err) {
+ res.status(500).json({ ok: false, error: err.message });
+ }
+});
+
+module.exports = router;
+module.exports.runBatchPush = runBatchPush;
diff --git a/src/services/gmailApi.js b/src/services/gmailApi.js
new file mode 100644
index 0000000..f9ad23e
--- /dev/null
+++ b/src/services/gmailApi.js
@@ -0,0 +1,17 @@
+const { google } = require('googleapis');
+const { getAuthClient } = require('./googleAdmin');
+
+async function pushSignatureToUser(userEmail, signatureHtml) {
+ const auth = getAuthClient(userEmail);
+ const gmail = google.gmail({ version: 'v1', auth });
+ const sendAsRes = await gmail.users.settings.sendAs.list({ userId: 'me' });
+ const primary = sendAsRes.data.sendAs.find(s => s.isPrimary);
+ if (!primary) throw new Error(`No primary sendAs found for ${userEmail}`);
+ await gmail.users.settings.sendAs.patch({
+ userId: 'me',
+ sendAsEmail: primary.sendAsEmail,
+ requestBody: { signature: signatureHtml }
+ });
+}
+
+module.exports = { pushSignatureToUser };
diff --git a/src/services/googleAdmin.js b/src/services/googleAdmin.js
new file mode 100644
index 0000000..f49c921
--- /dev/null
+++ b/src/services/googleAdmin.js
@@ -0,0 +1,46 @@
+const { google } = require('googleapis');
+
+function getAuthClient(subjectEmail) {
+ return new google.auth.GoogleAuth({
+ keyFile: process.env.SERVICE_ACCOUNT_KEY_PATH || '/app/secrets/sa.json',
+ scopes: [
+ 'https://www.googleapis.com/auth/admin.directory.user.readonly',
+ 'https://www.googleapis.com/auth/gmail.settings.basic'
+ ],
+ clientOptions: {
+ subject: subjectEmail || process.env.GOOGLE_ADMIN_EMAIL
+ }
+ });
+}
+
+async function getAllUsers() {
+ const auth = getAuthClient(process.env.GOOGLE_ADMIN_EMAIL);
+ const admin = google.admin({ version: 'directory_v1', auth });
+ let users = [];
+ let pageToken;
+ do {
+ const res = await admin.users.list({
+ customer: process.env.GOOGLE_CUSTOMER_ID || 'my_customer',
+ maxResults: 200,
+ orderBy: 'email',
+ projection: 'full',
+ pageToken
+ });
+ users = users.concat(res.data.users || []);
+ pageToken = res.data.nextPageToken;
+ } while (pageToken);
+
+ return users.map(u => ({
+ email: u.primaryEmail,
+ fullName: u.name?.fullName || '',
+ firstName: u.name?.givenName || '',
+ lastName: u.name?.familyName || '',
+ title: u.organizations?.[0]?.title || '',
+ department: u.organizations?.[0]?.department || '',
+ phone: u.phones?.find(p => p.type === 'work')?.value || '',
+ cellPhone: u.phones?.find(p => p.type === 'mobile')?.value || '',
+ suspended: u.suspended || false
+ }));
+}
+
+module.exports = { getAllUsers, getAuthClient };
diff --git a/src/services/renderer.js b/src/services/renderer.js
new file mode 100644
index 0000000..9816887
--- /dev/null
+++ b/src/services/renderer.js
@@ -0,0 +1,16 @@
+const Handlebars = require('handlebars');
+const fs = require('fs');
+const path = require('path');
+
+Handlebars.registerHelper('if_val', function(val, options) {
+ return val && val.trim() !== '' ? options.fn(this) : options.inverse(this);
+});
+
+function renderSignature(userData, templateName = 'default') {
+ const templatePath = path.join(__dirname, '../../templates', `${templateName}.hbs`);
+ const source = fs.readFileSync(templatePath, 'utf8');
+ const template = Handlebars.compile(source);
+ return template(userData);
+}
+
+module.exports = { renderSignature };
diff --git a/src/services/scheduler.js b/src/services/scheduler.js
new file mode 100644
index 0000000..375c998
--- /dev/null
+++ b/src/services/scheduler.js
@@ -0,0 +1,13 @@
+const cron = require('node-cron');
+const { runBatchPush } = require('../routes/push');
+
+function startScheduler() {
+ const schedule = process.env.CRON_SCHEDULE || '0 2 * * *';
+ console.log(`Scheduler set: ${schedule}`);
+ cron.schedule(schedule, async () => {
+ console.log('Starting nightly signature batch push...');
+ await runBatchPush();
+ });
+}
+
+module.exports = { startScheduler };
diff --git a/templates/default.hbs b/templates/default.hbs
new file mode 100644
index 0000000..ba68fc1
--- /dev/null
+++ b/templates/default.hbs
@@ -0,0 +1,22 @@
+
+
+
+
+ |
+
+ {{fullName}}
+ {{title}}
+
+ {{email}}
+
+ {{#if_val phone}}
+ O: {{phone}}
+ {{/if_val}}
+ {{#if_val cellPhone}}
+ C: {{cellPhone}}
+ {{/if_val}}
+ |
+
+