Add files via upload
This commit is contained in:
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -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"]
|
||||||
209
README.md
209
README.md
@@ -1 +1,208 @@
|
|||||||
# email-sigs
|
# 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
|
||||||
|
|||||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -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}
|
||||||
21
package.json
Normal file
21
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
120
public/dashboard.html
Normal file
120
public/dashboard.html
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Signature Manager — Dashboard</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: Arial, sans-serif; background: #1a1a1a; color: #eee; }
|
||||||
|
header { background: #111; border-bottom: 2px solid #C9A84C; padding: 14px 24px; display: flex; align-items: center; gap: 16px; }
|
||||||
|
header h1 { font-size: 18px; color: #C9A84C; }
|
||||||
|
nav a { color: #ccc; text-decoration: none; margin-left: 16px; font-size: 14px; }
|
||||||
|
nav a:hover { color: #C9A84C; }
|
||||||
|
main { padding: 24px; max-width: 1100px; margin: auto; }
|
||||||
|
.cards { display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||||
|
.card { background: #2a2a2a; border: 1px solid #444; border-radius: 8px; padding: 20px; flex: 1; min-width: 200px; }
|
||||||
|
.card h2 { font-size: 13px; color: #aaa; margin-bottom: 8px; }
|
||||||
|
.card .val { font-size: 28px; font-weight: bold; color: #C9A84C; }
|
||||||
|
.actions { display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; align-items: center; }
|
||||||
|
button { background: #C9A84C; color: #111; border: none; padding: 10px 20px; border-radius: 6px; font-weight: bold; cursor: pointer; font-size: 14px; }
|
||||||
|
button:hover { background: #e0bc5c; }
|
||||||
|
button.secondary { background: #333; color: #eee; border: 1px solid #555; }
|
||||||
|
button.secondary:hover { background: #444; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
th { text-align: left; padding: 10px 12px; background: #222; color: #C9A84C; border-bottom: 2px solid #C9A84C; }
|
||||||
|
td { padding: 8px 12px; border-bottom: 1px solid #333; vertical-align: top; }
|
||||||
|
tr:hover td { background: #2a2a2a; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: bold; }
|
||||||
|
.badge.success { background: #1a4a1a; color: #4caf50; }
|
||||||
|
.badge.error { background: #4a1a1a; color: #f44336; }
|
||||||
|
.badge.skipped { background: #3a3a1a; color: #aaa; }
|
||||||
|
#status-msg { margin-bottom: 12px; padding: 10px 14px; border-radius: 6px; display: none; }
|
||||||
|
#status-msg.ok { background: #1a4a1a; color: #4caf50; display: block; }
|
||||||
|
#status-msg.err { background: #4a1a1a; color: #f44336; display: block; }
|
||||||
|
input[type=email] { padding: 9px 12px; border-radius: 6px; border: 1px solid #555; background: #2a2a2a; color: #eee; font-size: 13px; width: 240px; }
|
||||||
|
.ts { color: #777; font-size: 11px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>✉ Email Signature Manager</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/dashboard">Dashboard</a>
|
||||||
|
<a href="/editor">Template Editor</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card"><h2>Total Users</h2><div class="val" id="stat-users">—</div></div>
|
||||||
|
<div class="card"><h2>Last Push</h2><div class="val" id="stat-last" style="font-size:14px;margin-top:6px;">—</div></div>
|
||||||
|
<div class="card"><h2>Last Run Success</h2><div class="val" id="stat-success">—</div></div>
|
||||||
|
<div class="card"><h2>Last Run Errors</h2><div class="val" id="stat-errors">—</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="pushAll()">▶ Push to All Users</button>
|
||||||
|
<button class="secondary" onclick="loadLogs()">↻ Refresh Logs</button>
|
||||||
|
<input id="single-email" type="email" placeholder="user@domain.com" />
|
||||||
|
<button class="secondary" onclick="pushSingle()">Push Single User</button>
|
||||||
|
</div>
|
||||||
|
<div id="status-msg"></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Timestamp</th><th>User</th><th>Name</th><th>Status</th><th>Message</th></tr></thead>
|
||||||
|
<tbody id="log-body"><tr><td colspan="5" style="color:#777;padding:20px;">Loading...</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
async function loadStats() {
|
||||||
|
const users = await fetch('/api/admin/users').then(r=>r.json()).catch(()=>[]);
|
||||||
|
document.getElementById('stat-users').textContent = Array.isArray(users) ? users.length : '—';
|
||||||
|
const logs = await fetch('/api/admin/logs?limit=500').then(r=>r.json()).catch(()=>[]);
|
||||||
|
if (logs.length) {
|
||||||
|
document.getElementById('stat-last').textContent = new Date(logs[0].timestamp).toLocaleString();
|
||||||
|
const recent = logs.slice(0,50);
|
||||||
|
document.getElementById('stat-success').textContent = recent.filter(l=>l.status==='success').length;
|
||||||
|
document.getElementById('stat-errors').textContent = recent.filter(l=>l.status==='error').length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadLogs() {
|
||||||
|
const logs = await fetch('/api/admin/logs?limit=200').then(r=>r.json()).catch(()=>[]);
|
||||||
|
const tbody = document.getElementById('log-body');
|
||||||
|
if (!logs.length) { tbody.innerHTML='<tr><td colspan="5" style="color:#777;">No logs yet.</td></tr>'; return; }
|
||||||
|
tbody.innerHTML = logs.map(l=>`
|
||||||
|
<tr>
|
||||||
|
<td class="ts">${new Date(l.timestamp).toLocaleString()}</td>
|
||||||
|
<td>${l.user_email}</td>
|
||||||
|
<td>${l.display_name||''}</td>
|
||||||
|
<td><span class="badge ${l.status}">${l.status}</span></td>
|
||||||
|
<td style="color:#888;font-size:12px;">${l.message||''}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
function showStatus(msg, ok) {
|
||||||
|
const el = document.getElementById('status-msg');
|
||||||
|
el.textContent = msg; el.className = ok ? 'ok' : 'err';
|
||||||
|
setTimeout(()=>{ el.className=''; el.textContent=''; }, 7000);
|
||||||
|
}
|
||||||
|
async function pushAll() {
|
||||||
|
if (!confirm('Push signatures to ALL users?')) return;
|
||||||
|
showStatus('Pushing to all users... this may take a minute.', true);
|
||||||
|
const res = await fetch('/api/push/all',{method:'POST'}).then(r=>r.json()).catch(e=>({ok:false,error:e.message}));
|
||||||
|
if (res.ok) {
|
||||||
|
const s=res.results.filter(r=>r.status==='success').length;
|
||||||
|
const e=res.results.filter(r=>r.status==='error').length;
|
||||||
|
showStatus(`Done! ${s} success, ${e} errors.`, e===0);
|
||||||
|
} else { showStatus('Error: '+res.error, false); }
|
||||||
|
loadLogs(); loadStats();
|
||||||
|
}
|
||||||
|
async function pushSingle() {
|
||||||
|
const email = document.getElementById('single-email').value.trim();
|
||||||
|
if (!email) return alert('Enter a user email first.');
|
||||||
|
showStatus(`Pushing to ${email}...`, true);
|
||||||
|
const res = await fetch('/api/push/user',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email})}).then(r=>r.json()).catch(e=>({ok:false,error:e.message}));
|
||||||
|
if (res.ok && res.results[0]?.status==='success') {
|
||||||
|
showStatus(`Success: signature pushed to ${email}`, true);
|
||||||
|
} else { showStatus('Error: '+(res.error||res.results?.[0]?.message||'unknown'), false); }
|
||||||
|
loadLogs();
|
||||||
|
}
|
||||||
|
loadStats(); loadLogs();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
99
public/editor.html
Normal file
99
public/editor.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Signature Manager — Template Editor</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: Arial, sans-serif; background: #1a1a1a; color: #eee; }
|
||||||
|
header { background: #111; border-bottom: 2px solid #C9A84C; padding: 14px 24px; display: flex; align-items: center; gap: 16px; }
|
||||||
|
header h1 { font-size: 18px; color: #C9A84C; }
|
||||||
|
nav a { color: #ccc; text-decoration: none; margin-left: 16px; font-size: 14px; }
|
||||||
|
nav a:hover { color: #C9A84C; }
|
||||||
|
main { padding: 24px; max-width: 1200px; margin: auto; }
|
||||||
|
.layout { display: flex; gap: 20px; flex-wrap: wrap; }
|
||||||
|
.panel { flex: 1; min-width: 300px; }
|
||||||
|
.panel h2 { font-size: 14px; color: #C9A84C; margin-bottom: 10px; }
|
||||||
|
textarea { width: 100%; height: 360px; background: #222; color: #eee; border: 1px solid #444; border-radius: 6px; padding: 12px; font-family: monospace; font-size: 12px; resize: vertical; }
|
||||||
|
.preview-box { background: #fff; border-radius: 6px; padding: 20px; min-height: 120px; border: 1px solid #444; }
|
||||||
|
.actions { display: flex; gap: 10px; margin-top: 16px; flex-wrap: wrap; }
|
||||||
|
button { background: #C9A84C; color: #111; border: none; padding: 10px 20px; border-radius: 6px; font-weight: bold; cursor: pointer; font-size: 14px; }
|
||||||
|
button:hover { background: #e0bc5c; }
|
||||||
|
button.secondary { background: #333; color: #eee; border: 1px solid #555; }
|
||||||
|
button.secondary:hover { background: #444; }
|
||||||
|
#status-msg { margin-top: 12px; padding: 10px 14px; border-radius: 6px; display: none; }
|
||||||
|
#status-msg.ok { background: #1a4a1a; color: #4caf50; display: block; }
|
||||||
|
#status-msg.err { background: #4a1a1a; color: #f44336; display: block; }
|
||||||
|
.test-fields { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.test-fields input { background: #2a2a2a; border: 1px solid #444; color: #eee; padding: 6px 10px; border-radius: 4px; font-size: 12px; width: calc(50% - 4px); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>✉ Email Signature Manager</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/dashboard">Dashboard</a>
|
||||||
|
<a href="/editor">Template Editor</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="layout">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Handlebars Template (HTML)</h2>
|
||||||
|
<textarea id="template-editor" spellcheck="false"></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="saveTemplate()">💾 Save Template</button>
|
||||||
|
<button class="secondary" onclick="loadTemplate()">↻ Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Live Preview</h2>
|
||||||
|
<div class="test-fields">
|
||||||
|
<input id="p-name" placeholder="Full Name" value="Jason Stedwell" oninput="updatePreview()"/>
|
||||||
|
<input id="p-title" placeholder="Job Title" value="Director of Technical Services" oninput="updatePreview()"/>
|
||||||
|
<input id="p-email" placeholder="Email" value="jstedwell@messagepointmedia.com" oninput="updatePreview()"/>
|
||||||
|
<input id="p-phone" placeholder="Office Phone" value="334-707-2550" oninput="updatePreview()"/>
|
||||||
|
<input id="p-cell" placeholder="Cell (optional)" value="" oninput="updatePreview()"/>
|
||||||
|
</div>
|
||||||
|
<div class="preview-box" id="preview-frame">Loading preview...</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="secondary" onclick="updatePreview()">↻ Refresh Preview</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="status-msg"></div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
async function loadTemplate() {
|
||||||
|
const res = await fetch('/api/admin/template').then(r=>r.json());
|
||||||
|
document.getElementById('template-editor').value = res.content;
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
async function saveTemplate() {
|
||||||
|
const content = document.getElementById('template-editor').value;
|
||||||
|
const res = await fetch('/api/admin/template',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content})}).then(r=>r.json());
|
||||||
|
showStatus(res.ok ? 'Template saved! A .bak backup was created.' : 'Error: '+res.error, res.ok);
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
async function updatePreview() {
|
||||||
|
const templateHtml = document.getElementById('template-editor').value;
|
||||||
|
const userData = {
|
||||||
|
fullName: document.getElementById('p-name').value,
|
||||||
|
title: document.getElementById('p-title').value,
|
||||||
|
email: document.getElementById('p-email').value,
|
||||||
|
phone: document.getElementById('p-phone').value,
|
||||||
|
cellPhone: document.getElementById('p-cell').value
|
||||||
|
};
|
||||||
|
const res = await fetch('/api/admin/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({templateHtml,userData})}).then(r=>r.json()).catch(()=>({error:'Preview failed'}));
|
||||||
|
document.getElementById('preview-frame').innerHTML = res.html || `<span style="color:red">${res.error}</span>`;
|
||||||
|
}
|
||||||
|
function showStatus(msg, ok) {
|
||||||
|
const el = document.getElementById('status-msg');
|
||||||
|
el.textContent = msg; el.className = ok ? 'ok' : 'err';
|
||||||
|
setTimeout(()=>{ el.className=''; el.textContent=''; }, 5000);
|
||||||
|
}
|
||||||
|
loadTemplate();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
src/db/audit.js
Normal file
18
src/db/audit.js
Normal file
@@ -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 };
|
||||||
41
src/db/sqlite.js
Normal file
41
src/db/sqlite.js
Normal file
@@ -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 };
|
||||||
43
src/index.js
Normal file
43
src/index.js
Normal file
@@ -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 * * *'}`);
|
||||||
|
});
|
||||||
58
src/routes/admin.js
Normal file
58
src/routes/admin.js
Normal file
@@ -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;
|
||||||
54
src/routes/push.js
Normal file
54
src/routes/push.js
Normal file
@@ -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;
|
||||||
17
src/services/gmailApi.js
Normal file
17
src/services/gmailApi.js
Normal file
@@ -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 };
|
||||||
46
src/services/googleAdmin.js
Normal file
46
src/services/googleAdmin.js
Normal file
@@ -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 };
|
||||||
16
src/services/renderer.js
Normal file
16
src/services/renderer.js
Normal file
@@ -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 };
|
||||||
13
src/services/scheduler.js
Normal file
13
src/services/scheduler.js
Normal file
@@ -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 };
|
||||||
22
templates/default.hbs
Normal file
22
templates/default.hbs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<table cellpadding="0" cellspacing="0" border="0" style="font-family: Arial, sans-serif; font-size: 13px; color: #333333;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-right: 16px; vertical-align: middle; border-right: 2px solid #C9A84C;">
|
||||||
|
<img src="cid:company-logo" alt="Messagepoint Media"
|
||||||
|
style="width: 80px; height: auto; display: block;"
|
||||||
|
onerror="this.style.display='none'" />
|
||||||
|
</td>
|
||||||
|
<td style="padding-left: 16px; vertical-align: middle;">
|
||||||
|
<p style="margin: 0 0 2px 0; font-weight: bold; font-size: 14px; color: #1a1a1a;">{{fullName}}</p>
|
||||||
|
<p style="margin: 0 0 2px 0; color: #555555; font-size: 12px;">{{title}}</p>
|
||||||
|
<p style="margin: 0 0 2px 0;">
|
||||||
|
<a href="mailto:{{email}}" style="color: #C9A84C; text-decoration: none;">{{email}}</a>
|
||||||
|
</p>
|
||||||
|
{{#if_val phone}}
|
||||||
|
<p style="margin: 0 0 2px 0; color: #333333;">O: {{phone}}</p>
|
||||||
|
{{/if_val}}
|
||||||
|
{{#if_val cellPhone}}
|
||||||
|
<p style="margin: 0 0 2px 0; color: #333333;">C: {{cellPhone}}</p>
|
||||||
|
{{/if_val}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
Reference in New Issue
Block a user