openvibers/openvibe.mail Commits
Inbox AI receipts handoff honours the opt-in (#25)
Changes
@@@ -20,7 +20,8 @@ the send route runs only after the person explicitly clicks Send. Outbound relayexact sender address and/or subject substring and assigns a category.- `GET /api/v1/me/mail/ai-journal` lists category changes made by rules. `POST/api/v1/me/mail/ai-journal/{id}/undo` restores the previous category when the thread has not since changed.--- `GET /api/v1/me/mail/receipts` is the read-only handoff for openvibe.my: `{ receipts: [{ thread_id, merchant,-- subject, summary, received_at }] }`. It contains only the person's own receipt-category summaries.++- `GET /api/v1/me/mail/receipts` is the read-only handoff for openvibe.my: `{ enabled, receipts: [{ thread_id,++ message_id, merchant, subject, summary, received_at }] }`. `enabled` is the mailbox's Inbox AI opt-in; when it is++ off or there is no mailbox, `receipts` is empty. It contains only the person's own receipt-category summaries.These routes require a person token and are private/no-store. Rule journals never contain message bodies.@@@ -72,6 +72,7 @@});side.appendChild(ul);var aiBox = el('section', 'ib-ai-settings');++ aiBox.id = 'inbox-ai'; // openvibe.my's Receipts card links here to turn Inbox AI onaiBox.appendChild(el('h2', 'ib-h', 'Inbox AI'));var aiLabel = el('label', 'ib-ai-label');var aiToggle = el('input');@@@ -380,7 +381,14 @@// The addresses come from the API when the shell does not carry them.function begin() {paintSide();-- loadAiControls().then(paintSide).catch(function () {});++ loadAiControls().then(function () {++ paintSide();++ // A link from openvibe.my's Receipts card to #inbox-ai lands on the switch, which only exists after this paint.++ if (location.hash === '#inbox-ai') {++ var ai = document.getElementById('inbox-ai');++ if (ai && ai.scrollIntoView) ai.scrollIntoView({ block: 'center' });++ }++ }).catch(function () {});loadList();}function loadAiControls() {@@@ -77,12 +77,17 @@ function mountReading(router, { db, stalwart, config, smtp, principal, wrap, faireturn noStore(res).json({ counts, threads: items });}));++ // The read-only handoff for openvibe.my's Receipts card. `enabled` says whether Inbox AI is on for this mailbox:++ // with it off the card asks the person to turn it on instead of showing triage rows kept from when it was on.++ // The opt-in is checked here, as it is immediately before every model call.router.get('/api/v1/me/mail/receipts', read, wrap(async (req, res) => {const owner = ownerOf(req);-- const receipts = await db.many(sql`SELECT thread_id, sender AS merchant, subject, summary, received_at++ const consent = await db.maybe(sql`SELECT enabled FROM mail_inbox_ai_consent WHERE owner_subject = ${owner}`);++ const enabled = !!(consent && consent.enabled);++ const receipts = enabled ? await db.many(sql`SELECT thread_id, message_id, sender AS merchant, subject, summary, received_atFROM mail_inbox_ai_threads WHERE owner_subject = ${owner} AND category = 'receipts'-- ORDER BY received_at DESC NULLS LAST LIMIT 200`);-- return noStore(res).json({ receipts });++ ORDER BY received_at DESC NULLS LAST LIMIT 200`) : [];++ return noStore(res).json({ enabled, receipts });}));const rules = require('../domain/inbox-ai-rules');@@@ -87,8 +87,18 @@ test('rules are owner-scoped, journal category changes, and undo restores the prconst report = await runInboxAi({ db: s.db, stalwart: s.mail, analyze: async () => ({ category: 'to_read', summary: 'Order receipt' }) });assert.strictEqual(report.triaged, 1);const receipts = await s.get('/api/v1/me/mail/receipts', { as: alice });++ assert.strictEqual(receipts.body.enabled, true, 'the card can tell Inbox AI is on');assert.strictEqual(receipts.body.receipts.length, 1);assert.strictEqual(receipts.body.receipts[0].merchant, '[email protected]');++ assert.ok(receipts.body.receipts[0].message_id, 'the card has the message to link to');++ assert.strictEqual((await s.get('/api/v1/me/mail/receipts', { as: bob })).body.receipts.length, 0, 'another person sees none');++ // Turning Inbox AI off hides the summaries kept from when it was on: the card asks to turn it back on rather++ // than showing receipts the person no longer consented to have triaged.++ await s.put('/api/v1/me/mail/ai-settings', { as: alice, json: { enabled: false } });++ const off = await s.get('/api/v1/me/mail/receipts', { as: alice });++ assert.strictEqual(off.body.enabled, false);++ assert.deepStrictEqual(off.body.receipts, []);++ await s.put('/api/v1/me/mail/ai-settings', { as: alice, json: { enabled: true } });const journal = await s.get('/api/v1/me/mail/ai-journal', { as: alice });assert.strictEqual(journal.body.entries.length, 1);assert.strictEqual(journal.body.entries[0].previous_category, 'to_read');
@@ -20,7 +20,8 @@ the send route runs only after the person explicitly clicks Send. Outbound relay
@@ -20,7 +20,8 @@ the send route runs only after the person explicitly clicks Send. Outbound relay
exact sender address and/or subject substring and assigns a category.
exact sender address and/or subject substring and assigns a category.
- `GET /api/v1/me/mail/ai-journal` lists category changes made by rules. `POST
- `GET /api/v1/me/mail/ai-journal` lists category changes made by rules. `POST
/api/v1/me/mail/ai-journal/{id}/undo` restores the previous category when the thread has not since changed.
/api/v1/me/mail/ai-journal/{id}/undo` restores the previous category when the thread has not since changed.
-- `GET /api/v1/me/mail/receipts` is the read-only handoff for openvibe.my: `{ receipts: [{ thread_id, merchant,
- subject, summary, received_at }] }`. It contains only the person's own receipt-category summaries.
+- `GET /api/v1/me/mail/receipts` is the read-only handoff for openvibe.my: `{ enabled, receipts: [{ thread_id,
+ message_id, merchant, subject, summary, received_at }] }`. `enabled` is the mailbox's Inbox AI opt-in; when it is
+ off or there is no mailbox, `receipts` is empty. It contains only the person's own receipt-category summaries.
These routes require a person token and are private/no-store. Rule journals never contain message bodies.
These routes require a person token and are private/no-store. Rule journals never contain message bodies.
@@ -72,6 +72,7 @@
@@ -72,6 +72,7 @@
});
});
side.appendChild(ul);
side.appendChild(ul);
var aiBox = el('section', 'ib-ai-settings');
var aiBox = el('section', 'ib-ai-settings');
+ aiBox.id = 'inbox-ai'; // openvibe.my's Receipts card links here to turn Inbox AI on
aiBox.appendChild(el('h2', 'ib-h', 'Inbox AI'));
aiBox.appendChild(el('h2', 'ib-h', 'Inbox AI'));
var aiLabel = el('label', 'ib-ai-label');
var aiLabel = el('label', 'ib-ai-label');
var aiToggle = el('input');
var aiToggle = el('input');
@@ -380,7 +381,14 @@
@@ -380,7 +381,14 @@
// The addresses come from the API when the shell does not carry them.
// The addresses come from the API when the shell does not carry them.
function begin() {
function begin() {
paintSide();
paintSide();
- loadAiControls().then(paintSide).catch(function () {});
+ loadAiControls().then(function () {
+ paintSide();
+ // A link from openvibe.my's Receipts card to #inbox-ai lands on the switch, which only exists after this paint.
+ if (location.hash === '#inbox-ai') {
+ var ai = document.getElementById('inbox-ai');
+ if (ai && ai.scrollIntoView) ai.scrollIntoView({ block: 'center' });
+ }
+ }).catch(function () {});
loadList();
loadList();
}
}
function loadAiControls() {
function loadAiControls() {
@@ -77,12 +77,17 @@ function mountReading(router, { db, stalwart, config, smtp, principal, wrap, fai
@@ -77,12 +77,17 @@ function mountReading(router, { db, stalwart, config, smtp, principal, wrap, fai
return noStore(res).json({ counts, threads: items });
return noStore(res).json({ counts, threads: items });
}));
}));
+ // The read-only handoff for openvibe.my's Receipts card. `enabled` says whether Inbox AI is on for this mailbox:
+ // with it off the card asks the person to turn it on instead of showing triage rows kept from when it was on.
+ // The opt-in is checked here, as it is immediately before every model call.
router.get('/api/v1/me/mail/receipts', read, wrap(async (req, res) => {
router.get('/api/v1/me/mail/receipts', read, wrap(async (req, res) => {
const owner = ownerOf(req);
const owner = ownerOf(req);
- const receipts = await db.many(sql`SELECT thread_id, sender AS merchant, subject, summary, received_at
+ const consent = await db.maybe(sql`SELECT enabled FROM mail_inbox_ai_consent WHERE owner_subject = ${owner}`);
+ const enabled = !!(consent && consent.enabled);
+ const receipts = enabled ? await db.many(sql`SELECT thread_id, message_id, sender AS merchant, subject, summary, received_at
FROM mail_inbox_ai_threads WHERE owner_subject = ${owner} AND category = 'receipts'
FROM mail_inbox_ai_threads WHERE owner_subject = ${owner} AND category = 'receipts'
- ORDER BY received_at DESC NULLS LAST LIMIT 200`);
- return noStore(res).json({ receipts });
+ ORDER BY received_at DESC NULLS LAST LIMIT 200`) : [];
+ return noStore(res).json({ enabled, receipts });
}));
}));
const rules = require('../domain/inbox-ai-rules');
const rules = require('../domain/inbox-ai-rules');
@@ -87,8 +87,18 @@ test('rules are owner-scoped, journal category changes, and undo restores the pr
@@ -87,8 +87,18 @@ test('rules are owner-scoped, journal category changes, and undo restores the pr
const report = await runInboxAi({ db: s.db, stalwart: s.mail, analyze: async () => ({ category: 'to_read', summary: 'Order receipt' }) });
const report = await runInboxAi({ db: s.db, stalwart: s.mail, analyze: async () => ({ category: 'to_read', summary: 'Order receipt' }) });
assert.strictEqual(report.triaged, 1);
assert.strictEqual(report.triaged, 1);
const receipts = await s.get('/api/v1/me/mail/receipts', { as: alice });
const receipts = await s.get('/api/v1/me/mail/receipts', { as: alice });
+ assert.strictEqual(receipts.body.enabled, true, 'the card can tell Inbox AI is on');
assert.strictEqual(receipts.body.receipts.length, 1);
assert.strictEqual(receipts.body.receipts.length, 1);
assert.strictEqual(receipts.body.receipts[0].merchant, '[email protected]');
assert.strictEqual(receipts.body.receipts[0].merchant, '[email protected]');
+ assert.ok(receipts.body.receipts[0].message_id, 'the card has the message to link to');
+ assert.strictEqual((await s.get('/api/v1/me/mail/receipts', { as: bob })).body.receipts.length, 0, 'another person sees none');
+ // Turning Inbox AI off hides the summaries kept from when it was on: the card asks to turn it back on rather
+ // than showing receipts the person no longer consented to have triaged.
+ await s.put('/api/v1/me/mail/ai-settings', { as: alice, json: { enabled: false } });
+ const off = await s.get('/api/v1/me/mail/receipts', { as: alice });
+ assert.strictEqual(off.body.enabled, false);
+ assert.deepStrictEqual(off.body.receipts, []);
+ await s.put('/api/v1/me/mail/ai-settings', { as: alice, json: { enabled: true } });
const journal = await s.get('/api/v1/me/mail/ai-journal', { as: alice });
const journal = await s.get('/api/v1/me/mail/ai-journal', { as: alice });
assert.strictEqual(journal.body.entries.length, 1);
assert.strictEqual(journal.body.entries.length, 1);
assert.strictEqual(journal.body.entries[0].previous_category, 'to_read');
assert.strictEqual(journal.body.entries[0].previous_category, 'to_read');