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:

  1. Accidental deletion. An admin clicks delete on the wrong row. It happens.
  2. 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.