Gmail folder auto-advance + /forward command

- Reply-cycle auto-advance: staff reply files the thread to "Awaiting Reply",
  a customer response files it to "Needs Response" (new GMAIL_LABEL_AWAITING_REPLY
  / GMAIL_LABEL_NEEDS_RESPONSE labels + autoAdvanceFolder, which only moves
  threads still in the auto-cycle and leaves hand-filed folders alone)
- /forward: forward a ticket's email to another address (handlers/commands/forward.js
  + forward composition in services/gmail.js)
- Tests for the auto-advance cycle; label fixtures updated for the new labels
This commit is contained in:
2026-06-05 02:46:50 +00:00
parent 0fcffe8d33
commit 6bae3e79b1
10 changed files with 410 additions and 10 deletions

View File

@@ -4,6 +4,9 @@ import {
resolveLabelId,
moveThreadToFolder,
folderDisplayName,
getManagedFolderKey,
autoAdvanceFolder,
MANAGED_USER_KEYS,
__clearLabelCache
} from '../services/gmailLabels.js';
@@ -19,8 +22,11 @@ const FULL_IDS = {
const FULL_LABELS = [
{ name: 'Triage', id: 'L_TRIAGE' },
{ name: 'Awaiting Reply', id: 'L_AR' },
{ name: 'Needs Response', id: 'L_NR' },
{ name: 'Escalated', id: 'L_ESC' },
{ name: 'Resolved', id: 'L_RES' },
{ name: 'Complete', id: 'L_RES' },
{ name: 'For Jake', id: 'L_FJ' },
{ name: 'Dashboard Errors', id: 'L_DE' },
{ name: 'Partnership Offers', id: 'L_PO' }
@@ -150,3 +156,76 @@ describe('moveThreadToFolder', () => {
await expect(moveThreadToFolder('t', 'BOGUS', gmail)).rejects.toThrow();
});
});
// Derive label name↔id from the live config so these tests don't depend on the
// actual GMAIL_LABEL_* names in .env (e.g. RESOLVED may be customized to "Complete").
const idForKey = key => `LID_${key}`;
const CYCLE_LABELS = MANAGED_USER_KEYS.map(k => ({ name: folderDisplayName(k), id: idForKey(k) }));
// Mock Gmail whose thread carries the given label ids; records threads.modify.
function makeGmail({ threadLabelIds = [], onModify } = {}) {
return {
users: {
labels: {
list: async () => ({ data: { labels: CYCLE_LABELS } }),
create: async () => { throw new Error('no create expected'); }
},
threads: {
get: async () => ({ data: { messages: [{ labelIds: threadLabelIds }] } }),
modify: async (args) => { if (onModify) onModify(args); return { data: {} }; }
}
}
};
}
describe('getManagedFolderKey', () => {
beforeEach(() => __clearLabelCache());
it('maps a thread label id to its managed folder key', async () => {
expect(await getManagedFolderKey('t', makeGmail({ threadLabelIds: [idForKey('FOR_JAKE'), 'INBOX'] }))).toBe('FOR_JAKE');
});
it('detects the system SPAM label', async () => {
expect(await getManagedFolderKey('t', makeGmail({ threadLabelIds: ['SPAM'] }))).toBe('SPAM');
});
it('returns null when no managed label is present', async () => {
expect(await getManagedFolderKey('t', makeGmail({ threadLabelIds: ['INBOX', 'UNREAD'] }))).toBeNull();
});
});
describe('autoAdvanceFolder', () => {
beforeEach(() => __clearLabelCache());
it('advances an auto-cycle thread (Awaiting Reply → Needs Response)', async () => {
let modifyArgs = null;
const gmail = makeGmail({ threadLabelIds: [idForKey('AWAITING_REPLY')], onModify: a => { modifyArgs = a; } });
expect(await autoAdvanceFolder('t', 'NEEDS_RESPONSE', gmail)).toBe(true);
expect(modifyArgs.requestBody.addLabelIds).toEqual([idForKey('NEEDS_RESPONSE')]);
});
it('advances a thread with no managed label', async () => {
let called = false;
const gmail = makeGmail({ threadLabelIds: ['INBOX'], onModify: () => { called = true; } });
expect(await autoAdvanceFolder('t', 'AWAITING_REPLY', gmail)).toBe(true);
expect(called).toBe(true);
});
it('leaves a manually-filed thread (For Jake) untouched', async () => {
let called = false;
const gmail = makeGmail({ threadLabelIds: [idForKey('FOR_JAKE')], onModify: () => { called = true; } });
expect(await autoAdvanceFolder('t', 'NEEDS_RESPONSE', gmail)).toBe(false);
expect(called).toBe(false);
});
it('leaves a SPAM-filed thread untouched', async () => {
let called = false;
const gmail = makeGmail({ threadLabelIds: ['SPAM'], onModify: () => { called = true; } });
expect(await autoAdvanceFolder('t', 'NEEDS_RESPONSE', gmail)).toBe(false);
expect(called).toBe(false);
});
it('rejects an unknown target key before touching the API', async () => {
await expect(autoAdvanceFolder('t', 'BOGUS', makeGmail())).rejects.toThrow();
});
});