Send personalised, password-protected payslips to your whole team β safely, in batches, from a single page
Features β’ Quick Start β’ Payslips β’ Configuration β’ How It Works β’ Troubleshooting
| Feature | Description |
|---|---|
| π CSV Recipients | Upload any CSV with an email column β extra columns become template variables |
| βοΈ Personalisation | Use {name}, {department}, or any CSV column in subject and body |
| π Attachment | Attach one file (PDF, etc.) up to 25 MB β same file goes to everyone |
| π’ Batched Sending | Configurable batch size + pause between batches β never hammers the mail server |
| β° Scheduling | Set a future start time or send immediately |
| π Daily Cap | Rolling 24-hour send limit keeps you inside Gmail / Workspace quotas |
| π Crash-Safe Resume | SQLite-backed β restarts pick up exactly where they left off |
| π Live Dashboard | Sent / pending / failed counts, progress bar, pause / resume / stop / retry |
| π UI Password | Optional APP_PASSWORD locks the web UI for cloud deployments |
| π Payslip Sender | AI-matched, NI-password-protected, per-recipient PDF payslips |
| Requirement | Version |
|---|---|
| Docker | 20+ (recommended) |
| Node.js | 20+ (without Docker) |
| Gmail / Workspace | App Password required |
| Account Type | Daily Limit |
|---|---|
| Google Workspace | ~2,000 recipients/day |
| Free @gmail.com | ~500 recipients/day |
git clone https://github.com/CaputoDavide93/Payroll-Mail-Service.git
cd Payroll-Mail-Servicecp .env.example .env
# Edit .env with your SMTP credentials and a strong APP_PASSWORDdocker compose up -d --buildNavigate to http://localhost:3000 and click βοΈ Settings to enter your Gmail App Password.
To stop: docker compose down β your data survives in the mail-data Docker volume.
npm install
npm start
# open http://localhost:3000App Passwords require 2-Step Verification to be enabled on your Google account.
- Enable 2-Step Verification: https://myaccount.google.com/signinoptions/twosv
- Create an App Password: https://myaccount.google.com/apppasswords
- Name it Payroll Mail Service β Create
- Copy the 16-character password shown
- Paste it into βοΈ Settings in the app
name,email,department
Alice Smith,alice@example.com,Engineering
Bob Jones,bob@example.com,Financeemailis required;nameis recommended- Any extra column (
department,location, etc.) can be used as{department}in the email - Duplicate or invalid emails are skipped and reported
A ready-to-edit sample-recipients.csv is included.
- Fill in New send: campaign name, subject, body (use
{name}for the greeting) - Upload your CSV and optional attachment (PDF, etc., up to 25 MB)
- Set batch size (10 is safe) and interval (60 s is gentle)
- Optionally set a schedule start time, or tick "Create without sending (draft)"
- Click Create & send β you can close the browser, sending continues on the server
Use Send preview on any campaign to email yourself the rendered message with the real attachment before it goes to 600 people.
A dedicated workflow for sending each employee their own password-protected PDF payslip.
| Step | What Happens |
|---|---|
| 1. Upload | Upload the employee Excel file + a ZIP of all payslip PDFs |
| 2. AI Match | Claude AI + fuzzy matching pairs each PDF to the right employee by name |
| 3. Protect | Each PDF is encrypted with the employee's NI number as the password (256-bit AES via qpdf) |
| 4. Pre-flight | Optional AI review flags suspicious pairings before a single email is sent |
| 5. Send | A per-recipient campaign is created β each person gets only their own payslip |
| 6. Cleanup | Delete all PDFs and match data from the server when done |
| Column | Notes |
|---|---|
EENo |
Employee number |
FullName |
Used for PDF matching |
NI No |
Used as the PDF password β never stored or logged |
Email Address |
Delivery address |
- NI numbers are never stored, logged, or returned by any API β used only at the moment of PDF encryption
- Raw (unprotected) PDFs are deleted from disk as soon as protection completes
- The Anthropic API key can be provided via
ANTHROPIC_API_KEYor in Settings β stored locally, never echoed back to the UI - Full data-handling policy and vulnerability reporting: SECURITY.md
Click π Payslips in the top navigation bar of the app.
Set in βοΈ Settings in the UI, or seed via environment variables. Copy .env.example to .env β Docker Compose reads it automatically.
| Variable | Purpose | Default |
|---|---|---|
SMTP_HOST |
Mail server hostname | smtp.gmail.com |
SMTP_PORT |
465 (SSL) or 587 (STARTTLS) |
465 |
SMTP_USER |
Gmail / Workspace login address | β |
SMTP_PASS |
App Password | β |
FROM_EMAIL |
Address shown as sender | SMTP_USER |
FROM_NAME |
Name shown as sender | β |
DAILY_LIMIT |
Max emails per rolling 24 h | 1800 |
APP_PASSWORD |
Locks the web UI (recommended on cloud) | β (off) |
ANTHROPIC_API_KEY |
Enables AI matching + pre-flight check for payslips | β (off) |
PORT |
Port to serve on | 3000 |
DATA_DIR |
Database + uploads location | data (/data in Docker) |
The app is a single container β deploy it anywhere Docker runs. Close your laptop and batches keep sending on schedule.
# 1. Install Docker
sudo apt-get update && sudo apt-get install -y docker.io git # Debian/Ubuntu
# or: sudo dnf install -y docker git # Amazon Linux / RHEL
sudo systemctl enable --now docker
sudo usermod -aG docker $USER # log out/in after this
# 2. Deploy the app
git clone https://github.com/CaputoDavide93/Payroll-Mail-Service.git payroll-mail
cd payroll-mail
cp .env.example .env
nano .env # set SMTP_USER, SMTP_PASS, FROM_EMAIL, FROM_NAME, APP_PASSWORD
docker compose up -d --buildAccess via SSH tunnel (simplest β no public port needed):
ssh -L 3000:localhost:3000 user@<server-ip>
# open http://localhost:3000 on your laptopFor team access, put Caddy or Nginx in front on port 443 for automatic HTTPS.
Security: Always set
APP_PASSWORDon any internet-facing server β without it the API is fully open.
Sending hundreds of near-identical emails is exactly what spam filters watch for. Work through this checklist once with whoever manages your DNS:
- SPF β TXT record authorising Google to send:
v=spf1 include:_spf.google.com ~all - DKIM β enable in Google Admin (Apps β Gmail β Authenticate email) and publish the key
- DMARC β start gentle:
v=DMARC1; p=none; rua=mailto:you@yourdomain.com - Test first β send a preview to a Gmail and an Outlook address before the full blast
- Keep From on your domain β don't send "as" an outside address
- Real text in the body β not just an image; include a contact or unsubscribe line
- Warm up gently β default 10-per-batch with 60 s gap spreads 600 over ~1 hour
- Backend: Node.js + Express. SMTP via
nodemailer. - State: Single SQLite file (
better-sqlite3) underDATA_DIRholds settings, campaigns, and every recipient's status β what makes crash-safe resume possible. - Worker: Background loop wakes every 2 s, promotes scheduled campaigns, sends the next batch (respecting the pause and daily cap), retries each failed send up to 3 times, and marks the campaign completed when the queue is empty.
- Atomic claim: Before sending a batch, recipients are marked
sendingin a single transaction β a crash can't cause double-sends; the startup hook resetssending β pending. - Payslips: AI + fuzzy name matching,
qpdf256-bit AES encryption, raw PDFs deleted immediately after protection, NI numbers never persisted. - Frontend: Static HTML/CSS/JS β no build step.
server.js Express app + API routes
src/db.js SQLite schema + migrations
src/settings.js SMTP settings (env seeding, secret masking)
src/mailer.js Transport, template rendering, sending
src/parseRecipients.js CSV parsing & validation
src/campaigns.js Campaign / recipient queries
src/worker.js Background batch-sending loop
src/preparePayslips.js Payslip pipeline (match β protect β manage)
src/matchAttachments.js AI + fuzzy PDF-to-employee matching
public/ Web UI (campaigns + payslips pages)
Dockerfile
docker-compose.yml
.env.example
- Delivery is "at least once." A recipient is marked sent only after the mail server accepts it. If the process is killed in the tiny window between acceptance and the DB write, that one recipient may get the email twice on restart. For payroll, a duplicate is far less harmful than a missed payslip.
- App Password stored in SQLite under
DATA_DIR. Treat that volume as a secret and rotate the password in your Google account if it's ever exposed. - Five wrong UI-password attempts from one IP triggers a one-minute lockout.
- One attachment per standard campaign, up to 25 MB. Payslips use per-recipient attachments with no size limit beyond disk space.
- Daily limit is a rolling 24-hour window, not a calendar day.
β Emails not sending β "Missing SMTP configuration"
Open βοΈ Settings, fill in all SMTP fields, and click Save. Then use Send test to confirm the connection works before creating a campaign.
β Test email goes to spam
Check SPF, DKIM, and DMARC are configured on your domain (see Deliverability). DKIM is the most important one.
β Payslip preparation fails β "qpdf is not installed"
qpdf is included in the Docker image. If running without Docker, install it:
# Debian / Ubuntu
sudo apt install qpdf
# macOS
brew install qpdfβ No AI matching β payslips only use fuzzy match
Set the ANTHROPIC_API_KEY environment variable in your .env file and rebuild the container, or paste the key in Settings (it's stored locally and never echoed back).
β Container starts but UI shows a blank page
docker logs payroll-mail-serviceCheck for port conflicts (something else on 3000) or missing env vars.
β Data lost after docker compose down
Data lives in the mail-data Docker volume. docker compose down preserves it. Only docker compose down -v removes it.
Contributions are welcome! Please open an issue first to discuss large changes.
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the MIT License β see the LICENSE file for details.
β If this tool helped you, please give it a star! ββΒ·βMade with β€οΈ by Davide Caputo