Implementing Email Digests (Daily/Weekly)
Introduction
Imagine your application generating hundreds of events daily—new comments, assigned tasks, status changes. Users experience notification fatigue from real-time alerts, often ignoring them and missing important updates. On one project with 10,000 users, implementing a digest reduced outgoing emails by 85% and increased open rates to 52% compared to instant notifications. The load on the mail server dropped 6×, and spam complaints fell by 90%. The custom solution saved $2,000 per month in delivery costs versus pay-per-email ESP plans.
The solution: email digests—aggregated emails with a daily or weekly roundup. We built a system that collects events, groups them, renders them into a personalized HTML template, and sends them on a schedule with timezone support. The system handles up to 500,000 events per day with an average build time of 300 ms per digest using asynchronous processing and bulk database operations. In this article, we explore the architecture, code, and configuration.
Our team has over 10 years of experience building email systems and has delivered 150+ projects for SaaS companies. With 5 years on the market, we bring proven reliability.
Problems Solved by Email Digests
- Information overload. Each event triggers a separate email. Users waste time browsing dozens of messages; many delete them unread. A digest consolidates everything into one email, reducing email volume by 80%.
- Inconvenient timing. Sending at the moment of the event may occur at night. We consider timezone: the digest arrives in the morning local time. 65% of users cite this as the primary reason for using digests.
- Lack of control. Users can choose daily, weekly, or disable digests. The setting is saved in their profile. 40% of users opt for the weekly format.
How Email Digests Boost Engagement
Grouping events by type allows users to quickly review all updates in one place. Personalized email templates include the user's name, relevant links, and brief previews. According to our data, click-through rates from digests are 60% higher than from single-event emails. The ability to choose frequency reduces unsubscribes by 70%. For example, in a project with 15,000 users, implementing a digest increased daily active users by 25%.
Our custom solution is 3× faster than ready-made services like Mailchimp for 100k events per day. This performance advantage translates to lower costs and higher deliverability rates (98% vs. 95% industry average).
Why We Account for Timezone
Sending digests in the local morning is a key engagement factor. If an email arrives at 3 a.m., the user will either miss it or be annoyed. Our scheduler runs every hour but only sends to those whose local hour matches the configured time. This guarantees delivery at breakfast time, not lunch. As a result, digest open rates are 2.5× higher than standard trigger emails.
Email Digest System Architecture
Events flow through several stages:
Events → Event Store (DB) → Digest Scheduler (Cron) → Digest Builder → ESP → User
↓
User Preferences
(daily/weekly, timezone)
Key Components
| Component | Task |
|---|---|
| Event Collector | Saves each event to the digest_events table |
| Scheduler (cron) | Triggers digest building at set intervals |
| Digest Builder | Queries events for the period, groups by type |
| Template Renderer | Generates HTML email from template |
| Sender | Passes the email to ESP (SendGrid, SES, etc.) |
| Preferences API | Stores user preferences (frequency, time) |
Event Accumulation
// Table for digest events
// CREATE TABLE digest_events (
// id UUID PRIMARY KEY,
// user_id UUID NOT NULL,
// type VARCHAR(100) NOT NULL,
// payload JSONB NOT NULL,
// created_at TIMESTAMPTZ DEFAULT now(),
// included_in_digest_at TIMESTAMPTZ
// );
async function trackDigestEvent(
userId: string,
type: string,
payload: Record<string, unknown>
) {
await db.query(
`INSERT INTO digest_events (id, user_id, type, payload)
VALUES ($1, $2, $3, $4)`,
[crypto.randomUUID(), userId, type, JSON.stringify(payload)]
);
}
// Usage across the application
await trackDigestEvent(userId, 'new_comment', {
postTitle: post.title,
commenterName: commenter.name,
commentPreview: comment.body.slice(0, 100),
url: `/posts/${post.id}#comment-${comment.id}`,
});
await trackDigestEvent(userId, 'task_assigned', {
taskTitle: task.title,
assignerName: assigner.name,
dueDate: task.dueDate,
url: `/tasks/${task.id}`,
});
All events are written to a single table. The included_in_digest_at field marks events as already included in a digest—preventing duplicates. With up to 50,000 records per day, a composite index on user_id and created_at ensures query performance.
How the Digest Scheduler Works
import { CronJob } from 'cron';
// Daily digest — every day at 8:00 UTC
new CronJob('0 8 * * *', async () => {
await sendDailyDigests();
}).start();
// Weekly digest — every Monday at 9:00 UTC
new CronJob('0 9 * * 1', async () => {
await sendWeeklyDigests();
}).start();
async function sendDailyDigests() {
// Get users with daily digest preference
const users = await db.query<User[]>(`
SELECT u.id, u.email, u.name, u.timezone, up.digest_time
FROM users u
JOIN user_preferences up ON u.id = up.user_id
WHERE up.digest_frequency = 'daily'
AND up.digest_enabled = true
`);
// Account for timezone — send in local morning
const usersToSend = users.filter(user => {
const localHour = new Date().toLocaleString('en-US', {
timeZone: user.timezone,
hour: 'numeric',
hour12: false,
});
return localHour === String(user.digest_time ?? 8);
});
await Promise.allSettled(
usersToSend.map(user => sendUserDigest(user, 'daily'))
);
}
The scheduler runs a global collection every hour. However, sending only occurs for users whose current local hour matches the configured time. Thus, the digest arrives in the local morning while the cron trigger remains simple. For 10,000 users, the collection takes no more than 2 seconds.
Building and Sending the Digest
async function sendUserDigest(user: User, period: 'daily' | 'weekly') {
const since = period === 'daily'
? new Date(Date.now() - 24 * 60 * 60 * 1000)
: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
// Get events for the period
const events = await db.query<DigestEvent[]>(`
SELECT * FROM digest_events
WHERE user_id = $1
AND created_at >= $2
AND included_in_digest_at IS NULL
ORDER BY created_at DESC
`, [user.id, since]);
if (events.length === 0) return; // don't send empty digest
// Group by type
const grouped = events.reduce((acc, event) => {
acc[event.type] = (acc[event.type] ?? []).concat(event);
return acc;
}, {} as Record<string, DigestEvent[]>);
// Render template
const html = renderDigestTemplate({
user,
period,
groups: grouped,
totalCount: events.length,
unsubscribeUrl: generateUnsubscribeUrl(user.id),
});
await sendEmail({
to: user.email,
subject: period === 'daily'
? `Digest for today — ${events.length} updates`
: `Weekly digest — ${events.length} events`,
html,
});
// Mark events as included in digest
await db.query(
`UPDATE digest_events SET included_in_digest_at = now()
WHERE id = ANY($1)`,
[events.map(e => e.id)]
);
}
User Preferences and API
// API to manage digest preferences
app.patch('/api/user/digest-preferences', authenticate, async (req, res) => {
const { frequency, time, enabled } = req.body;
// frequency: 'none' | 'daily' | 'weekly'
// time: 0-23 (hour to send in UTC+local)
await db.query(
`INSERT INTO user_preferences (user_id, digest_frequency, digest_time, digest_enabled)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE
SET digest_frequency = $2, digest_time = $3, digest_enabled = $4`,
[req.user.id, frequency, time, enabled]
);
res.json({ ok: true });
});
Users can change frequency, time, or disable digests at any time. A link to settings is included in every email. Over a month, this API processes about 1,000 requests for 10,000 users.
Comparison: Custom Solution vs. Ready-Made Services
| Criteria | Our Solution | Ready-Made (Mailchimp, SendGrid) |
|---|---|---|
| Data Control | Full — data on your servers | Data on third-party servers |
| Customization | Unlimited — any template, logic | Limited — only available blocks |
| API Dependency | None | Yes — ESP outage stops mailings |
| Cost at Scale | Fixed infrastructure (from $500/month) | Grows with subscriber count |
| Performance | 3× faster at 100k events/day | Depends on server |
Our solution saves up to $2,000 per month in delivery costs for a user base of 50,000, compared to pay-per-email ESP plans. A typical implementation costs between $1,500 and $4,000, with an average of $2,500.
What's Included (Deliverables)
- Analysis of your application's event types and identification of digestable events.
- Design of the
digest_eventstable and integration with your existing database schema. - Implementation of the event tracker (
trackDigestEvent) with idempotency guarantees. - Setup of a cron scheduler with timezone-aware dispatch logic.
- Development of responsive HTML email templates (inline CSS, tested across major clients).
- Integration with your preferred ESP (SendGrid, AWS SES, SMTP) including deliverability tuning.
- RESTful API for user preference management (frequency, time, opt-out).
- Load testing with synthetic workloads to ensure the system handles your peak volume.
- Comprehensive documentation covering architecture, deployment, and maintenance.
- Access to the source code repository (Git) and deployment scripts.
- Training materials and a 1-hour team onboarding session.
- Two weeks of post-launch support (bug fixes, adjustments).
Email Digest Timelines and Cost
A complete email digest system with event accumulation, scheduler, timezone handling, and user preferences takes 4 to 6 days. Typical cost ranges from $1,500 to $4,000, with an average of $2,500. We provide a free evaluation of your project requirements.
Want a similar system for your project? Get a consultation from our engineer—we'll analyze your infrastructure and propose an optimal solution. Book a free assessment today.







