Collaborative Editing

Enable your team to work together on email templates with conflict-free editing, version history, and presence awareness.

How it works: Collaborative editing uses a combination of pessimistic locking (to prevent simultaneous edits), optimistic concurrency control (version numbers to detect conflicts), and presence tracking (to see who is viewing or editing a template).

Overview

The collaboration system provides three key capabilities:

Template Locking

Acquire an edit lock to prevent others from making simultaneous changes. Locks auto-expire after 30 minutes of inactivity.

Version History

Every change creates a new version with full content snapshots. Browse history, compare versions, and restore previous versions at any time.

Presence Awareness

See who is currently viewing or editing a template in real-time, including their cursor position.

Locking Workflow

The recommended workflow for editing a template collaboratively:

  1. Acquire the lock before making edits
  2. Send heartbeats every 5 minutes to keep the lock alive
  3. Save your changes as a new version
  4. Release the lock when done (or it auto-releases on save)

Acquire Lock

POST /v1/templates/:id/lock

Returns 409 Conflict if the template is already locked by another user, including the lock holder details.

acquire-lock.ts
// Acquire an edit lock
const lock = await client.templates.lock('template_xxxxx');
console.log(lock.lockedByName);  // "API Key ab12"
console.log(lock.expiresAt);     // "2026-01-31T12:30:00.000Z"

// If already locked by someone else, a 409 error is thrown
try {
  await client.templates.lock('template_xxxxx');
} catch (err) {
  if (err.statusCode === 409) {
    console.log('Template is locked by:', err.details.lock.lockedByName);
  }
}

Extend Lock (Heartbeat)

POST /v1/templates/:id/lock/extend

Resets the lock expiry to 30 minutes from now. Call this periodically while the user is actively editing.

lock-heartbeat.ts
// Extend the lock every 5 minutes while editing
const heartbeat = setInterval(async () => {
  try {
    await client.templates.extendLock('template_xxxxx');
  } catch (err) {
    console.error('Failed to extend lock:', err.message);
    clearInterval(heartbeat);
  }
}, 5 * 60 * 1000);

// Stop heartbeat when done editing
clearInterval(heartbeat);

Release Lock

DELETE /v1/templates/:id/lock

Only the lock holder can release. Note that saving a version auto-releases the lock.

release-lock.ts
// Explicitly release the lock
await client.templates.unlock('template_xxxxx');

Version History

Every template maintains a complete version history. Each version includes the full HTML content, subject line, editor information, and an optional commit message.

Save a New Version

POST /v1/templates/:id/versions
save-version.ts
// Save a new version with optimistic concurrency control
const version = await client.templates.saveVersion('template_xxxxx', {
  html: '<h1>Updated email content</h1>',
  subject: 'New subject line',
  message: 'Redesigned the header section',
  expectedVersion: 3,  // Must match the current version
});

console.log(version.version);  // 4
console.log(version.message);  // "Redesigned the header section"

Optimistic concurrency: The expectedVersion parameter prevents lost updates. If someone else saved a version between when you loaded the template and when you save, the API returns 409 Conflict with the current version number so you can refresh and retry.

List Versions

GET /v1/templates/:id/versions
list-versions.ts
// List version history (most recent first)
const { data: versions } = await client.templates.listVersions('template_xxxxx');

for (const v of versions) {
  console.log(`v${v.version} - ${v.editedByName} - ${v.message || 'No message'}`);
  console.log(`  Created: ${v.createdAt}`);
}

Get a Specific Version

GET /v1/templates/:id/versions/:version
get-version.ts
// Get a specific version to view or compare
const version = await client.templates.getVersion('template_xxxxx', 2);
console.log(version.html);     // Full HTML content of version 2
console.log(version.subject);  // Subject at version 2

Restore a Previous Version

POST /v1/templates/:id/restore/:version

Restoring creates a new version with the content from the specified old version. The original version is preserved in history.

restore-version.ts
// Restore version 2 as the latest version
const restored = await client.templates.restoreVersion('template_xxxxx', 2);
console.log(restored.version);  // 5 (new version number)
console.log(restored.message);  // "Restored from version 2"

Presence Awareness

Track who is currently viewing or editing a template. Presence uses a polling model: clients send heartbeats every 30 seconds, and records are considered stale after 60 seconds.

Update Presence

POST /v1/templates/:id/presence
presence-heartbeat.ts
// Start sending presence heartbeats
const presenceInterval = setInterval(async () => {
  await client.templates.updatePresence('template_xxxxx', {
    line: currentCursorLine,
    column: currentCursorColumn,
  });
}, 30_000); // Every 30 seconds

// Clean up on page leave
window.addEventListener('beforeunload', async () => {
  clearInterval(presenceInterval);
  await client.templates.removePresence('template_xxxxx');
});

Get Active Editors

GET /v1/templates/:id/presence
get-presence.ts
// Poll for active editors
const { data: editors } = await client.templates.getPresence('template_xxxxx');

for (const editor of editors) {
  console.log(editor.userName, 'is viewing');
  if (editor.cursor) {
    console.log(`  Cursor at line ${editor.cursor.line}, col ${editor.cursor.column}`);
  }
}

Remove Presence

DELETE /v1/templates/:id/presence
remove-presence.ts
// Remove your presence when leaving the editor
await client.templates.removePresence('template_xxxxx');

Conflict Resolution

The system uses two layers of protection against conflicting edits:

MechanismTypeHow It Works
Template LocksPessimisticOnly one user can hold the edit lock at a time. Others see who holds it and when it expires.
Version NumbersOptimisticWhen saving, you must provide the expected version number. If it does not match, the save is rejected with 409 Conflict.

Handling Version Conflicts

conflict-resolution.ts
async function saveWithRetry(templateId: string, html: string) {
  // Get current template state
  let template = await client.templates.get(templateId);

  try {
    // Try to save with expected version
    return await client.templates.saveVersion(templateId, {
      html,
      expectedVersion: template.version,
      message: 'Updated content',
    });
  } catch (err) {
    if (err.statusCode === 409 && err.details?.currentVersion) {
      // Version conflict - another user saved first
      // Option 1: Reload and let the user resolve manually
      template = await client.templates.get(templateId);
      console.log('Conflict! Current version:', template.version);
      console.log('Please review the latest changes and try again.');

      // Option 2: Auto-retry with the new version (if no content conflict)
      // return await client.templates.saveVersion(templateId, {
      //   html,
      //   expectedVersion: err.details.currentVersion,
      //   message: 'Updated content (auto-retry)',
      // });
    }
    throw err;
  }
}

Complete Editing Workflow

Here is a complete example of a collaborative editing session:

complete-workflow.ts
import { VeilMail } from '@veilmail/sdk';

const client = new VeilMail('veil_live_xxxxx');
const templateId = 'template_xxxxx';

// 1. Start editing: acquire lock and announce presence
const lock = await client.templates.lock(templateId);
console.log('Lock acquired, expires:', lock.expiresAt);

// 2. Set up heartbeats for both lock and presence
const lockHeartbeat = setInterval(async () => {
  await client.templates.extendLock(templateId);
}, 5 * 60 * 1000); // Every 5 minutes

const presenceHeartbeat = setInterval(async () => {
  await client.templates.updatePresence(templateId, {
    line: 42,
    column: 10,
  });
}, 30_000); // Every 30 seconds

// 3. Check who else is viewing
const { data: editors } = await client.templates.getPresence(templateId);
console.log(`${editors.length} editors currently viewing`);

// 4. Get the current template to read the version number
const template = await client.templates.get(templateId);

// 5. Make edits and save as a new version
const newVersion = await client.templates.saveVersion(templateId, {
  html: '<h1>Updated email content</h1><p>New paragraph.</p>',
  subject: 'Updated subject line',
  message: 'Redesigned header and added new CTA',
  expectedVersion: template.version,
});
console.log('Saved version:', newVersion.version);
// Note: Lock is auto-released after saving

// 6. Clean up
clearInterval(lockHeartbeat);
clearInterval(presenceHeartbeat);
await client.templates.removePresence(templateId);

Best Practices

Lock heartbeat interval

Send lock heartbeats every 5 minutes. Locks expire after 30 minutes, so this gives ample buffer for network issues.

Presence heartbeat interval

Send presence heartbeats every 30 seconds. Presence records are considered stale after 60 seconds, so 30-second intervals ensure continuous visibility.

Always clean up on exit

Use beforeunload events or equivalent cleanup hooks to release locks and remove presence when the user navigates away. Stale locks will auto-expire, but explicit cleanup provides a better experience for other users.

Use meaningful version messages

Include a descriptive message when saving versions. This helps team members understand what changed in each version when reviewing history.

Handle conflicts gracefully

When you receive a 409 Conflict on save, reload the latest template content and present a diff or merge UI to the user rather than silently overwriting.

API Reference

MethodEndpointScopeDescription
POST/v1/templates/:id/locktemplate:writeAcquire edit lock
DELETE/v1/templates/:id/locktemplate:writeRelease edit lock
POST/v1/templates/:id/lock/extendtemplate:writeExtend lock (heartbeat)
GET/v1/templates/:id/versionstemplate:readList version history
GET/v1/templates/:id/versions/:versiontemplate:readGet specific version
POST/v1/templates/:id/versionstemplate:writeSave new version
POST/v1/templates/:id/restore/:versiontemplate:writeRestore previous version
POST/v1/templates/:id/presencetemplate:readUpdate presence (heartbeat)
GET/v1/templates/:id/presencetemplate:readGet active editors
DELETE/v1/templates/:id/presencetemplate:readRemove own presence

Response Types

TemplateLock

FieldTypeDescription
idstringLock ID
templateIdstringTemplate ID
lockedBystringUser or API key ID
lockedByNamestringDisplay name
lockedAtstringISO 8601 timestamp
expiresAtstringISO 8601 expiry time

TemplateVersion

FieldTypeDescription
idstringVersion record ID
versionnumberSequential version number
htmlstringFull HTML content snapshot
subjectstring | nullSubject line at this version
editedBystringEditor ID
editedByNamestringEditor display name
messagestring | nullOptional commit message
createdAtstringISO 8601 timestamp

TemplatePresence

FieldTypeDescription
userIdstringUser or API key ID
userNamestringDisplay name
cursor{ line, column } | nullCursor position in the editor
lastSeenAtstringISO 8601 last heartbeat time