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 + + + +
+

✉ Email Signature Manager

+ +
+
+
+

Total Users

+

Last Push

+

Last Run Success

+

Last Run Errors

+
+
+ + + + +
+
+ + + +
TimestampUserNameStatusMessage
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 + + + +
+

✉ Email Signature Manager

+ +
+
+
+
+

Handlebars Template (HTML)

+ +
+ + +
+
+
+

Live Preview

+
+ + + + + +
+
Loading preview...
+
+ +
+
+
+
+
+ + + 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 @@ + + + + + +
+ Messagepoint Media + +

{{fullName}}

+

{{title}}

+

+ {{email}} +

+ {{#if_val phone}} +

O: {{phone}}

+ {{/if_val}} + {{#if_val cellPhone}} +

C: {{cellPhone}}

+ {{/if_val}} +