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:
280
email-worker/src/lib/emailSenders.ts
Normal file
280
email-worker/src/lib/emailSenders.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user