openvibers/openvibe.mail Commits

Inbox AI receipts handoff honours the opt-in (#25)

OP OpenVibers committed 5 days ago · f4d8dadb35 · 4 files, +30 −6

GET /api/v1/me/mail/receipts now says whether Inbox AI is on for the
mailbox and returns no triage rows when it is off or the mailbox is
gone, instead of handing over summaries kept from when it was on. The
answer also names the message, so openvibe.my's Receipts card can link
the thread to the inbox message that opens it.

The Inbox AI switch has an #inbox-ai anchor and the page scrolls to it,
for the card's turn-it-on link.

Tests cover the on answer (enabled, message id, owner scope) and the
off answer hiding the rows.

Changes

ddiff --git a/docs/INBOX-AI.md b/docs/INBOX-AI.md
iindex 28d55d6..d6f66cb 100644
---- a/docs/INBOX-AI.md
++++ b/docs/INBOX-AI.md
@@@ -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.
- `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.
ddiff --git a/public/js/inbox.js b/public/js/inbox.js
iindex ce96180..40a804d 100644
---- a/public/js/inbox.js
++++ b/public/js/inbox.js
@@@ -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 on
aiBox.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() {
ddiff --git a/server/http/reading-routes.js b/server/http/reading-routes.js
iindex edfdeae..96baa07 100644
---- a/server/http/reading-routes.js
++++ b/server/http/reading-routes.js
@@@ -77,12 +77,17 @@ function mountReading(router, { db, stalwart, config, smtp, principal, wrap, fai
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) => {
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'
-- 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');
ddiff --git a/test/inbox-ai.test.js b/test/inbox-ai.test.js
iindex 3d9a0e2..8f934b6 100644
---- a/test/inbox-ai.test.js
++++ b/test/inbox-ai.test.js
@@@ -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' }) });
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');