Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/migrations/0040_gtd_delegations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
CREATE TABLE gtd_delegations (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
contact_display_name_snapshot TEXT NOT NULL,
contact_primary_email_snapshot TEXT,
delegated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, account_id, thread_key)
);

CREATE INDEX idx_gtd_delegations_contact_id
ON gtd_delegations(contact_id)
WHERE contact_id IS NOT NULL;
54 changes: 54 additions & 0 deletions backend/src/routes/gtd.classify.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,16 @@ vi.mock('../services/gtdConfig.js', async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, getGtdConfig: vi.fn() };
});
vi.mock('../services/gtdDelegations.js', async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, delegateMessages: vi.fn(), reconcileDelegatedRemovals: vi.fn() };
});

import express from 'express';
import { query } from '../services/db.js';
import { imapManager } from '../index.js';
import { getGtdConfig, DEFAULT_GTD_FOLDERS } from '../services/gtdConfig.js';
import { delegateMessages, GtdDelegationError, reconcileDelegatedRemovals } from '../services/gtdDelegations.js';
import gtdRoutes from './gtd.js';

const MSG_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
Expand Down Expand Up @@ -63,6 +68,9 @@ const classify = (body) => fetch(`${base}/api/gtd/classify`, {
const unclassify = (body) => fetch(`${base}/api/gtd/classify`, {
method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
const delegate = (body) => fetch(`${base}/api/gtd/delegations`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});

let server;
let base;
Expand All @@ -81,9 +89,44 @@ beforeEach(() => {
Object.values(imapManager).forEach(fn => fn.mockReset());
getGtdConfig.mockReset();
getGtdConfig.mockResolvedValue({ enabled: true, folders: DEFAULT_GTD_FOLDERS });
reconcileDelegatedRemovals.mockReset();
delegateMessages.mockReset();
stubQueries();
});

describe('POST /api/gtd/delegations', () => {
it('deduplicates valid IDs and returns the structured bulk result', async () => {
delegateMessages.mockResolvedValue({
status: 'success', successCount: 1, failureCount: 0,
results: [{ messageId: MSG_ID, ok: true }],
});
const res = await delegate({ messageIds: [MSG_ID, MSG_ID], contactId: null });
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ status: 'success', successCount: 1 });
expect(delegateMessages).toHaveBeenCalledWith(expect.objectContaining({
userId: 'u1', messageIds: [MSG_ID], contactId: null, imapManager,
}));
});

it('rejects invalid shapes before calling the service', async () => {
for (const body of [
{}, { messageIds: [], contactId: null }, { messageIds: ['bad'], contactId: null },
{ messageIds: [MSG_ID], contactId: 'bad' },
]) {
const res = await delegate(body);
expect(res.status).toBe(400);
}
expect(delegateMessages).not.toHaveBeenCalled();
});

it('maps an unowned contact to the same 404 as an absent contact', async () => {
delegateMessages.mockRejectedValue(new GtdDelegationError('contact_not_found', 404));
const res = await delegate({ messageIds: [MSG_ID], contactId: ACCT_ID });
expect(res.status).toBe(404);
expect(await res.json()).toEqual({ error: 'contact_not_found' });
});
});

describe('POST /api/gtd/classify — request validation', () => {
it('rejects a missing messageId/state with 400 before any lookup', async () => {
const res = await classify({ state: 'todo' });
Expand Down Expand Up @@ -136,6 +179,17 @@ describe('POST /api/gtd/classify — apply a GTD label (COPY)', () => {
});

describe('DELETE /api/gtd/classify — remove a GTD label', () => {
it('clears person metadata after removing the Delegated label', async () => {
const delegated = { ...inboxMsg, thread_key: 'thread-a' };
stubQueries({ msg: delegated });
const res = await unclassify({ messageId: MSG_ID, state: 'delegated' });
expect(res.status).toBe(200);
expect(reconcileDelegatedRemovals).toHaveBeenCalledWith({
userId: 'u1', accountId: ACCT_ID,
delegatedFolder: 'Delegated', threadKeys: ['thread-a'],
});
});

it('removes the sibling copy in the state folder and returns removed:true', async () => {
const res = await unclassify({ messageId: MSG_ID, state: 'todo' });
expect(res.status).toBe(200);
Expand Down
17 changes: 16 additions & 1 deletion backend/src/routes/gtd.done.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,24 @@ vi.mock('../services/gtdConfig.js', async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, getGtdConfig: vi.fn() };
});
vi.mock('../services/gtdDelegations.js', async (importOriginal) => ({
...(await importOriginal()), reconcileDelegatedRemovals: vi.fn(), delegateMessages: vi.fn(),
}));

import express from 'express';
import { query } from '../services/db.js';
import { imapManager } from '../index.js';
import { resolveArchiveFolder, isAllMailFolder, adjustFolderCounts, fanOutReadToSiblings } from '../utils/mailUtils.js';
import { getGtdConfig, DEFAULT_GTD_FOLDERS } from '../services/gtdConfig.js';
import { reconcileDelegatedRemovals } from '../services/gtdDelegations.js';
import gtdRoutes from './gtd.js';

const MSG_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
const ACCT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';

// The rail acts on the Watch-folder copy; a distinct INBOX sibling is what the archive step
// moves. is_read true on both keeps the mark-read path off the IMAP setFlag mock.
const msg = { id: MSG_ID, account_id: ACCT_ID, uid: 10, folder: 'Watch', message_id: '<m@x>', is_read: true };
const msg = { id: MSG_ID, account_id: ACCT_ID, uid: 10, folder: 'Watch', message_id: '<m@x>', thread_key: 'thread-a', is_read: true };
const account = { id: ACCT_ID, user_id: 'u1', folder_mappings: {} };
const inboxCopy = { id: 'ib-1', uid: 77, is_read: true };

Expand Down Expand Up @@ -91,6 +95,7 @@ beforeEach(() => {
query.mockReset();
Object.values(imapManager).forEach(fn => fn.mockReset());
[resolveArchiveFolder, isAllMailFolder, adjustFolderCounts, fanOutReadToSiblings, getGtdConfig].forEach(fn => fn.mockReset());
reconcileDelegatedRemovals.mockReset();
getGtdConfig.mockResolvedValue({ enabled: true, folders: DEFAULT_GTD_FOLDERS });
resolveArchiveFolder.mockResolvedValue('Archive');
isAllMailFolder.mockResolvedValue(false);
Expand All @@ -107,6 +112,16 @@ describe('POST /api/gtd/done — id validation', () => {
});

describe('POST /api/gtd/done — archive count-adjust race', () => {
it('clears person metadata after successfully stripping Delegated', async () => {
stubQueries();
const res = await done({ id: MSG_ID, states: ['delegated'] });
expect(res.status).toBe(200);
expect(reconcileDelegatedRemovals).toHaveBeenCalledWith({
userId: 'u1', accountId: ACCT_ID,
delegatedFolder: 'Delegated', threadKeys: ['thread-a'],
});
});

it('archives + adjusts both counts when the INBOX-scoped write applied (rowCount 1)', async () => {
stubQueries({ archiveWrite: { rowCount: 1 } });
imapManager.moveMessage.mockResolvedValue(88); // UIDPLUS newUid
Expand Down
66 changes: 62 additions & 4 deletions backend/src/routes/gtd.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { fanOutReadToSiblings } from '../utils/mailUtils.js';
import { archiveInboxCopy } from '../services/archiveInbox.js';
import { query } from '../services/db.js';
import { imapManager } from '../index.js';
import {
delegateMessages,
GtdDelegationError,
reconcileDelegatedRemovals,
} from '../services/gtdDelegations.js';

const router = Router();
router.use(requireAuth);
Expand All @@ -17,6 +22,20 @@ router.use(requireAuth);
// driver cast error. Same idiom + regex as mail.js.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export function parseDelegationBody(body) {
if (!Array.isArray(body?.messageIds)) {
throw new GtdDelegationError('invalid_request', 400, 'messageIds must be an array');
}
const messageIds = [...new Set(body.messageIds)];
if (messageIds.length < 1 || messageIds.length > 100 || messageIds.some(id => !UUID_RE.test(id))) {
throw new GtdDelegationError('invalid_request', 400, 'messageIds must contain 1 to 100 UUIDs');
}
if (body.contactId !== null && !UUID_RE.test(body.contactId || '')) {
throw new GtdDelegationError('invalid_request', 400, 'contactId must be a UUID or null');
}
return { messageIds, contactId: body.contactId };
}

// Shared classify precondition: an account must have GTD enabled and the request's
// state must resolve to a designated folder. Returns { folder } to proceed, or
// { status, error } to reject. Pure — exported for unit tests.
Expand Down Expand Up @@ -84,6 +103,24 @@ router.get('/sections', async (req, res) => {
}).catch(err => console.warn('GTD gist generation error:', err.message));
});

router.post('/delegations', async (req, res, next) => {
try {
const { messageIds, contactId } = parseDelegationBody(req.body);
const result = await delegateMessages({
userId: req.session.userId,
messageIds,
contactId,
imapManager,
});
res.json(result);
} catch (error) {
if (error instanceof GtdDelegationError) {
return res.status(error.status).json({ error: error.code });
}
next(error);
}
});

// ── GTD Inbox-Zero pet ────────────────────────────────────────────────────────

// POST /api/gtd/pet/import { petJson, sheet } — import a user's OWN pet by uploading the
Expand Down Expand Up @@ -225,10 +262,24 @@ router.delete('/classify', async (req, res) => {
return res.status(400).json({ error: 'Message has no Message-ID — cannot resolve GTD copy' });
}
const siblingUid = await resolveCopyUid(msg, stateFolder);
if (siblingUid == null) return res.json({ ok: true, removed: false });
if (siblingUid == null) {
if (state === 'delegated' && msg.thread_key) {
await reconcileDelegatedRemovals({
userId: req.session.userId, accountId: msg.account_id,
delegatedFolder: stateFolder, threadKeys: [msg.thread_key],
});
}
return res.json({ ok: true, removed: false });
}

try {
await imapManager.removeMessageCopy(msg.account_id, siblingUid, stateFolder);
if (state === 'delegated' && msg.thread_key) {
await reconcileDelegatedRemovals({
userId: req.session.userId, accountId: msg.account_id,
delegatedFolder: stateFolder, threadKeys: [msg.thread_key],
});
}
} catch (err) {
console.error(`GTD unclassify failed for message ${messageId} in ${stateFolder}:`, err.message);
return res.status(500).json({ error: 'Failed to remove GTD label' });
Expand Down Expand Up @@ -315,9 +366,16 @@ router.post('/done', async (req, res) => {
try {
for (const folder of stripOrder) {
const uid = await resolveCopyUid(msg, folder);
if (uid == null) continue; // already gone
await imapManager.removeMessageCopy(msg.account_id, uid, folder);
removed.push(folder);
if (uid != null) {
await imapManager.removeMessageCopy(msg.account_id, uid, folder);
removed.push(folder);
}
if (folder === folders.delegated && msg.thread_key) {
await reconcileDelegatedRemovals({
userId: req.session.userId, accountId: msg.account_id,
delegatedFolder: folder, threadKeys: [msg.thread_key],
});
}
}
} catch (err) {
console.error(`GTD done: label strip for ${id} failed:`, err.message);
Expand Down
20 changes: 14 additions & 6 deletions backend/src/routes/mail.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { listMessages } from '../services/messageService.js';
import { resolveAccountScope } from '../services/unifiedInbox.js';
import { validateHost } from '../services/hostValidation.js';
import { safeFetch } from '../services/safeFetch.js';
import { DELEGATION_SELECT_SQL, delegationJoinSql, mapDelegationRow } from '../services/gtdDelegations.js';

const router = Router();
router.use(requireAuth);
Expand Down Expand Up @@ -147,15 +148,17 @@ router.get('/messages/:id', async (req, res) => {
m.has_attachments, m.account_id, m.category,
m.list_unsubscribe, m.list_unsubscribe_post, m.unsubscribed_at, m.delivery_addresses,
a.name AS account_name, a.email_address AS account_email,
a.color AS account_color
a.color AS account_color,
${DELEGATION_SELECT_SQL}
FROM messages m
JOIN email_accounts a ON m.account_id = a.id
${delegationJoinSql('m', 'a')}
WHERE m.id = $1
AND a.user_id = $2
AND m.is_deleted = false
`, [id, req.session.userId]);
if (!result.rows.length) return res.status(404).json({ error: 'Message not found' });
res.json(result.rows[0]);
res.json({ ...result.rows[0], delegation: mapDelegationRow(result.rows[0]) });
} catch (err) {
console.error('GET /messages/:id error:', err.message);
res.status(500).json({ error: 'Failed to load message' });
Expand Down Expand Up @@ -184,14 +187,16 @@ router.get('/resolve-message', async (req, res) => {
m.has_attachments, m.account_id, m.category,
m.list_unsubscribe, m.list_unsubscribe_post, m.unsubscribed_at, m.delivery_addresses,
a.name AS account_name, a.email_address AS account_email,
a.color AS account_color`;
a.color AS account_color,
${DELEGATION_SELECT_SQL}`;
try {
// Durable match on the stable Message-ID header. When the same email exists in more
// than one folder (e.g. INBOX + Archive), prefer the INBOX copy, then the most recent.
let result = await query(`
SELECT ${COLS}
FROM messages m
JOIN email_accounts a ON m.account_id = a.id
${delegationJoinSql('m', 'a')}
WHERE m.message_id = $1
AND a.user_id = $2
AND m.is_deleted = false
Expand All @@ -205,14 +210,15 @@ router.get('/resolve-message', async (req, res) => {
SELECT ${COLS}
FROM messages m
JOIN email_accounts a ON m.account_id = a.id
${delegationJoinSql('m', 'a')}
WHERE m.id = $1
AND a.user_id = $2
AND m.is_deleted = false
AND ($3::uuid IS NULL OR m.account_id = $3)
`, [ref, req.session.userId, accountId]);
}
if (result.rows.length === 0) return res.status(404).json({ error: 'Message not found' });
res.json(result.rows[0]);
res.json({ ...result.rows[0], delegation: mapDelegationRow(result.rows[0]) });
} catch (err) {
console.error('GET /resolve-message error:', err.message);
res.status(500).json({ error: 'Failed to resolve message' });
Expand Down Expand Up @@ -262,9 +268,11 @@ router.get('/thread/:threadId', async (req, res) => {
m.date, m.snippet, m.is_read, m.is_starred,
m.has_attachments, m.account_id, m.category,
m.list_unsubscribe, m.list_unsubscribe_post, m.unsubscribed_at, m.delivery_addresses,
a.name AS account_name, a.email_address AS account_email, a.color AS account_color
a.name AS account_name, a.email_address AS account_email, a.color AS account_color,
${DELEGATION_SELECT_SQL}
FROM messages m
JOIN email_accounts a ON m.account_id = a.id
${delegationJoinSql('m', 'a')}
WHERE m.is_deleted = false
AND m.account_id = ANY($1)
AND m.thread_key = $2
Expand All @@ -275,7 +283,7 @@ router.get('/thread/:threadId', async (req, res) => {
SELECT * FROM deduped ORDER BY date ASC
`, [accountIds, threadId]);

res.json({ messages: result.rows });
res.json({ messages: result.rows.map(row => ({ ...row, delegation: mapDelegationRow(row) })) });
} catch (err) {
console.error('Thread fetch error:', err);
res.status(500).json({ error: 'Failed to load thread' });
Expand Down
7 changes: 6 additions & 1 deletion backend/src/routes/mail.resolve.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,18 @@ describe('GET /api/mail/resolve-message account scope', () => {
});

it('keeps unscoped deep-link resolution backward compatible', async () => {
query.mockResolvedValueOnce({ rows: [{ id: 'current-row', account_id: ACCOUNT_ID }] });
query.mockResolvedValueOnce({ rows: [{
id: 'current-row', account_id: ACCOUNT_ID,
delegation: '{"contact_id":null,"display_name":"Casey"}',
}] });

const response = await fetch(`${base}/api/mail/resolve-message?ref=${encodeURIComponent(MESSAGE_ID)}`);

expect(response.status).toBe(200);
const [, params] = query.mock.calls[0];
expect(params).toEqual([MESSAGE_ID, 'user-1', null]);
expect((await response.json()).delegation).toEqual({ contact_id: null, display_name: 'Casey' });
expect(query.mock.calls[0][0]).toContain('gtd_delegations');
});

it('rejects a malformed account scope before querying', async () => {
Expand Down
10 changes: 8 additions & 2 deletions backend/src/routes/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Router } from 'express';
import { query } from '../services/db.js';
import { requireAuth } from '../middleware/auth.js';
import { resolveAccountScope } from '../services/unifiedInbox.js';
import { DELEGATION_SELECT_SQL, delegationJoinSql, mapDelegationRow } from '../services/gtdDelegations.js';

const router = Router();
router.use(requireAuth);
Expand Down Expand Up @@ -241,17 +242,22 @@ router.get('/', searchLimiter, async (req, res) => {
SELECT
m.id, m.uid, m.folder, m.subject, m.from_name, m.from_email,
m.date, m.snippet, m.is_read, m.is_starred, m.has_attachments, m.account_id,
a.name as account_name, a.email_address as account_email, a.color as account_color
a.name as account_name, a.email_address as account_email, a.color as account_color,
${DELEGATION_SELECT_SQL}
FROM messages m
JOIN email_accounts a ON m.account_id = a.id
${delegationJoinSql('m', 'a')}
WHERE m.account_id = ANY($1)
AND m.is_deleted = false
AND ${conditions.join('\n AND ')}
ORDER BY m.date DESC
LIMIT $${p} OFFSET $${p + 1}
`, params);

res.json({ messages: result.rows, query: q });
res.json({
messages: result.rows.map(row => ({ ...row, delegation: mapDelegationRow(row) })),
query: q,
});
} catch (err) {
console.error('Search error:', err);
res.status(500).json({ error: 'Search failed' });
Expand Down
Loading