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 @@
+
+
+
+