feat: implement email notification worker with Mailgun integration

- Add MongoDB connection module for database access
- Implement Mailgun email service for sending notifications
- Add shareChecksum utility for generating secure share links
- Implement three email sender functions:
  - Email verification requests (highest priority)
  - Rent due notifications (CET timezone)
  - Utility bills due notifications
- Create main email worker with budget-based email sending
- Add environment variables for configuration
- Install dependencies: mongodb, mailgun.js, form-data
- Update package.json description to reflect email worker purpose
- Add .env.example with all required configuration

The worker processes emails in priority order and respects a configurable
budget to prevent overwhelming the mail server. All database operations are
atomic and updates are performed immediately after each email send.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Knee Cola
2025-12-30 12:27:32 +01:00
parent 33ab06e22e
commit a901980a6f
11 changed files with 10408 additions and 3 deletions

26
email-worker/.env.example Normal file
View File

@@ -0,0 +1,26 @@
# Worker Configuration
PULL_INTERVAL=60000
EMAIL_BUDGET=10
# MongoDB Configuration
MONGODB_URI=mongodb://localhost:27017/utility-bills
# Mailgun Configuration
MAILGUN_API_KEY=your-mailgun-api-key-here
MAILGUN_DOMAIN=rezije.app
# Security
SHARE_LINK_SECRET=your-secret-key-here
# Server Configuration
PORT=3000
# Logging
DEBUG=worker:*,email:*,db:*
# Prometheus Metrics (optional)
PROMETHEUS_APP_LABEL=email-worker
PROMETHEUS_HISTOGRAM_BUCKETS=0.1, 0.5, 1, 5, 10
# Environment
ENV=dev

9788
email-worker/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "email-worker",
"version": "0.1.0",
"description": "Background worker service with HTTP health monitoring and metrics collection",
"description": "Email notification worker service for sending verification requests, rent due notices, and utility bills notifications",
"main": "entry.ts",
"scripts": {
"start": "nodemon ./src/entry.ts",
@@ -14,7 +14,10 @@
"dependencies": {
"debug": "^2.6.9",
"express": "^4.18.2",
"form-data": "^4.0.5",
"http-errors": "^1.7.2",
"mailgun.js": "^12.4.1",
"mongodb": "^7.0.0",
"node-fetch": "^2.6.7",
"prom-client": "^14.0.1",
"stoppable": "^1.1.0"
@@ -25,6 +28,7 @@
"@types/express": "^4.17.13",
"@types/http-errors": "^1.8.1",
"@types/jest": "^29.2.5",
"@types/mongodb": "^4.0.6",
"@types/node": "^16.10.2",
"@types/node-fetch": "^2.6.2",
"@types/stoppable": "^1.1.1",
@@ -46,4 +50,4 @@
"@babel/preset-typescript"
]
}
}
}

View File

@@ -0,0 +1,70 @@
import { connectToDatabase, disconnectFromDatabase } from './lib/dbClient';
import { sendVerificationRequests, sendRentDueNotifications, sendUtilityBillsNotifications } from './lib/emailSenders';
import { createLogger } from './lib/logger';
const log = createLogger("worker:email");
/**
* Email worker implementation
*
* Sends three types of emails in priority order:
* 1. Email verification requests (highest priority)
* 2. Rent due notifications
* 3. Utility bills due notifications
*
* Uses a budget system to limit total emails sent per run.
*/
export const doWork = async () => {
const startTime = Date.now();
const emailBudget = parseInt(process.env.EMAIL_BUDGET || '10', 10);
log(`Starting email worker run with budget: ${emailBudget}`);
let remainingBudget = emailBudget;
let totalSent = 0;
try {
// Connect to database
const db = await connectToDatabase();
// 1. Send verification requests (highest priority)
const verificationsSent = await sendVerificationRequests(db, remainingBudget);
totalSent += verificationsSent;
remainingBudget -= verificationsSent;
log(`Verification emails sent: ${verificationsSent}, remaining budget: ${remainingBudget}`);
// 2. Send rent due notifications
if (remainingBudget > 0) {
const rentSent = await sendRentDueNotifications(db, remainingBudget);
totalSent += rentSent;
remainingBudget -= rentSent;
log(`Rent due emails sent: ${rentSent}, remaining budget: ${remainingBudget}`);
}
// 3. Send utility bills notifications
if (remainingBudget > 0) {
const billsSent = await sendUtilityBillsNotifications(db, remainingBudget);
totalSent += billsSent;
remainingBudget -= billsSent;
log(`Utility bills emails sent: ${billsSent}, remaining budget: ${remainingBudget}`);
}
// Disconnect from database
await disconnectFromDatabase();
const workDuration = Date.now() - startTime;
log(`Email worker completed in ${workDuration}ms. Total emails sent: ${totalSent}`);
} catch (error) {
log(`Email worker failed: ${error}`);
// Try to disconnect even on error
try {
await disconnectFromDatabase();
} catch (disconnectError) {
log(`Failed to disconnect from database: ${disconnectError}`);
}
throw error; // Re-throw to mark work as failed
}
};

View File

@@ -0,0 +1,53 @@
import { MongoClient, Db } from 'mongodb';
import { createLogger } from './logger';
const log = createLogger("db:client");
let client: MongoClient | null = null;
let db: Db | null = null;
/**
* Connect to MongoDB
* @returns Database instance
*/
export async function connectToDatabase(): Promise<Db> {
if (!process.env.MONGODB_URI) {
throw new Error('MONGODB_URI environment variable is not set');
}
if (db) {
log('Reusing existing database connection');
return db;
}
log('Creating new database connection');
client = new MongoClient(process.env.MONGODB_URI);
await client.connect();
db = client.db("utility-bills");
log('Connected to database');
return db;
}
/**
* Disconnect from MongoDB
*/
export async function disconnectFromDatabase(): Promise<void> {
if (client) {
log('Disconnecting from database');
await client.close();
client = null;
db = null;
log('Disconnected from database');
}
}
/**
* Get current database instance (must call connectToDatabase first)
*/
export function getDatabase(): Db {
if (!db) {
throw new Error('Database not connected. Call connectToDatabase() first.');
}
return db;
}

View File

@@ -0,0 +1,280 @@
import { Db, ObjectId } from 'mongodb';
import { BillingLocation, EmailStatus, UserSettings } from '../types/db-types';
import { generateShareId } from './shareChecksum';
import { sendEmail } from './mailgunService';
import { createLogger } from './logger';
const log = createLogger("email:senders");
/**
* Send email verification requests
* @param db Database instance
* @param budget Remaining email budget
* @returns Number of emails sent
*/
export async function sendVerificationRequests(db: Db, budget: number): Promise<number> {
if (budget <= 0) {
log('Budget exhausted, skipping verification requests');
return 0;
}
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1; // JavaScript months are 0-indexed
log(`Fetching locations for verification: year=${currentYear}, month=${currentMonth}`);
const locations = await db.collection<BillingLocation>('lokacije')
.find({
'yearMonth.year': currentYear,
'yearMonth.month': currentMonth,
'tenantEmailStatus': EmailStatus.Unverified
})
.toArray();
log(`Found ${locations.length} locations needing verification`);
let sentCount = 0;
for (const location of locations) {
if (budget <= 0) {
log('Budget exhausted during verification sending');
break;
}
if (!location.tenantEmail) {
log(`Skipping location ${location._id}: no tenant email`);
continue;
}
// Fetch user settings
const userSettings = await db.collection<UserSettings>('userSettings')
.findOne({ userId: location.userId });
const ownerName = userSettings?.ownerName || '';
const shareId = generateShareId(location._id.toString());
const html = `
<p>Hello ${location.tenantName || 'there'}!</p>
<p>You have received this e-mail because your landlord <strong>${ownerName}</strong> wants to send you rent and utility bills invoices via rezije.app</p>
<p><strong>rezije.app</strong> is an online app which helps property owners to manage expenses related to properties they lease.</p>
<p>Before the app can start sending you rent due and utility bills emails we need your verification.</p>
<p>To verify that you want to receive these notifications please click on the following link: <a href="https://rezije.app/email/verify/${shareId}">Verify</a></p>
<p>You can ignore this email if you don't want to receive notifications. You can also unsubscribe at any time using the link included in every notification email you receive.</p>
<p>Thank you!</p>
<a href="https://rezije.app" target="_blank">rezije.app</a>
`;
const success = await sendEmail({
to: location.tenantEmail,
subject: 'Please verify your e-mail address',
html
});
// Update location status
const newStatus = success ? EmailStatus.VerificationPending : EmailStatus.VerificationFailed;
await db.collection('lokacije').updateOne(
{ _id: location._id },
{ $set: { tenantEmailStatus: newStatus } }
);
if (success) {
sentCount++;
log(`Verification email sent to ${location.tenantEmail}`);
} else {
log(`Failed to send verification email to ${location.tenantEmail}`);
}
budget--;
}
log(`Sent ${sentCount} verification emails`);
return sentCount;
}
/**
* Send rent due notifications
* @param db Database instance
* @param budget Remaining email budget
* @returns Number of emails sent
*/
export async function sendRentDueNotifications(db: Db, budget: number): Promise<number> {
if (budget <= 0) {
log('Budget exhausted, skipping rent due notifications');
return 0;
}
const now = new Date();
// Use CET timezone
const cetDate = new Date(now.toLocaleString('en-US', { timeZone: 'Europe/Belgrade' }));
const currentYear = cetDate.getFullYear();
const currentMonth = cetDate.getMonth() + 1;
const currentDay = cetDate.getDate();
log(`Fetching locations for rent due: year=${currentYear}, month=${currentMonth}, day=${currentDay}`);
const locations = await db.collection<BillingLocation>('lokacije')
.find({
'yearMonth.year': currentYear,
'yearMonth.month': currentMonth,
'tenantEmailStatus': EmailStatus.Verified,
'rentDueNotificationEnabled': true,
'rentDueDay': currentDay,
$or: [
{ 'rentDueNotificationStatus': { $exists: false } },
{ 'rentDueNotificationStatus': null }
]
})
.toArray();
log(`Found ${locations.length} locations needing rent due notifications`);
let sentCount = 0;
for (const location of locations) {
if (budget <= 0) {
log('Budget exhausted during rent due sending');
break;
}
if (!location.tenantEmail) {
log(`Skipping location ${location._id}: no tenant email`);
continue;
}
const shareId = generateShareId(location._id.toString());
const html = `
<p>Hello ${location.tenantName || 'there'}!</p>
<p>Your rent for the apartment ${location.name} is due today.</p>
<p>For details and payment options please click the following link: <a href="https://rezije.app/share/location/rent/${shareId}">Rent details</a></p>
<p>Thank you!</p>
<p><a href="https://rezije.app" target="_blank">rezije.app</a></p>
<p style="font-size:.7em">If you do no longer want to receive these notifications please click the following link: <a href="https://rezije.app/email/unsubscribe/${shareId}">Unsubscribe</a></p>
`;
const success = await sendEmail({
to: location.tenantEmail,
subject: `Rent due for ${location.tenantName || 'your apartment'}`,
html
});
// Update location status
const newStatus = success ? 'sent' : 'failed';
await db.collection('lokacije').updateOne(
{ _id: location._id },
{ $set: { rentDueNotificationStatus: newStatus } }
);
if (success) {
sentCount++;
log(`Rent due notification sent to ${location.tenantEmail}`);
} else {
log(`Failed to send rent due notification to ${location.tenantEmail}`);
}
budget--;
}
log(`Sent ${sentCount} rent due notifications`);
return sentCount;
}
/**
* Send utility bills due notifications
* @param db Database instance
* @param budget Remaining email budget
* @returns Number of emails sent
*/
export async function sendUtilityBillsNotifications(db: Db, budget: number): Promise<number> {
if (budget <= 0) {
log('Budget exhausted, skipping utility bills notifications');
return 0;
}
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
log(`Fetching locations for utility bills: year=${currentYear}, month=${currentMonth}`);
const locations = await db.collection<BillingLocation>('lokacije')
.find({
'yearMonth.year': currentYear,
'yearMonth.month': currentMonth,
'tenantEmailStatus': EmailStatus.Verified,
'billFwdEnabled': true,
'billFwdStatus': 'pending'
})
.toArray();
log(`Found ${locations.length} locations needing utility bills notifications`);
let sentCount = 0;
for (const location of locations) {
if (budget <= 0) {
log('Budget exhausted during utility bills sending');
break;
}
if (!location.tenantEmail) {
log(`Skipping location ${location._id}: no tenant email`);
continue;
}
const shareId = generateShareId(location._id.toString());
const html = `
<p>Hello ${location.tenantName || 'there'}!</p>
<p>Your utility bills for the apartment ${location.name} are due today.</p>
<p>For details and payment options please click the following link: <a href="https://rezije.app/share/location/bills/${shareId}">Utility Bills</a></p>
<p>Thank you!</p>
<p><a href="https://rezije.app" target="_blank">rezije.app</a></p>
<p style="font-size:.7em">If you do no longer want to receive these notifications please click the following link: <a href="https://rezije.app/email/unsubscribe/${shareId}">Unsubscribe</a></p>
`;
const success = await sendEmail({
to: location.tenantEmail,
subject: `Utility bills due for ${location.tenantName || 'your apartment'}`,
html
});
// Update location status
const newStatus = success ? 'sent' : 'failed';
await db.collection('lokacije').updateOne(
{ _id: location._id },
{ $set: { billFwdStatus: newStatus } }
);
if (success) {
sentCount++;
log(`Utility bills notification sent to ${location.tenantEmail}`);
} else {
log(`Failed to send utility bills notification to ${location.tenantEmail}`);
}
budget--;
}
log(`Sent ${sentCount} utility bills notifications`);
return sentCount;
}

View File

@@ -0,0 +1,63 @@
import formData from 'form-data';
import Mailgun from 'mailgun.js';
import { createLogger } from './logger';
const log = createLogger("email:mailgun");
export interface EmailMessage {
to: string;
subject: string;
html: string;
}
let mailgunClient: any = null;
/**
* Initialize Mailgun client
*/
function getMailgunClient() {
if (mailgunClient) {
return mailgunClient;
}
const apiKey = process.env.MAILGUN_API_KEY;
const domain = process.env.MAILGUN_DOMAIN || 'rezije.app';
if (!apiKey) {
throw new Error('MAILGUN_API_KEY environment variable is not set');
}
const mailgun = new Mailgun(formData);
mailgunClient = mailgun.client({ username: 'api', key: apiKey });
return mailgunClient;
}
/**
* Send an email using Mailgun
* @param message Email message to send
* @returns True if successful, false otherwise
*/
export async function sendEmail(message: EmailMessage): Promise<boolean> {
try {
const client = getMailgunClient();
const domain = process.env.MAILGUN_DOMAIN || 'rezije.app';
const messageData = {
from: 'rezije.app <noreply@rezije.app>',
to: message.to,
subject: message.subject,
html: message.html
};
log(`Sending email to ${message.to}: ${message.subject}`);
const response = await client.messages.create(domain, messageData);
log(`Email sent successfully to ${message.to}, ID: ${response.id}`);
return true;
} catch (error) {
log(`Failed to send email to ${message.to}: ${error}`);
return false;
}
}

View File

@@ -0,0 +1,36 @@
import crypto from 'crypto';
/**
* Checksum length in hex characters (16 chars = 64 bits of entropy)
*/
export const CHECKSUM_LENGTH = 16;
/**
* Generate share link checksum for location
* Uses HMAC-SHA256 for cryptographic integrity
*
* SECURITY: Prevents location ID enumeration while allowing stateless validation
*/
export function generateShareChecksum(locationId: string): string {
const secret = process.env.SHARE_LINK_SECRET;
if (!secret) {
throw new Error('SHARE_LINK_SECRET environment variable not configured');
}
return crypto
.createHmac('sha256', secret)
.update(locationId)
.digest('hex')
.substring(0, CHECKSUM_LENGTH);
}
/**
* Generate combined location ID with checksum appended
* @param locationId - The MongoDB location ID (24 chars)
* @returns Combined ID: locationId + checksum (40 chars total)
*/
export function generateShareId(locationId: string): string {
const checksum = generateShareChecksum(locationId);
return locationId + checksum;
}

View File

@@ -0,0 +1,54 @@
import { ObjectId } from 'mongodb';
export interface YearMonth {
year: number;
month: number;
}
export enum EmailStatus {
/** Email is not yet verified - recipient has not yet confirmed their email address */
Unverified = "unverified",
/** Email is not yet verified - a verification request has been sent */
VerificationPending = "verification-pending",
/** sending of verification email failed */
VerificationFailed = "verification-failed",
/** Email is verified and is in good standing: emails are being successfully delivered */
Verified = "verified",
/** Recepient has unsubscribed from receiving emails via link - no further emails will be sent */
Unsubscribed = "unsubscribed"
}
/** User settings data */
export interface UserSettings {
/** user's ID */
userId: string;
/** owner name */
ownerName?: string | null;
}
/** Billing location document from MongoDB */
export interface BillingLocation {
_id: ObjectId;
/** user's ID */
userId: string;
/** name of the location */
name: string;
/** billing period year and month */
yearMonth: YearMonth;
/** (optional) tenant name */
tenantName?: string | null;
/** (optional) tenant email */
tenantEmail?: string | null;
/** (optional) tenant email status */
tenantEmailStatus?: EmailStatus | null;
/** (optional) whether to automatically notify tenant */
billFwdEnabled?: boolean | null;
/** (optional) bill forwarding status */
billFwdStatus?: "pending" | "sent" | "failed" | null;
/** (optional) whether to automatically send rent notification */
rentDueNotificationEnabled?: boolean | null;
/** (optional) day of month when rent is due (1-31) */
rentDueDay?: number | null;
/** (optional) when was the rent due notification sent */
rentDueNotificationStatus?: "sent" | "failed" | null;
}

View File

@@ -22,6 +22,37 @@ declare global {
* @default "10000"
* */
PULL_INTERVAL:string
/**
* (required) MongoDB connection URI
* */
MONGODB_URI: string
/**
* (required) Mailgun API key for sending emails
* */
MAILGUN_API_KEY: string
/**
* (optional) Mailgun domain
* @default "rezije.app"
* */
MAILGUN_DOMAIN?: string
/**
* (required) Secret key for generating share link checksums
* */
SHARE_LINK_SECRET: string
/**
* (optional) Maximum number of emails to send per worker run
* @default "10"
* */
EMAIL_BUDGET?: string
/**
* (optional) HTTP server port
* @default "3000"
* */
PORT?: string
/**
* (optional) Debug namespaces for console logging
* */
DEBUG?: string
}
}
}

View File

@@ -2,7 +2,7 @@ import { failedRequestCounter, requestDurationHistogram, successfulRequestCounte
import { coalesce } from "./lib/initTools";
import { createLogger } from "./lib/logger";
import { serializeError } from "./lib/serializeError";
import { doWork } from "./exampleWorker";
import { doWork } from "./emailWorker";
/** time between two pull operations */
const PULL_INTERVAL = parseInt(coalesce(process.env.PULL_INTERVAL, "10000"));