Tracking Deleted Messages
Tracking deleted messages on an IMAP account is one of the more challenging aspects of email synchronization. While it's straightforward to detect new messages, identifying deletions requires careful handling of IMAP's complexity.
Why Deletion Tracking is Challenging
IMAP Connection Limitations
- IDLE/NOOP only monitors one folder at a time
- Connection limits prevent opening one connection per folder
- Gmail heavily limits simultaneous connections (3-15 connections typically)
Reconnection Issues
- Network interruptions cause disconnects
- Forced logouts lose notification state
- No notifications for events while disconnected
Sequence Number Complexity
- EXPUNGE notifications use sequence numbers, not UIDs
- Sequence numbers change as messages are deleted
- Must maintain accurate sequence-to-UID mapping
How EmailEngine Tracks Deletions
EmailEngine solves these challenges by:
- Monitoring folder state via UIDNEXT and message counts
- Using MODSEQ when available (CONDSTORE extension)
- Maintaining UID sequences for accurate tracking
- Sending webhooks when deletions are detected
Detection Methods
Method 1: UIDNEXT + Message Count
The UIDNEXT value predicts the next message UID (usually highest UID + 1). By tracking both UIDNEXT and message count:
Before: messages=100, UIDNEXT=150
After: messages=95, UIDNEXT=150
Conclusion: 5 messages were deleted
If both values are unchanged, no messages were deleted.
Method 2: MODSEQ (if supported)
The MODSEQ value increments whenever folder content changes:
Before: MODSEQ=12345
After: MODSEQ=12345
Conclusion: No changes (no deletions)
Note: MODSEQ changes for any modification (flags, deletions, additions), so it's more sensitive than UIDNEXT.
Method 3: UID Sequence Comparison
Compare the list of message UIDs before and after:
Before: [100, 101, 102, 103, 104, 105]
After: [100, 102, 104, 105, 106]
Deleted: [101, 103]
Added: [106]
Deletion Webhooks
messageDeleted Event
When EmailEngine detects a deleted message, it sends a messageDeleted webhook:
{
"serviceUrl": "https://emailengine.example.com",
"account": "example",
"date": "2025-01-15T10:30:00.000Z",
"path": "INBOX",
"event": "messageDeleted",
"data": {
"id": "AAAAAQAAAeE",
"uid": 12345
}
}
Important Fields:
path- Folder where message was deleted (at root level, not inside data)data.id- EmailEngine's message ID (now deleted)data.uid- IMAP UID of deleted message
The payload varies by account type:
- IMAP accounts:
datacontainsidanduid - Gmail API accounts:
datacontainsid,threadId,flags,labels, andcategory - Outlook API accounts:
datacontains onlyid
Handling Deletion Webhooks
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/emailengine', async (req, res) => {
const event = req.body;
// Acknowledge immediately
res.status(200).json({ success: true });
// Process asynchronously
if (event.event === 'messageDeleted') {
await handleMessageDeleted(event);
}
});
async function handleMessageDeleted(event) {
const { account, path, data } = event;
console.log(`Message deleted from ${account}:`);
console.log(`- Folder: ${path}`);
console.log(`- UID: ${data.uid}`);
console.log(`- EmailEngine ID: ${data.id}`);
// Update your database
await updateMessageStatus(data.id, 'deleted');
// Sync deletion to external system
await syncDeletion(account, { ...data, path });
}
async function updateMessageStatus(messageId, status) {
// Update in your database
await db.messages.update(
{ emailEngineId: messageId },
{ $set: { status: status, deletedAt: new Date() } }
);
}
app.listen(3000);
Tracking Deletions in Your Application
Store Message State
Maintain a local copy of message state:
// Database schema example
const messageSchema = {
emailEngineId: String, // EmailEngine message ID
accountId: String, // Email account
folderPath: String, // Folder location
uid: Number, // IMAP UID
emailId: String, // Unique email ID
threadId: String, // Thread ID
subject: String,
from: Object,
date: Date,
status: String, // 'active', 'deleted', 'moved'
deletedAt: Date,
createdAt: Date,
updatedAt: Date
};
Sync Deletions
When a deletion webhook arrives:
async function syncDeletion(accountId, deletionData) {
const message = await db.messages.findOne({
emailEngineId: deletionData.id
});
if (!message) {
console.log('Message not found in local database');
return;
}
// Mark as deleted
await db.messages.update(
{ _id: message._id },
{
$set: {
status: 'deleted',
deletedAt: new Date()
}
}
);
console.log(`Synced deletion of: ${message.subject}`);
// Trigger additional actions
await onMessageDeleted(message);
}
async function onMessageDeleted(message) {
// Examples of actions:
// 1. Update search index
await searchIndex.delete(message.emailEngineId);
// 2. Clean up attachments
await cleanupAttachments(message.emailEngineId);
// 3. Update statistics
await analytics.recordDeletion({
accountId: message.accountId,
folderPath: message.folderPath,
date: new Date()
});
// 4. Notify users
if (message.important) {
await notifyUser({
type: 'message-deleted',
subject: message.subject,
from: message.from
});
}
}
Deleted vs Moved
Distinguishing Moves from Deletions
A message might appear "deleted" from one folder because it was moved to another. The ability to detect moves depends on the account type:
Gmail API accounts: The messageDeleted event includes the Gmail message id, which you can use to correlate with a subsequent messageNew event (Gmail assigns the same ID to moved messages).
IMAP accounts: The messageDeleted event only includes id (packed UID) and uid, which are folder-specific. When a message is moved, it gets a new UID in the destination folder, so direct correlation is not possible. However, if your IMAP server supports the OBJECTID extension, you can use emailId from messageNew events to detect moves by matching against previously stored message data.
// Track moved messages using emailId from messageNew events
// Note: This requires storing emailId when messages are first received
const recentDeletions = new Map();
async function handleMessageDeleted(event) {
const { path, data } = event;
// Look up the emailId from our stored message data
const storedMessage = await db.messages.findOne({ emailEngineId: data.id });
if (storedMessage && storedMessage.emailId) {
// Store deletion temporarily using emailId
recentDeletions.set(storedMessage.emailId, {
id: data.id,
path: path,
emailId: storedMessage.emailId,
timestamp: Date.now()
});
// Clean up old entries after 60 seconds
setTimeout(() => {
recentDeletions.delete(storedMessage.emailId);
}, 60000);
}
await updateMessageStatus(data.id, 'deleted');
}
async function handleMessageNew(event) {
const { path, data } = event;
// Check if this is a moved message (using emailId from the new message)
const recentDeletion = data.emailId ? recentDeletions.get(data.emailId) : null;
if (recentDeletion) {
console.log('Message was moved, not deleted');
console.log(`From: ${recentDeletion.path}`);
console.log(`To: ${path}`);
// Update status to moved
await updateMessageStatus(recentDeletion.id, 'moved');
await updateMessageLocation(data.emailId, path, data.id);
recentDeletions.delete(data.emailId);
} else {
// Genuinely new message
await createMessage(data);
}
}
async function updateMessageLocation(emailId, newPath, newId) {
await db.messages.update(
{ emailId: emailId },
{
$set: {
status: 'active',
folderPath: newPath,
emailEngineId: newId,
updatedAt: new Date()
},
$unset: {
deletedAt: ''
}
}
);
}
The emailId field is only available if the IMAP server supports the OBJECTID extension (RFC 8474). Not all IMAP servers support this extension.
Batch Deletion Detection
Detect Mass Deletions
Alert when many messages are deleted at once:
const deletionCounts = new Map();
async function handleMessageDeleted(event) {
const { account, path, data } = event;
const key = `${account}:${path}`;
// Track deletions per folder per minute
if (!deletionCounts.has(key)) {
deletionCounts.set(key, {
count: 0,
timestamp: Date.now()
});
}
const stats = deletionCounts.get(key);
const now = Date.now();
// Reset if more than 1 minute passed
if (now - stats.timestamp > 60000) {
stats.count = 0;
stats.timestamp = now;
}
stats.count++;
// Alert if more than 50 deletions in 1 minute
if (stats.count > 50) {
await alertMassDeletion({
account: account,
folder: path,
count: stats.count,
timeWindow: '1 minute'
});
}
await syncDeletion(account, { ...data, path });
}
async function alertMassDeletion(info) {
console.warn('MASS DELETION DETECTED:', info);
// Send alert to admin
await sendAdminAlert({
type: 'mass-deletion',
...info
});
}
Recovery and Audit
Maintain Deletion Audit Log
Keep a record of all deletions:
const deletionLogSchema = {
accountId: String,
folderPath: String,
messageId: String,
emailId: String,
subject: String,
from: Object,
date: Date,
deletedAt: Date,
deletionSource: String // 'user', 'auto-archive', 'retention-policy'
};
async function logDeletion(message, source = 'user') {
await db.deletionLog.insert({
accountId: message.accountId,
folderPath: message.folderPath,
messageId: message.emailEngineId,
emailId: message.emailId,
subject: message.subject,
from: message.from,
date: message.date,
deletedAt: new Date(),
deletionSource: source
});
}
// When deletion occurs
async function onMessageDeleted(message) {
await logDeletion(message);
// Continue with other actions...
}
Query Deletion History
async function getDeletionHistory(accountId, options = {}) {
const query = { accountId };
if (options.folder) {
query.folderPath = options.folder;
}
if (options.since) {
query.deletedAt = { $gte: new Date(options.since) };
}
const deletions = await db.deletionLog.find(query)
.sort({ deletedAt: -1 })
.limit(options.limit || 100)
.toArray();
return deletions;
}
// Get recent deletions
const recent = await getDeletionHistory('example', {
since: '2025-10-01',
folder: 'INBOX',
limit: 50
});
console.log(`Found ${recent.length} deleted messages`);
Soft Delete Pattern
Implement Soft Deletes
Instead of immediately deleting, mark as deleted:
async function handleMessageDeleted(event) {
const { path, data } = event;
// Soft delete: mark as deleted but keep in database
await db.messages.update(
{ emailEngineId: data.id },
{
$set: {
status: 'deleted',
deletedAt: new Date(),
// Keep original data for potential recovery
originalFolderPath: path,
originalUid: data.uid
}
}
);
// Schedule permanent deletion after 30 days
await schedulePermanentDeletion(data.id, 30);
}
async function schedulePermanentDeletion(messageId, daysDelay) {
const deleteAt = new Date();
deleteAt.setDate(deleteAt.getDate() + daysDelay);
await db.scheduledDeletions.insert({
messageId: messageId,
deleteAt: deleteAt
});
}
// Periodic cleanup job
async function permanentlyDeleteOldMessages() {
const now = new Date();
const toDelete = await db.scheduledDeletions.find({
deleteAt: { $lte: now }
}).toArray();
for (const item of toDelete) {
// Permanently delete
await db.messages.remove({ emailEngineId: item.messageId });
await db.scheduledDeletions.remove({ _id: item._id });
console.log(`Permanently deleted: ${item.messageId}`);
}
}
// Run daily
setInterval(permanentlyDeleteOldMessages, 24 * 60 * 60 * 1000);
Performance Considerations
Batch Webhook Processing
Process deletion webhooks in batches:
const deletionQueue = [];
let processingTimer = null;
async function handleMessageDeleted(event) {
deletionQueue.push(event.data);
// Process in batches every 5 seconds
if (!processingTimer) {
processingTimer = setTimeout(async () => {
await processBatchDeletions();
processingTimer = null;
}, 5000);
}
}
async function processBatchDeletions() {
if (deletionQueue.length === 0) return;
const batch = deletionQueue.splice(0, deletionQueue.length);
console.log(`Processing ${batch.length} deletions`);
// Batch database update
const messageIds = batch.map(d => d.id);
await db.messages.updateMany(
{ emailEngineId: { $in: messageIds } },
{
$set: {
status: 'deleted',
deletedAt: new Date()
}
}
);
console.log(`Batch update complete`);
}
Index for Efficient Queries
Create appropriate database indexes:
// MongoDB indexes
await db.messages.createIndex({ emailEngineId: 1 });
await db.messages.createIndex({ accountId: 1, status: 1 });
await db.messages.createIndex({ emailId: 1 });
await db.messages.createIndex({ status: 1, deletedAt: 1 });
await db.deletionLog.createIndex({ accountId: 1, deletedAt: -1 });