diff --git a/docs/openapi.yaml b/docs/openapi.yaml index bc0e33e..fdf03c7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2477,6 +2477,86 @@ paths: created: { type: integer } "502": { $ref: "#/components/responses/Upstream" } + /api/v1/mailboxes: + get: + tags: [integrations] + summary: The mailboxes this person has connected. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + mailboxes: + type: array + items: { $ref: "#/components/schemas/Mailbox" } + post: + tags: [integrations] + summary: Connect a mailbox over IMAP. + description: > + The connection is tested before it is saved, so a mailbox that cannot be + read is refused here rather than failing quietly every ten minutes. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [host, username, password] + properties: + name: { type: string } + host: { type: string, example: "imap.mail.me.com:993" } + username: { type: string } + password: { type: string, description: "An app-specific password on every host worth naming. Stored sealed." } + folder: { type: string, default: INBOX } + responses: + "201": + description: Connected + content: + application/json: + schema: { $ref: "#/components/schemas/Mailbox" } + "400": { $ref: "#/components/responses/BadRequest" } + "409": { description: That mailbox is already connected } + "422": { $ref: "#/components/responses/Upstream" } + + /api/v1/mailboxes/{mailboxID}: + delete: + tags: [integrations] + summary: Disconnect a mailbox. The tasks it made stay. + parameters: + - name: mailboxID + in: path + required: true + schema: { type: string } + responses: + "204": { description: Disconnected } + + /api/v1/mailboxes/{mailboxID}/sync: + post: + tags: [integrations] + summary: Read one mailbox now. + parameters: + - name: mailboxID + in: path + required: true + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + created: { type: integer } + partial: + type: boolean + description: The run hit its time budget. What it made is kept. + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Upstream" } + /api/v1/ai/settings: get: tags: [integrations] @@ -3082,6 +3162,20 @@ components: items: { type: string } schemas: + Mailbox: + type: object + description: One connected mailbox. The password and any token are never returned. + properties: + id: { type: string } + kind: { type: string, enum: [gmail, imap] } + name: { type: string } + host: { type: string } + username: { type: string } + folder: { type: string } + last_uid: { type: integer } + last_sync_at: { type: string, format: date-time } + created_at: { type: string, format: date-time } + Error: type: object description: | diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 2abb197..2ba6808 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1814,3 +1814,35 @@ test('man kan skrive hvor som helst, og feltet fortæller hvad det forstår', as expect(trouble).toEqual([]); }); + +test('en postkasse kan tilføjes, og værtens afvisning når frem til skærmen', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/indstillinger/integrationer'); + + await expect(page.getByRole('heading', { name: 'Postkasser' })).toBeVisible(); + await page.getByRole('button', { name: 'Tilføj en postkasse' }).click(); + + // Nothing is listening there, so the dial fails at once. The point is not the + // failure but where it is reported: the server tests the connection before it + // saves, so this is refused at the door rather than accepted and then silently + // failing every ten minutes. + await page.getByLabel('IMAP-server').fill('127.0.0.1:1'); + await page.getByLabel('Brugernavn').fill('kw'); + await page.getByLabel('App-kodeord').fill('hemmelig'); + // `exact`, because "Forbind" is a prefix of "Forbind Gmail" one panel up and + // Playwright matches an accessible name by substring unless told otherwise. + await page.getByRole('button', { name: 'Forbind', exact: true }).click(); + + // The host's own words, not "noget gik galt". This is the whole reason + // upstream refusals are answered as 422: a proxy may replace the body of a 5xx, + // and then the person is told nothing. + await expect(page.getByText(/Postkassen svarede/)).toBeVisible(); + + // And nothing was saved on the way past. + await page.reload(); + await expect(page.getByText('127.0.0.1:1')).toBeHidden(); + + // The 422 above is the point of the test, not a symptom. Everything else the + // watcher caught still has to be empty. + expect(trouble.filter((t) => !t.includes('/mailboxes → 422'))).toEqual([]); +}); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 87280d5..ab41c91 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -58,6 +58,9 @@ const MESSAGES = { // The fallback for when Gmail refused and said nothing usable. When it did say // something, `humanMessage` quotes it instead — see below. gmail_failed: 'error.gmailRefused', + // Same shape for a mailbox read over IMAP: quoted when the host said something, + // this when it did not. + mailbox_failed: 'error.mailboxRefused', last_admin: 'error.lastAdmin' }; @@ -75,6 +78,9 @@ export function humanMessage(err) { // authentication scopes" — and a generic sentence in their place throws away // the only thing that says what to do. Wrapped so it reads as a quotation // rather than as a stray English string in a Danish interface. + if (err.code === 'mailbox_failed' && err.message) { + return t('error.mailboxSaid', { what: err.message }); + } if (err.code === 'gmail_failed' && err.message) { return t('error.gmailSaid', { what: err.message }); } @@ -354,6 +360,13 @@ export const api = { authorizeGmail: () => post('/gmail/authorize'), syncGmail: () => post('/gmail/sync'), + // Mailboxes read over IMAP. Each belongs to the person who connected it, so + // there is no instance-wide registration behind these the way Gmail has one. + mailboxes: () => get('/mailboxes'), + addMailbox: (body) => post('/mailboxes', body), + deleteMailbox: (id) => del(`/mailboxes/${id}`), + syncMailbox: (id) => post(`/mailboxes/${id}/sync`), + getAISettings: () => get('/ai/settings'), setAISettings: (data) => put('/ai/settings', data), aiSummary: () => post('/ai/summary'), diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 770718f..ae3a5da 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -459,6 +459,22 @@ export const da = { 'detail.attachFile': 'Vedhæft en fil', // --- settings: integrations -------------------------------------------------------- + 'int.mailboxes': 'Postkasser', + 'int.mailboxesHint': + 'Flag en mail i iCloud, Fastmail eller enhver anden udbyder, og den bliver en opgave. Én vej: fjerner du flaget, sker der ikke noget med opgaven. Office 365 kan ikke bruges her — Microsoft har lukket for kodeord på IMAP.', + 'int.addMailbox': 'Tilføj en postkasse', + 'int.host': 'IMAP-server', + 'int.username': 'Brugernavn', + 'int.appPassword': 'App-kodeord', + 'int.appPasswordHint': + 'Ikke dit almindelige kodeord. iCloud og de fleste andre laver et særskilt et til programmer, som du kan trække tilbage alene. Det gemmes krypteret.', + 'int.connect': 'Forbind', + 'int.connecting': 'Forbinder …', + 'int.disconnect': 'Kobl fra', + 'int.disconnectMailbox': 'Kobl {name} fra? Opgaverne, den har lavet, bliver.', + 'int.mailboxConnected': 'Postkassen er forbundet.', + 'int.fetchNow': 'Hent nu', + 'int.fetching': 'Henter …', 'int.gmail': 'Gmail', 'int.gmailHint': 'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave. Envejs: at fjerne stjernen gør ikke noget ved opgaven.', @@ -663,6 +679,8 @@ export const da = { 'error.gmailSaid': 'Gmail svarede: {what}', + 'error.mailboxRefused': 'Postkassen kunne ikke læses.', + 'error.mailboxSaid': 'Postkassen svarede: {what}', 'error.gmailRefused': 'Gmail afviste kaldet. Prøv at forbinde postkassen igen.', 'int.newAddressQuestion': 'Ny adresse? Mail til den gamle bliver afvist.', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index c3096ca..0c20b3a 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -455,6 +455,22 @@ export const en = { 'detail.attachFile': 'Attach a file', // --- settings: integrations -------------------------------------------------------- + 'int.mailboxes': 'Mailboxes', + 'int.mailboxesHint': + 'Flag an email in iCloud, Fastmail or any other provider and it becomes a task. One way: removing the flag does nothing to the task. Office 365 cannot be used here — Microsoft has closed IMAP to passwords.', + 'int.addMailbox': 'Add a mailbox', + 'int.host': 'IMAP server', + 'int.username': 'Username', + 'int.appPassword': 'App password', + 'int.appPasswordHint': + 'Not your ordinary password. iCloud and most others issue a separate one for applications, which you can withdraw on its own. It is stored encrypted.', + 'int.connect': 'Connect', + 'int.connecting': 'Connecting …', + 'int.disconnect': 'Disconnect', + 'int.disconnectMailbox': 'Disconnect {name}? The tasks it made will stay.', + 'int.mailboxConnected': 'The mailbox is connected.', + 'int.fetchNow': 'Fetch now', + 'int.fetching': 'Fetching …', 'int.gmail': 'Gmail', 'int.gmailHint': 'Star an email, or give it a label, and it becomes a task. One way: removing the star does nothing to the task.', @@ -658,6 +674,8 @@ export const en = { 'error.gmailSaid': 'Gmail said: {what}', + 'error.mailboxRefused': 'The mailbox could not be read.', + 'error.mailboxSaid': 'The mailbox answered: {what}', 'error.gmailRefused': 'Gmail refused the call. Try connecting the mailbox again.', 'int.newAddressQuestion': 'A new address? Mail to the old one is refused.', diff --git a/web/src/routes/indstillinger/integrationer/+page.svelte b/web/src/routes/indstillinger/integrationer/+page.svelte index 70e4d47..7bcde56 100644 --- a/web/src/routes/indstillinger/integrationer/+page.svelte +++ b/web/src/routes/indstillinger/integrationer/+page.svelte @@ -182,6 +182,73 @@ app.toast(t('int.copyFailed')); } } + + // --- mailboxes ---------------------------------------------------------------- + + let mailboxes = $state([]); + let addingMailbox = $state(false); + let connecting = $state(false); + let syncingId = $state(null); + let newMailbox = $state({ host: '', username: '', password: '' }); + + $effect(() => { + loadMailboxes(); + }); + + async function loadMailboxes() { + try { + mailboxes = (await api.mailboxes()).mailboxes ?? []; + } catch { + // A settings page that will not render because one panel could not load + // is worse than a panel that is empty. + mailboxes = []; + } + } + + async function addMailbox(event) { + event.preventDefault(); + connecting = true; + try { + // The server dials before it saves, so an error here is the host's own + // refusal — a wrong app password, a host that is not listening. + await api.addMailbox({ ...newMailbox }); + newMailbox = { host: '', username: '', password: '' }; + addingMailbox = false; + await loadMailboxes(); + app.toast(t('int.mailboxConnected')); + } catch (e) { + app.toast(humanMessage(e)); + } finally { + connecting = false; + } + } + + async function syncMailbox(box) { + syncingId = box.id; + try { + const result = await api.syncMailbox(box.id); + app.toast( + result?.created + ? plural(result.created, 'int.gmailFetchedOne', 'int.gmailFetchedMany') + : t('int.gmailNothingNew') + ); + await loadMailboxes(); + } catch (e) { + app.toast(humanMessage(e)); + } finally { + syncingId = null; + } + } + + async function removeMailbox(box) { + if (!confirm(t('int.disconnectMailbox', { name: box.name }))) return; + try { + await api.deleteMailbox(box.id); + await loadMailboxes(); + } catch (e) { + app.toast(humanMessage(e)); + } + }
@@ -352,6 +419,66 @@
+ +
+
+

{t('int.mailboxes')}

+

{t('int.mailboxesHint')}

+
+ + {#if mailboxes.length} + + {/if} + + {#if addingMailbox} +
+
+ + +
+
+ + +
+
+ + +

{t('int.appPasswordHint')}

+
+
+ + +
+
+ {:else} +
+ +
+ {/if} +
+