hafn spec v0.3 · uppdaterad 2026-08-30 · maskinform: /docs.md

integrationen · den enda sida din agent behöver

docsprovisionering · nycklar · kalender · push

Allt en agent behöver för att integrera blint mot hafn. Uppmätt mot servern som kör — inte lovat.

Där verkligheten skaver mot standarden står det utskrivet. En spec som ljuger är värre än ingen.

1 · provisionering (invite-only)

Ett konto med adress, lösenord, kalender och en scopad API-nyckel skapas i ett anrop. Fältnamnen är svenska. Auktoritativt schema: /openapi.json.

POST https://hafn.eu/v1/konto
Content-Type: application/json

{"inbjudningskod": "hafn-xxxx-xxxx-xxxx",
 "kontonamn": "exempel",
 "adminkontakt": "namn@exempel.se"}
inbjudningskodengångskod från hafn · ^hafn(-[a-z2-9]{4}){3}$
kontonamn^[a-z][a-z0-9-]{2,29}$ — blir adressen kontonamn@hafn.eu
adminkontakte-postadress till kontots kontaktperson

Svar 201:

{"konto": "...", "adress": "...", "engangslosenord": "...",
 "apinyckel": "API_...", "apinyckelns_scope": ["..."],
 "jmap": "https://mail.hafn.eu/jmap/", "imap": {...}, "smtp": {...},
 "viktigt": "..."}

Lösenordet och API-nyckeln visas en gång och lagras inte hos hafn. Felformen är {"fel", "beskrivning"}: 400 ogiltiga fält · 403 ogiltig eller använd kod · 409 upptaget namn (koden förbrukas inte) · 429 för många försök · 502 bakomliggande fel (inget skapat, koden förbrukas inte).

Samma endpoint tar application/x-www-form-urlencoded och svarar då med en kvittosida i HTML — formulärvägen för människor. Fälten är desamma. Ingen kod? Se /invite.

2 · nyckeln och autentiseringen

Authorization: Bearer API_...

Nyckeln används som Bearer mot JMAP: https://mail.hafn.eu/jmap/. Som lösenord i HTTP Basic avvisas den med 401.

Behörigheterna sitter per JMAP-metod, i camelCase: jmapEmailGet, jmapCalendarEventCreate… Skrivning är delad per operation — jmapEmailCreate/Update/Destroy och jmapEmailSubmissionCreate; *Set-namn finns inte. Läget Replace ger exakt de listade metoderna, ingenting annat.

Nyckeln från provisioneringen kan läsa post, läsa och skriva kalender och hantera pushprenumerationer. Sändning ingår inte: EmailSubmission/set ger forbidden. Nyckeln bär även expiresAt och allowedIps; ett konto kan ha högst fem nycklar, och över taket svarar servern med det vilseledande felet invalidPatch — kontrollera antalet nycklar innan du felsöker något annat.

Rotation utan avbrott: skapa den nya, båda giltiga samtidigt, återkalla den gamla. Kontots lösenord påverkas inte. Men: en återkallad nyckel stänger inte en redan öppen pushström — bryt även uppkopplingen.

3 · kalendersemantik

recurrenceRule är singular och ett objekt. RFC 8984:s pluralform recurrenceRules (array) avvisas med invalidProperties — även i sin enklaste form.

"recurrenceRule": {"frequency": "weekly", "until": "2026-12-31T17:00:00",
                   "byDay": [{"day": "th"}]}
"recurrenceOverrides": {"2026-09-03T17:00:00": {"start": "2026-09-03T18:30:00"}}

Undantag är recurrenceOverrides, nycklade på förekomstens ursprungliga starttid. Att avsluta en serie är att sätta fältet until — ingen RRULE-sträng behöver någonsin tolkas.

status är förstklassigt: tentative | confirmed | cancelled, och överlever uttaget som STATUS:TENTATIVE.

Heldag: showWithoutTime: true, en duration i dagar, ingen tidszon. Varning: expandRecurrences tappar heldagsflaggan och sätter Etc/UTC — även på händelser som inte är återkommande. Läs mästarna och expandera själv, eller gå genom MCP-skalet som rättar felet. En klient som går rakt på JMAP med expansion ärver buggen.

Sök serier som serier: CalendarEvent/query utan expandRecurrences ger mästarnas id. Samtidighet: ifInState (tillståndet är på typnivå). Ägarskap i delat konto: en Participant med calendarAddress — den enda bärare som uppmätt överlever en tredjepartsklient som skriver om händelsen.

4 · uppgifter

Det finns ingen Task/get, och urn:ietf:params:jmap:tasks finns inte i sessionen — leta inte. Uppgifter går genom CalendarEvent-metoderna med "@type": "Task" och progress i stället för status. Båda riktningarna är uppmätta: VTODO via CalDAV läses som Task i JMAP och tvärtom. En dedikerad lista skapas med MKCALENDAR och enbart VTODO i komponentuppsättningen; den syns i Calendar/get.

5 · push och billig ändringskontroll

GET https://mail.hafn.eu/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}

EventSource fungerar. Notisen är en StateChange — datatyp och nytt tillstånd, aldrig vad som ändrats. Prenumerera på types=EmailDelivery,CalendarEvent för nyankommet utan flaggbrus. Servern skickar ingen keepalive (ping= ignoreras) och inga id:-fält — håll egen timer och kör alltid */changes efter varje återuppkoppling.

WebSocket fungerar: wss://mail.hafn.eu/jmap/ws, subprotokoll jmap, push via WebSocketPushEnable.

PushSubscription (webhook) fungerar inte. Prenumerationen lagras men ingen verifiering och ingen notis skickas någonsin — noll utgående paket, uppmätt. Bygg ingenting på den.

Känd lucka: strömmarna lyder inte nyckelns scope — en nyckel utan läsrätt får ändå varje StateChange, och en öppen ström överlever återkallelse. Rapporterat uppströms; skalet är vägen runt tills det är lagat.

Delta: */changes kostar ~200 byte när inget hänt. Spara tillstånd per typ. Ett skadat sinceState fäller hela begäran med HTTP 400 notRequest — tolka det som ”gör full synk”. sessionState duger inte som ändringssignal.

6 · gränser

Utgående post: 100 brev per dygn. Taket räknas i dag per avsändaradress och på könivå — brev 101 avvisas inte utan skjuts upp till nästa UTC-dygn. Högre tak: POST /v1/begar-mer {"konto", "motivering"}202; en människa läser och hör av sig. Agentnyckeln har ingen sändning som standard.

7 · mcp-skalet och uttag

För agentklienter finns ett MCP-skal med verktygslista filtrerad per nyckel — hela ytan på /agents. Uttag: ett konto kan ta ut post och kalender i standardformat medan servern kör; hittills provat i liten skala, och det står hellre här än låtsas färdigtestat.

GET hafn.eu/docs.md · text/markdown · 200 · det här är exakt vad din agent hämtar

# hafn · docs — integrationen

> Den här sidan räcker för att integrera blint mot hafn. Allt tekniskt nedan är
> uppmätt mot servern som kör (Stalwart 0.16.19 på mail.hafn.eu) — inte läst i
> dokumentation. Auktoritativt schema för provisioneringen: https://hafn.eu/openapi.json
> spec v0.3 · 2026-08-30 · människoform: https://hafn.eu/docs

## 1. Provisionering  (invite-only)

Ett konto med adress, lösenord, kalender och en scopad API-nyckel skapas i ett
anrop. Fältnamnen är svenska.

    POST https://hafn.eu/v1/konto
    Content-Type: application/json

    {"inbjudningskod": "hafn-xxxx-xxxx-xxxx",
     "kontonamn": "exempel",
     "adminkontakt": "namn@exempel.se"}

- inbjudningskod   engångskod från hafn, mönster ^hafn(-[a-z2-9]{4}){3}$
- kontonamn        mönster ^[a-z][a-z0-9-]{2,29}$ — blir adressen kontonamn@hafn.eu
- adminkontakt     e-postadress till kontots kontaktperson

Svar 201:

    {"konto": "...", "adress": "...", "engangslosenord": "...",
     "apinyckel": "API_...", "apinyckelns_scope": ["..."],
     "jmap": "https://mail.hafn.eu/jmap/", "imap": {...}, "smtp": {...},
     "viktigt": "..."}

- engangslosenord och apinyckel visas EN gång och lagras inte hos hafn.
- Fel: 400 ogiltiga fält · 403 ogiltig eller använd kod · 409 upptaget kontonamn
  (koden förbrukas inte) · 429 för många försök · 502 bakomliggande fel (inget
  skapat, koden förbrukas inte). Felform: {"fel": "...", "beskrivning": "..."}.
- Samma endpoint tar application/x-www-form-urlencoded och svarar då med en
  kvittosida i HTML — det är formulärvägen för människor. Fälten är desamma.
- Ingen inbjudningskod? Se https://hafn.eu/invite (hello@hafn.eu).

## 2. Nyckeln och autentiseringen

    Authorization: Bearer API_...

- Nyckeln används som Bearer mot JMAP: https://mail.hafn.eu/jmap/
  Nyckeln som lösenord i HTTP Basic avvisas med 401.
- Behörigheterna sitter per JMAP-metod, i camelCase: jmapEmailGet,
  jmapCalendarEventCreate, jmapPushSubscriptionGet …
  Skrivning är delad per operation: jmapEmailCreate / jmapEmailUpdate /
  jmapEmailDestroy och jmapEmailSubmissionCreate. *Set-namn finns inte.
- Läget Replace ger exakt de listade metoderna, ingenting annat.
- Nyckeln från provisioneringen kan läsa post, läsa och skriva kalender och
  hantera pushprenumerationer. Sändning ingår inte: EmailSubmission/set ger
  forbidden. Scopet står i apinyckelns_scope i svaret.
- Nyckeln bär även expiresAt och allowedIps. Ett konto kan ha högst fem
  API-nycklar; över taket ger servern det vilseledande felet invalidPatch
  "Invalid key for object property" — kontrollera antalet nycklar först.
- Rotation utan avbrott: skapa den nya, båda är giltiga samtidigt, återkalla
  den gamla. Kontots eget lösenord påverkas inte.
- Varning: en återkallad nyckel stänger INTE en redan öppen pushström —
  auktorisationen prövas bara vid uppkopplingen. Bryt även uppkopplingen.

## 3. Kalendersemantik  (uppmätt mot servern)

- recurrenceRule är SINGULAR och ett objekt. RFC 8984:s pluralform
  recurrenceRules (array) avvisas med invalidProperties — även i sin enklaste
  form. Skriv singular:

      "recurrenceRule": {"frequency": "weekly", "until": "2026-12-31T17:00:00",
                         "byDay": [{"day": "th"}]}

- Undantag är recurrenceOverrides — en map nycklad på förekomstens
  URSPRUNGLIGA starttid, med de avvikande fälten som värde:

      "recurrenceOverrides": {"2026-09-03T17:00:00": {"start": "2026-09-03T18:30:00"}}

- Att avsluta en serie är att sätta fältet until. Ingen RRULE-sträng behöver
  någonsin tolkas eller skrivas.
- status är ett förstklassigt fält: tentative | confirmed | cancelled.
  Det överlever uttaget som STATUS:TENTATIVE i iCalendar.
- Heldag: showWithoutTime: true, en duration i dagar, ingen timeZone.
  VARNING: expandRecurrences tappar showWithoutTime och sätter
  timeZone: "Etc/UTC" — även på händelser som inte är återkommande. Läs
  mästarna och expandera själv, eller gå genom MCP-skalet som rättar felet.
  En klient som går rakt på JMAP med expandRecurrences ärver buggen.
- Sök serier som serier: CalendarEvent/query UTAN expandRecurrences
  returnerar mästarnas id, inte förekomsterna.
- Samtidighet: skicka ifInState i CalendarEvent/set. Tillståndet är på
  typnivå (hela kalenderkontot) — en krock kan alltså gälla en annan händelse.
- Ägarskap i ett delat konto: märk personen som Participant med
  calendarAddress (t.ex. mailto:rut@hushall.hafn.eu — adressen behöver ingen
  brevlåda). Det är den enda bärare som uppmätt överlever en tredjepartsklient
  som skriver om händelsen; keywords, categories och color gör det inte.

## 4. Uppgifter

- Det finns ingen Task/get och urn:ietf:params:jmap:tasks finns inte i
  sessionen. Leta inte efter dem.
- Uppgifter går genom CalendarEvent-metoderna med "@type": "Task" i stället
  för "Event", och progress (t.ex. needs-action) i stället för status.
  Båda riktningarna är uppmätta: en VTODO lagd via CalDAV läses som Task i
  JMAP, och en Task skapad via CalendarEvent/set blir korrekt VTODO i CalDAV.
- En dedikerad lista skapas med CalDAV MKCALENDAR och en komponentuppsättning
  med enbart VTODO; den syns i Calendar/get som vilken kalender som helst.

## 5. Push och billig ändringskontroll

- EventSource (SSE) fungerar, med Bearer:

      GET https://mail.hafn.eu/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}

  Notisen är en StateChange: {"@type":"StateChange","changed":{"<konto>":
  {"CalendarEvent":"<state>"}}} — datatyp och nytt tillstånd, ALDRIG vad som
  ändrats. Prenumerera på types=EmailDelivery,CalendarEvent för nyankommen
  post utan flaggbrus.
- Servern skickar ingen keepalive (ping= ignoreras) och inga id:-fält, så
  Last-Event-ID-återupptagning finns inte. Håll egen timer, och kör alltid
  */changes efter varje återuppkoppling.
- WebSocket fungerar: wss://mail.hafn.eu/jmap/ws, subprotokoll jmap, push slås
  på med {"@type":"WebSocketPushEnable","dataTypes":[...]}.
- PushSubscription (webhook) fungerar INTE: prenumerationen lagras men ingen
  verifiering och ingen notis skickas någonsin (0 utgående paket, uppmätt).
  Bygg ingenting på den.
- Känd lucka: strömmarna lyder inte nyckelns scope — en nyckel utan läsrätt
  får ändå varje StateChange, och en återkallad nyckel behåller en öppen
  ström. Rapporterat uppströms; MCP-skalet är vägen runt tills det är lagat.
- Delta: */changes är billigt (~200 B när inget hänt). Spara tillstånd PER TYP.
  Ett skadat sinceState fäller hela begäran med HTTP 400
  urn:ietf:params:jmap:error:notRequest — tolka det som "gör full synk".
  sessionState duger inte som ändringssignal.

## 6. Gränser

- Utgående post: 100 brev per dygn. Taket räknas i dag per avsändaradress och
  på könivå: brev 101 avvisas inte utan skjuts upp till nästa UTC-dygn.
  Högre tak: POST https://hafn.eu/v1/begar-mer {"konto": "...", "motivering": "..."}
  → 202; en människa läser förfrågan och hör av sig. Eller hello@hafn.eu.
- Agentnyckeln har ingen sändning som standard. Sändning är en egen behörighet
  (jmapEmailSubmissionCreate) som kontots ägare måste ge uttryckligen.

## 7. MCP-skalet

För agentklienter finns ett MCP-skal (streamable HTTP) över samma data, med
verktygslista filtrerad per nyckelns scope. Hela ytan: https://hafn.eu/agents.md

## 8. Uttag

Ett konto kan ta ut post och kalender i standardformat medan servern kör —
uttaget stoppar ingen annan. Hittills provat i liten skala; formaten är
vanlig mbox/ICS-standard som andra system läser.

## Källa och sanning

Fältnamnen i §1 är verifierade mot https://hafn.eu/openapi.json och
https://hafn.eu/llms.txt. Ändras något av detta ändras dokumentet först.
Frågor: hello@hafn.eu