feat(indbakke): en adresse, andre programmer kan skubbe en opgave til

Der var to veje ind i forvejen, og ingen af dem passer til "gem den her,
mens jeg står med telefonen i hånden". API-tokenet giver adgang til alt
og skal have en JSON-krop skrevet rigtigt; mailadressen virker først, når
nogen har sat en mailserver op, og går gennem en indbakke, der kan være
minutter om det. Det, der manglede, var en URL, man kan smide en linje
tekst efter — fra en genvej på telefonen, et script, en tjeneste, der kan
kalde en URL.

Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en
tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en
genvej, der bare sender det, man har markeret. Første linje bliver til
opgaven, resten til noten under den. Linjen læses med quick-add-parseren,
så "Ring til Anders i morgen p1 #Firma" betyder det samme her som i
feltet øverst i fladen — dét er hele pointen med at have én parser: den
vej, teksten kom ind ad, ændrer ikke, hvad den betyder.

Sit eget token frem for at låne mailens. De to er den samme slags
hemmelighed, men de slipper ud hver for sig: en URL, der har ligget i en
genvej på en telefon, der blev væk, skal kunne skiftes uden at
mailadressen — som står i andres adressebøger — skifter med.

Og ikke et API-token. Et token herfra kan én ting: lave en opgave i den
persons indbakke. Det er hele forskellen på en hemmelighed, man kan lægge
i et script på en anden maskine, og en, man ikke kan.

Kun POST. En GET ville være nemmere at kalde, og det er præcis
problemet: forudindlæsning i en browser, en linkkontrol i en chat og en
historik, der bliver besøgt igen, ville alle lave opgaver, ingen har
bedt om. Sin egen kvote frem for postens, så en travl integration ikke
kan lukke for den indgående post.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-09-08 14:03:32 +02:00
co-authored by Claude Opus 5
parent 0764fbd566
commit d063425cae
10 changed files with 633 additions and 0 deletions
+5
View File
@@ -418,6 +418,11 @@ export const api = {
getMailAddress: () => get('/mail-address'),
rotateMailAddress: () => post('/mail-address/rotate'),
// Krogen ind i indbakken. Selve skubbet sker ikke herfra — det er et andet
// program, der gør det — så der er kun adressen at hente og at skifte.
getHookURL: () => get('/hook-url'),
rotateHookURL: () => post('/hook-url/rotate'),
getGmail: () => get('/gmail'),
setGmail: (data) => put('/gmail', data),
disconnectGmail: () => del('/gmail'),
+11
View File
@@ -746,6 +746,17 @@ export const da = {
'Der er ingen mailserver sat op på denne server, så adressen herunder kan ikke modtage noget endnu. Sæt VERDANDE_SMTP_HOST og de øvrige SMTP-indstillinger, og få mailserveren til at aflevere post til todo+* videre til /inbound/mail.',
'int.yourAddress': 'Din adresse',
'int.newAddress': 'Ny adresse',
// Krogen ind i indbakken: en URL, et andet program kan skubbe en opgave til.
'int.hookToTask': 'Skub fra andre programmer',
'int.hookHint':
'En adresse, andre programmer kan sende en opgave til — en genvej på telefonen, et script, en tjeneste, der kan kalde en URL. Linjen læses som i feltet øverst i fladen, så “i morgen”, “p1” og “#Projekt” betyder det samme her.',
'int.yourHook': 'Din adresse',
'int.hookHow':
'Send en POST med teksten som krop — ren tekst, JSON med et {text}-felt, eller et formularfelt. Første linje bliver opgaven, resten står under den. Adressen er en nøgle: den, der har den, kan lægge opgaver i din indbakke og ikke andet.',
'int.hookExample': 'Sådan ser det ud',
'int.newHook': 'Ny adresse',
'int.newHookQuestion':
'Vil du have en ny adresse? Den gamle holder op med at virke med det samme, og alt, der bruger den, skal rettes.',
'int.caldav': 'CalDAV',
'int.caldavHint':
'Tovejs, i modsætning til feedet ovenfor: Apple Påmindelser og Thunderbird kan også skrive tilbage.',
+11
View File
@@ -743,6 +743,17 @@ export const en = {
'No mail server is configured on this server, so the address below cannot receive anything yet. Set VERDANDE_SMTP_HOST and the other SMTP settings, and have the mail server deliver post for todo+* to /inbound/mail.',
'int.yourAddress': 'Your address',
'int.newAddress': 'New address',
// The hook into the inbox: a URL another program can push a task to.
'int.hookToTask': 'Push from other programs',
'int.hookHint':
'An address other programs can send a task to — a shortcut on your phone, a script, any service that can call a URL. The line is read the way the box at the top of the app reads it, so “tomorrow”, “p1” and “#Project” mean the same here.',
'int.yourHook': 'Your address',
'int.hookHow':
'Send a POST with the text as the body — plain text, JSON with a {text} field, or a form field. The first line becomes the task and the rest sits underneath it. The address is a key: whoever has it can put tasks in your inbox, and nothing else.',
'int.hookExample': 'What it looks like',
'int.newHook': 'New address',
'int.newHookQuestion':
'Do you want a new address? The old one stops working immediately, and anything using it has to be changed.',
'int.caldav': 'CalDAV',
'int.caldavHint':
'Two-way, unlike the feed above: Apple Reminders and Thunderbird can write back as well.',
@@ -289,6 +289,9 @@
let feedURL = $state('');
let mailAddress = $state('');
// Krogen ind i indbakken: den URL, en genvej på telefonen eller et script kan
// skubbe en linje tekst til.
let hookURL = $state('');
/** Null until loaded, so the warning does not flash before the answer is in. */
let mailConfigured = $state(null);
@@ -301,6 +304,10 @@
mailConfigured = r.configured;
})
.catch(() => {});
api
.getHookURL()
.then((r) => (hookURL = r.url))
.catch(() => {});
});
async function rotateFeed() {
@@ -323,6 +330,15 @@
}
}
async function rotateHook() {
if (!confirm(t('int.newHookQuestion'))) return;
try {
hookURL = (await api.rotateHookURL()).url;
} catch (e) {
app.toast(humanMessage(e));
}
}
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
@@ -674,6 +690,38 @@
</div>
</section>
<section class="panel">
<header>
<h2>{t('int.hookToTask')}</h2>
<p class="hint">
{t('int.hookHint')}
</p>
</header>
<div class="field">
<label for="hook">{t('int.yourHook')}</label>
<input id="hook" class="mono" value={hookURL} readonly />
<p class="hint">
{t('int.hookHow')}
</p>
</div>
<!-- Vist som den kommando, den er. Den, der skal sætte det op, skal kunne
prøve det af ét sted, før de bygger genvejen på telefonen — og se, at
kroppen bare er den linje, opgaven skal hedde. -->
<div class="field">
<label for="hookcurl">{t('int.hookExample')}</label>
<textarea id="hookcurl" class="mono" rows="3" readonly
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</textarea
>
</div>
<div class="row">
<button class="secondary" onclick={() => copy(hookURL)}>{t('int.copy')}</button>
<button class="danger" onclick={rotateHook}>{t('int.newHook')}</button>
</div>
</section>
<section class="panel">
<header>
<h2>{t('int.caldav')}</h2>