feat(mailboxes): postkasser kan forbindes fra fladen
Release / Build and publish image (push) Failing after 9m59s
Release / Build and publish image (push) Failing after 9m59s
Panelet under Integrationer: tilføj, hent nu, kobl fra. Sit eget frem for en tilstand mere inde i Gmail-panelet, for de to er ikke det samme i forskellige klæder — det ene er en app, man logger ind igennem, det andet et kodeord, man selv holder. Feltet hedder IMAP-server og ikke Server: der stod i forvejen Server på CalDAV længere nede, og to felter med samme navn på én side er tvetydige også for en skærmlæser. Testen fandt det, men det var ikke kun testens problem. To vagter fangede mangler undervejs, og begge havde ret: mailbox_failed skulle have en tekst i fladen, ellers ville nogen få serverens engelske logsprog at se, og de tre ruter skulle i openapi.yaml. Der er nu et Mailbox-skema samme sted, som siger hvad der ikke kommer tilbage — kodeordet og eventuelle tokens. E2E-prøven forbinder til noget, der ikke lytter, og ser efter værtens egen afvisning på skærmen. Den er samtidig prøven på at 422 var det rigtige valg: var det stadig 502, ville et mellemled kunne kaste beskeden væk, og der ville stå "noget gik galt". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6b4ef5b2f2
commit
c9dfffd95a
@@ -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: |
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
@@ -352,6 +419,66 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Mailboxes read over IMAP: iCloud, Fastmail, any ordinary host. Its own panel
|
||||
rather than a second mode inside the Gmail one, because they are not the same
|
||||
thing wearing different clothes — one is an app you sign in through, the other
|
||||
is a password you hold. -->
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('int.mailboxes')}</h2>
|
||||
<p class="hint">{t('int.mailboxesHint')}</p>
|
||||
</header>
|
||||
|
||||
{#if mailboxes.length}
|
||||
<ul class="mailboxes">
|
||||
{#each mailboxes as box (box.id)}
|
||||
<li>
|
||||
<div class="what">
|
||||
<strong>{box.name}</strong>
|
||||
<span class="hint">{box.username} · {box.host}</span>
|
||||
</div>
|
||||
<button class="ghost" onclick={() => syncMailbox(box)} disabled={syncingId === box.id}>
|
||||
{syncingId === box.id ? t('int.fetching') : t('int.fetchNow')}
|
||||
</button>
|
||||
<button class="ghost remove" onclick={() => removeMailbox(box)}>
|
||||
{t('int.disconnect')}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if addingMailbox}
|
||||
<form class="add-mailbox" onsubmit={addMailbox}>
|
||||
<div class="field">
|
||||
<label for="mb-host">{t('int.host')}</label>
|
||||
<input id="mb-host" bind:value={newMailbox.host} placeholder="imap.mail.me.com:993" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="mb-user">{t('int.username')}</label>
|
||||
<input id="mb-user" bind:value={newMailbox.username} autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="mb-pass">{t('int.appPassword')}</label>
|
||||
<input id="mb-pass" type="password" bind:value={newMailbox.password} autocomplete="off" />
|
||||
<p class="hint">{t('int.appPasswordHint')}</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="submit" class="primary" disabled={connecting}>
|
||||
{connecting ? t('int.connecting') : t('int.connect')}
|
||||
</button>
|
||||
<button type="button" class="ghost" onclick={() => (addingMailbox = false)}>
|
||||
{t('account.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="row">
|
||||
<button class="ghost" onclick={() => (addingMailbox = true)}>{t('int.addMailbox')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Folded away once it is set: it is a one-off registration, and a panel that
|
||||
keeps showing its own setup instructions after setup reads as unfinished. */
|
||||
|
||||
Reference in New Issue
Block a user