Part of the PurelyManage series.
The Problem
Any admin panel that manages real data has a deletion problem. The moment you give someone a delete button, you are one misclick away from losing something that took time to set up. In a single-user tool this is manageable: you know who deleted it because it was you. In a multi-admin panel it is more complicated.
PurelyManage can have multiple sysadmin accounts. Any of them can delete email users, domains, and routing rules. The operations go directly to PurelyMail via their API, meaning the moment the request is made, the resource is gone. There is no recycle bin, no undo, no recovery path.
Two scenarios make this dangerous:
- Accidental deletion. An admin clicks delete on the wrong row. It happens.
- Compromised account. An attacker gets access to one sysadmin account and starts deleting resources before anyone notices.
Immediate execution makes both scenarios unrecoverable. A 24-hour window makes both recoverable.
How the Queue Works
When any admin triggers a deletion in PurelyManage, the resource is not deleted immediately. Instead, a row is inserted into the pending_deletions table with an execution timestamp 24 hours in the future.
CREATE TABLE IF NOT EXISTS pending_deletions (
id SERIAL PRIMARY KEY,
resource_type TEXT NOT NULL, -- 'user', 'domain', 'routing_rule'
resource_id TEXT NOT NULL,
resource_label TEXT NOT NULL,
scheduled_for TIMESTAMPTZ NOT NULL, -- created_at + 24 hours
triggered_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
cancelled_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending',
executed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
The scheduled_for value is always NOW() + 24 hours. The resource stays fully intact in PurelyMail during this window. The deletion is just a scheduled intent, not an action.
const GRACE_MS = 24 * 60 * 60 * 1000
export async function scheduleDeletion(
resourceType: ResourceType,
resourceId: string,
resourceLabel: string,
triggeredBy: number
) {
const scheduledFor = new Date(Date.now() + GRACE_MS)
await sql`
INSERT INTO pending_deletions (resource_type, resource_id, resource_label, scheduled_for, triggered_by)
VALUES (${resourceType}, ${resourceId}, ${resourceLabel}, ${scheduledFor}, ${triggeredBy})
`
}
Duplicate prevention is also enforced: if a pending deletion already exists for the same resource, scheduling another one throws an error. You cannot stack multiple deletions on the same resource.
Owner Alert
If the admin who triggered the deletion is not the owner account, the owner gets an email immediately. Not after 24 hours. Immediately.
const [triggeringUser] = await sql`SELECT email, name, is_owner FROM users WHERE id = ${triggeredBy}`
if (!triggeringUser?.is_owner && isMailConfigured()) {
const [owner] = await sql`SELECT email FROM users WHERE is_owner = true LIMIT 1`
if (owner) {
await sendMail(
owner.email,
`PurelyManage - Deletion scheduled: ${resourceLabel}`,
`...${triggeringUser.name} has scheduled a deletion...`
)
}
}
The email includes who scheduled it, what resource is affected, and when execution is scheduled. The owner has the full 24-hour window to investigate and cancel if needed.
If the owner themselves schedules the deletion, no alert is sent. The assumption is that the owner is aware of their own actions.
Cancellation Policy
Any authenticated admin can cancel any pending deletion, including one they did not schedule. This is intentional.
The obvious instinct is to restrict cancellation to the owner. But think about what happens if the owner account is the compromised one. The owner schedules a deletion, no alert fires (because the owner triggered it), and no other admin can cancel it. That is a worse outcome.
By allowing any admin to cancel, the system ensures that a group of sysadmins can collectively intervene even if the owner account is the source of the problem. The cancellation is audited, so there is a record of who cancelled what and when.
export async function cancelDeletion(id: number, cancelledBy: number) {
const [cancelled] = await sql`
UPDATE pending_deletions
SET status = 'cancelled', cancelled_by = ${cancelledBy}
WHERE id = ${id} AND status = 'pending'
RETURNING *
`
if (!cancelled) return null
await audit(cancelledBy, `${cancelled.resource_type}.delete.cancelled`, cancelled.resource_label)
return cancelled
}
Execution
A cron job runs every 10 minutes. It picks up all pending deletions where scheduled_for <= NOW() and executes them against the PurelyMail API.
export async function executeElapsed() {
const elapsed = await sql`
SELECT * FROM pending_deletions
WHERE status = 'pending' AND scheduled_for <= NOW()
`
for (const pending of elapsed) {
if (pending.resource_type === 'user') {
await pmPost('/api/v0/deleteUser', { userName: pending.resource_id })
await cacheRemove(pending.resource_id) // remove from local user cache
} else if (pending.resource_type === 'domain') {
await pmPost('/api/v0/deleteDomain', { name: pending.resource_id })
await removeCacheEntry(pending.resource_id) // remove from DNS cache
} else if (pending.resource_type === 'routing_rule') {
await pmPost('/api/v0/deleteRoutingRule', { routingRuleId: parseInt(pending.resource_id) })
}
await sql`
UPDATE pending_deletions SET status = 'executed', executed_at = NOW() WHERE id = ${pending.id}
`
}
}
Execution also cleans up the local caches: the PurelyMail user cache and the DNS health cache are updated to remove the deleted resource. If the PurelyMail API call fails for any reason, the error is logged and the row stays in pending status so it can be retried on the next cron cycle.
What the UI Shows
A few things happen in the frontend to make the queue visible:
Delete button replaced. On the Users, Domains, and Routing pages, the delete button is replaced with a grayed-out “Pending” button for any resource that already has a scheduled deletion. Hovering shows a tooltip with the scheduled execution time. You cannot schedule the same deletion twice.
Dashboard warning. If any deletions are pending, a warning panel appears on the dashboard showing the total count broken down by type. It links directly to the Settings page where they can be reviewed and cancelled.
Pending Deletions tab. Settings has a dedicated tab listing all queued deletions with the resource name, who scheduled it, and how much time remains. Each row has a cancel button.
Red badge. The Settings tab shows a red count badge when any deletions are pending, so it is visible without navigating there.
One Edge Case: Domain Deletion
Domains cannot be deleted while email users on that domain still exist in the local cache. If you schedule a domain deletion while users are still active, the backend rejects the request with a clear error: delete all users first.
This is a guard against orphaned users. PurelyMail would accept the domain deletion, but the users would be left in a broken state. Enforcing the order at the scheduling step means the state stays consistent.
The next post covers something different: what it is actually like to build against a closed API with no public documentation.