diff --git a/internal/ai/ai.go b/internal/ai/ai.go
new file mode 100644
index 0000000..9d73b9f
--- /dev/null
+++ b/internal/ai/ai.go
@@ -0,0 +1,369 @@
+// Package ai is a provider-agnostic layer over chat completions.
+//
+// One interface, adapters for Anthropic, OpenAI, Google, and anything speaking the
+// OpenAI-compatible shape — which is what a locally-run model behind Ollama, vLLM
+// or LM Studio offers. The last of those is why the abstraction exists at all: the
+// owner runs their own models, and an integration that only spoke to a hosted API
+// would be useless to them.
+//
+// Every feature that uses this degrades to nothing when no provider is configured.
+// AI here is a convenience on top of a to-do app that works without it, not a
+// dependency the app has acquired.
+package ai
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// ErrNotConfigured is returned when no provider is set up. Callers treat it as
+// "this feature is off", not as a failure.
+var ErrNotConfigured = errors.New("ai: no provider is configured")
+
+type Provider string
+
+const (
+ ProviderAnthropic Provider = "anthropic"
+ ProviderOpenAI Provider = "openai"
+ ProviderGoogle Provider = "google"
+ // ProviderCompatible is any OpenAI-shaped endpoint: Ollama, vLLM, LM Studio,
+ // OpenRouter, a company gateway. Base URL and model are configured by hand.
+ ProviderCompatible Provider = "compatible"
+)
+
+type Config struct {
+ Provider Provider
+ APIKey string
+ // BaseURL overrides the provider's default endpoint. Required for
+ // ProviderCompatible and useful for a proxy in front of the others.
+ BaseURL string
+ Model string
+}
+
+func (c Config) Configured() bool {
+ if c.Provider == "" || c.Model == "" {
+ return false
+ }
+ // A local model needs no key, which is exactly the case a key check would
+ // break — so the requirement is per-provider rather than universal.
+ if c.Provider == ProviderCompatible {
+ return c.BaseURL != ""
+ }
+ return c.APIKey != ""
+}
+
+type Message struct {
+ Role string `json:"role"` // "user" or "assistant"
+ Content string `json:"content"`
+}
+
+// Client talks to whichever provider is configured.
+type Client struct {
+ cfg Config
+ http *http.Client
+}
+
+func New(cfg Config) *Client {
+ return &Client{
+ cfg: cfg,
+ // Generous: a local model on modest hardware can take a while to produce
+ // its first token, and the alternative is a timeout that only ever fires
+ // for the people running their own.
+ http: &http.Client{Timeout: 120 * time.Second},
+ }
+}
+
+// Complete sends a system prompt and a conversation, and returns the reply.
+func (c *Client) Complete(ctx context.Context, system string, messages []Message) (string, error) {
+ if !c.cfg.Configured() {
+ return "", ErrNotConfigured
+ }
+ switch c.cfg.Provider {
+ case ProviderAnthropic:
+ return c.anthropic(ctx, system, messages)
+ case ProviderGoogle:
+ return c.google(ctx, system, messages)
+ default:
+ // OpenAI and every compatible endpoint share one request shape.
+ return c.openAICompatible(ctx, system, messages)
+ }
+}
+
+// --- Anthropic --------------------------------------------------------------------
+
+func (c *Client) anthropic(ctx context.Context, system string, messages []Message) (string, error) {
+ base := c.cfg.BaseURL
+ if base == "" {
+ base = "https://api.anthropic.com"
+ }
+
+ body := map[string]any{
+ "model": c.cfg.Model,
+ "max_tokens": 2048,
+ "system": system,
+ "messages": messages,
+ }
+ req, err := c.request(ctx, base+"/v1/messages", body)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("x-api-key", c.cfg.APIKey)
+ req.Header.Set("anthropic-version", "2023-06-01")
+
+ var parsed struct {
+ Content []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"content"`
+ }
+ if err := c.do(req, &parsed); err != nil {
+ return "", err
+ }
+
+ var out strings.Builder
+ for _, block := range parsed.Content {
+ if block.Type == "text" {
+ out.WriteString(block.Text)
+ }
+ }
+ return out.String(), nil
+}
+
+// --- OpenAI and compatible ----------------------------------------------------------
+
+func (c *Client) openAICompatible(ctx context.Context, system string, messages []Message) (string, error) {
+ base := c.cfg.BaseURL
+ if base == "" {
+ base = "https://api.openai.com"
+ }
+ base = strings.TrimSuffix(base, "/")
+ // A locally-run endpoint is usually given as ".../v1" already; appending it
+ // again is the single most common way this is misconfigured.
+ if !strings.HasSuffix(base, "/v1") {
+ base += "/v1"
+ }
+
+ all := append([]map[string]string{{"role": "system", "content": system}}, toOpenAI(messages)...)
+ req, err := c.request(ctx, base+"/chat/completions", map[string]any{
+ "model": c.cfg.Model,
+ "messages": all,
+ })
+ if err != nil {
+ return "", err
+ }
+ if c.cfg.APIKey != "" {
+ req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
+ }
+
+ var parsed struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ }
+ if err := c.do(req, &parsed); err != nil {
+ return "", err
+ }
+ if len(parsed.Choices) == 0 {
+ return "", fmt.Errorf("ai: the model returned nothing")
+ }
+ return parsed.Choices[0].Message.Content, nil
+}
+
+func toOpenAI(messages []Message) []map[string]string {
+ out := make([]map[string]string, 0, len(messages))
+ for _, m := range messages {
+ out = append(out, map[string]string{"role": m.Role, "content": m.Content})
+ }
+ return out
+}
+
+// --- Google -------------------------------------------------------------------------
+
+func (c *Client) google(ctx context.Context, system string, messages []Message) (string, error) {
+ base := c.cfg.BaseURL
+ if base == "" {
+ base = "https://generativelanguage.googleapis.com"
+ }
+
+ contents := make([]map[string]any, 0, len(messages))
+ for _, m := range messages {
+ role := "user"
+ if m.Role == "assistant" {
+ role = "model" // Google's name for the same thing
+ }
+ contents = append(contents, map[string]any{
+ "role": role, "parts": []map[string]string{{"text": m.Content}},
+ })
+ }
+
+ body := map[string]any{
+ "contents": contents,
+ "systemInstruction": map[string]any{"parts": []map[string]string{{"text": system}}},
+ }
+ url := fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", base, c.cfg.Model, c.cfg.APIKey)
+ req, err := c.request(ctx, url, body)
+ if err != nil {
+ return "", err
+ }
+
+ var parsed struct {
+ Candidates []struct {
+ Content struct {
+ Parts []struct {
+ Text string `json:"text"`
+ } `json:"parts"`
+ } `json:"content"`
+ } `json:"candidates"`
+ }
+ if err := c.do(req, &parsed); err != nil {
+ return "", err
+ }
+ if len(parsed.Candidates) == 0 {
+ return "", fmt.Errorf("ai: the model returned nothing")
+ }
+
+ var out strings.Builder
+ for _, part := range parsed.Candidates[0].Content.Parts {
+ out.WriteString(part.Text)
+ }
+ return out.String(), nil
+}
+
+// --- plumbing ---------------------------------------------------------------------------
+
+func (c *Client) request(ctx context.Context, url string, body any) (*http.Request, error) {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ return req, nil
+}
+
+func (c *Client) do(req *http.Request, out any) error {
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return fmt.Errorf("ai: %w", err)
+ }
+ defer resp.Body.Close()
+
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode >= 300 {
+ // The provider's own message is included: for a local model it is usually
+ // the actual problem ("model not found"), and hiding it would leave the
+ // operator with nothing to go on.
+ return fmt.Errorf("ai: %s said %s: %s", req.URL.Host, resp.Status, truncate(string(raw), 300))
+ }
+ return json.Unmarshal(raw, out)
+}
+
+func truncate(s string, max int) string {
+ if len(s) <= max {
+ return s
+ }
+ return s[:max] + "…"
+}
+
+// --- features ------------------------------------------------------------------------
+
+// SplitIntoSubtasks asks for a task to be broken down.
+//
+// The prompt insists on a bare JSON array because the result is parsed, and a model
+// that helpfully wraps it in prose or a code fence produces a parse failure that
+// looks to the user like the feature being broken.
+func (c *Client) SplitIntoSubtasks(ctx context.Context, task, description, locale string) ([]string, error) {
+ language := "Danish"
+ if strings.HasPrefix(strings.ToLower(locale), "en") {
+ language = "English"
+ }
+
+ system := fmt.Sprintf(`You break a task into concrete sub-tasks.
+
+Reply with a JSON array of strings and nothing else — no prose, no code fence.
+Write the sub-tasks in %s. Give between two and seven of them. Each one should be
+a single concrete action somebody could do in one sitting, phrased the way the
+original task is phrased. Do not restate the original task as a sub-task.`, language)
+
+ prompt := "Task: " + task
+ if description != "" {
+ prompt += "\nNotes: " + description
+ }
+
+ reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
+ if err != nil {
+ return nil, err
+ }
+
+ var subtasks []string
+ if err := json.Unmarshal([]byte(extractJSON(reply)), &subtasks); err != nil {
+ return nil, fmt.Errorf("ai: the model did not return a list: %s", truncate(reply, 200))
+ }
+
+ out := make([]string, 0, len(subtasks))
+ for _, s := range subtasks {
+ if s = strings.TrimSpace(s); s != "" {
+ out = append(out, s)
+ }
+ }
+ if len(out) == 0 {
+ return nil, fmt.Errorf("ai: the model returned an empty list")
+ }
+ return out, nil
+}
+
+// WeeklySummary produces a short prioritisation note over what is outstanding.
+func (c *Client) WeeklySummary(ctx context.Context, tasks []string, locale string) (string, error) {
+ language := "Danish"
+ if strings.HasPrefix(strings.ToLower(locale), "en") {
+ language = "English"
+ }
+
+ system := fmt.Sprintf(`You help somebody decide what to do this week.
+
+Write in %s. Be brief — at most six short lines. Say what looks most urgent and
+why, and name anything that appears to be slipping. Do not restate the whole list
+back, do not congratulate, and do not offer productivity advice.`, language)
+
+ prompt := "These are the open tasks:\n" + strings.Join(tasks, "\n")
+ return c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
+}
+
+// extractJSON pulls an array out of a reply that may have been wrapped in a code
+// fence or preceded by a sentence, which models do however firmly they are asked
+// not to.
+func extractJSON(reply string) string {
+ reply = strings.TrimSpace(reply)
+
+ if fence := strings.Index(reply, "```"); fence >= 0 {
+ rest := reply[fence+3:]
+ if newline := strings.IndexByte(rest, '\n'); newline >= 0 {
+ rest = rest[newline+1:]
+ }
+ if end := strings.Index(rest, "```"); end >= 0 {
+ reply = strings.TrimSpace(rest[:end])
+ }
+ }
+
+ start := strings.IndexByte(reply, '[')
+ end := strings.LastIndexByte(reply, ']')
+ if start >= 0 && end > start {
+ return reply[start : end+1]
+ }
+ return reply
+}
diff --git a/internal/caldav/caldav.go b/internal/caldav/caldav.go
new file mode 100644
index 0000000..9968a7c
--- /dev/null
+++ b/internal/caldav/caldav.go
@@ -0,0 +1,236 @@
+// Package caldav implements the WebDAV and CalDAV verbs a task client needs
+// (RFC 4791, over RFC 4918), so Apple Reminders and Thunderbird can read, create
+// and complete tasks in verdande directly.
+//
+// Only the VTODO half is implemented, and only the parts a real client actually
+// uses: PROPFIND to discover the collection, REPORT to fetch its contents, and
+// GET/PUT/DELETE on individual items. Calendar scheduling, free/busy and the whole
+// VEVENT side are not here, because nothing that reads tasks asks for them.
+//
+// XML is written as text rather than through encoding/xml. The namespace prefixing
+// CalDAV requires — DAV:, caldav and calendarserver all interleaved in one
+// document — is genuinely more awkward to express through struct tags than to write
+// out, and the shape of these responses is fixed.
+package caldav
+
+import (
+ "encoding/xml"
+ "fmt"
+ "strings"
+)
+
+// Namespaces, with the prefixes clients expect to see.
+const (
+ NSDav = "DAV:"
+ NSCalDAV = "urn:ietf:params:xml:ns:caldav"
+ NSCalSrv = "http://calendarserver.org/ns/"
+ NSApple = "http://apple.com/ns/ical/"
+)
+
+// PropfindRequest is the parsed body of a PROPFIND, which names the properties the
+// client is asking about.
+type PropfindRequest struct {
+ // AllProp is set when the client asked for everything rather than a list.
+ AllProp bool
+ Props []string
+}
+
+// ParsePropfind reads a PROPFIND body. An empty body means allprop, which is what
+// the spec says and what some clients rely on.
+func ParsePropfind(body []byte) PropfindRequest {
+ if len(strings.TrimSpace(string(body))) == 0 {
+ return PropfindRequest{AllProp: true}
+ }
+
+ var doc struct {
+ XMLName xml.Name
+ AllProp *struct{} `xml:"DAV: allprop"`
+ Prop struct {
+ Any []xml.Name `xml:",any"`
+ } `xml:"DAV: prop"`
+ }
+ if err := xml.Unmarshal(body, &doc); err != nil {
+ // A body that will not parse is treated as allprop rather than as an
+ // error: clients send surprising things, and answering with everything is
+ // always a valid response to a PROPFIND.
+ return PropfindRequest{AllProp: true}
+ }
+ if doc.AllProp != nil {
+ return PropfindRequest{AllProp: true}
+ }
+
+ req := PropfindRequest{}
+ for _, name := range doc.Prop.Any {
+ req.Props = append(req.Props, name.Space+":"+name.Local)
+ }
+ return req
+}
+
+func (r PropfindRequest) Wants(space, local string) bool {
+ if r.AllProp {
+ return true
+ }
+ for _, p := range r.Props {
+ if p == space+":"+local {
+ return true
+ }
+ }
+ return false
+}
+
+// Multistatus builds a 207 response body.
+type Multistatus struct {
+ responses []string
+}
+
+// AddCollection describes a calendar collection — the project itself.
+func (m *Multistatus) AddCollection(href, displayName, ctag string, req PropfindRequest) {
+ var props []string
+
+ if req.Wants(NSDav, "resourcetype") {
+ props = append(props, ``)
+ }
+ if req.Wants(NSDav, "displayname") {
+ props = append(props, ``+escapeXML(displayName)+``)
+ }
+ if req.Wants(NSCalDAV, "supported-calendar-component-set") {
+ // VTODO only. Announcing VEVENT as well would make Apple Calendar offer to
+ // put appointments here, which it would then have nowhere to store.
+ props = append(props,
+ ``)
+ }
+ if req.Wants(NSCalSrv, "getctag") {
+ // The collection tag: clients poll it and only re-sync when it changes.
+ // Getting this wrong means either constant full syncs or stale data.
+ props = append(props, ``+escapeXML(ctag)+``)
+ }
+ if req.Wants(NSDav, "getetag") {
+ props = append(props, `"`+escapeXML(ctag)+`"`)
+ }
+ if req.Wants(NSApple, "calendar-color") {
+ props = append(props, `#5FBF8FFF`)
+ }
+ if req.Wants(NSCalDAV, "calendar-description") {
+ props = append(props, ``+escapeXML(displayName)+``)
+ }
+
+ m.add(href, props)
+}
+
+// AddItem describes one VTODO resource.
+func (m *Multistatus) AddItem(href, etag, calendarData string, req PropfindRequest) {
+ var props []string
+
+ if req.Wants(NSDav, "getetag") {
+ props = append(props, `"`+escapeXML(etag)+`"`)
+ }
+ if req.Wants(NSDav, "resourcetype") {
+ props = append(props, ``)
+ }
+ if req.Wants(NSDav, "getcontenttype") {
+ props = append(props, `text/calendar; component=vtodo`)
+ }
+ if calendarData != "" && req.Wants(NSCalDAV, "calendar-data") {
+ props = append(props, ``+escapeXML(calendarData)+``)
+ }
+
+ m.add(href, props)
+}
+
+// AddPrincipal describes the user, which is what a client asks for first when it is
+// working out where its calendars live.
+func (m *Multistatus) AddPrincipal(href, calendarHome, displayName string, req PropfindRequest) {
+ var props []string
+
+ if req.Wants(NSDav, "resourcetype") {
+ props = append(props, ``)
+ }
+ if req.Wants(NSDav, "displayname") {
+ props = append(props, ``+escapeXML(displayName)+``)
+ }
+ if req.Wants(NSDav, "current-user-principal") || req.Wants(NSDav, "principal-URL") {
+ props = append(props,
+ ``+escapeXML(href)+``,
+ ``+escapeXML(href)+``)
+ }
+ if req.Wants(NSCalDAV, "calendar-home-set") {
+ props = append(props,
+ ``+escapeXML(calendarHome)+``)
+ }
+
+ m.add(href, props)
+}
+
+func (m *Multistatus) add(href string, props []string) {
+ if len(props) == 0 {
+ // A response with no properties still has to exist, or the client sees the
+ // resource as absent rather than as having nothing it asked for.
+ props = append(props, ``)
+ }
+ m.responses = append(m.responses, fmt.Sprintf(
+ `%s%sHTTP/1.1 200 OK`,
+ escapeXML(href), strings.Join(props, "")))
+}
+
+// AddNotFound records a resource the client asked about that does not exist. Needed
+// by sync: it is how a client learns a task was deleted somewhere else.
+func (m *Multistatus) AddNotFound(href string) {
+ m.responses = append(m.responses, fmt.Sprintf(
+ `%sHTTP/1.1 404 Not Found`,
+ escapeXML(href)))
+}
+
+func (m *Multistatus) XML() string {
+ return `` +
+ `` +
+ strings.Join(m.responses, "") +
+ ``
+}
+
+// --- REPORT ---------------------------------------------------------------------------
+
+// ReportRequest is the parsed body of a calendar-query or calendar-multiget.
+type ReportRequest struct {
+ // Multiget lists specific hrefs; a query asks for everything matching a filter.
+ Multiget bool
+ Hrefs []string
+ Props PropfindRequest
+}
+
+func ParseReport(body []byte) ReportRequest {
+ var doc struct {
+ XMLName xml.Name
+ Prop struct {
+ Any []xml.Name `xml:",any"`
+ } `xml:"DAV: prop"`
+ Hrefs []string `xml:"DAV: href"`
+ }
+ if err := xml.Unmarshal(body, &doc); err != nil {
+ return ReportRequest{Props: PropfindRequest{AllProp: true}}
+ }
+
+ req := ReportRequest{
+ Multiget: strings.Contains(strings.ToLower(doc.XMLName.Local), "multiget"),
+ Hrefs: doc.Hrefs,
+ }
+ for _, name := range doc.Prop.Any {
+ req.Props.Props = append(req.Props.Props, name.Space+":"+name.Local)
+ }
+ if len(req.Props.Props) == 0 {
+ req.Props.AllProp = true
+ }
+ return req
+}
+
+// escapeXML is deliberately its own function rather than xml.EscapeText: task titles
+// arrive from users, and every one of them passes through here on its way into a
+// document a client will parse. A missed escape is a client that fails to sync with
+// a parse error nobody can see the cause of.
+func escapeXML(s string) string {
+ var b strings.Builder
+ if err := xml.EscapeText(&b, []byte(s)); err != nil {
+ return ""
+ }
+ return b.String()
+}
diff --git a/internal/httpapi/caldav_handlers.go b/internal/httpapi/caldav_handlers.go
new file mode 100644
index 0000000..48f0c7f
--- /dev/null
+++ b/internal/httpapi/caldav_handlers.go
@@ -0,0 +1,392 @@
+package httpapi
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/kristianwind/verdande/internal/caldav"
+ "github.com/kristianwind/verdande/internal/ics"
+ "github.com/kristianwind/verdande/internal/store"
+)
+
+// chi only routes the standard HTTP verbs. WebDAV's are registered here, once,
+// before any router is built — PROPFIND and REPORT are the two a task client needs,
+// and without this the router panics at construction rather than at request time.
+func init() {
+ for _, method := range []string{"PROPFIND", "REPORT", "PROPPATCH", "MKCALENDAR"} {
+ chi.RegisterMethod(method)
+ }
+}
+
+// CalDAV lives at /caldav and speaks HTTP Basic with an API token as the password.
+//
+// Basic rather than the session cookie because that is what CalDAV clients offer:
+// Apple Reminders and Thunderbird both ask for a username and password and send
+// them on every request. The username is the email address and the password is a
+// personal API token — never the account password, so a client's stored credential
+// cannot be used to sign in to the app or change the password.
+
+func (s *Server) caldavAuth(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ email, token, ok := r.BasicAuth()
+ if !ok {
+ s.caldavChallenge(w)
+ return
+ }
+
+ user, err := s.db.UserByAPIToken(r.Context(), token)
+ if err != nil || !strings.EqualFold(user.Email, strings.TrimSpace(email)) {
+ s.caldavChallenge(w)
+ return
+ }
+ next.ServeHTTP(w, r.WithContext(withUser(r.Context(), user)))
+ })
+}
+
+func (s *Server) caldavChallenge(w http.ResponseWriter) {
+ w.Header().Set("WWW-Authenticate", `Basic realm="verdande"`)
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+}
+
+// handleCalDAVOptions advertises what this server can do. Clients read the DAV
+// header before they will try anything else; without "calendar-access" in it,
+// Apple Reminders decides this is a plain WebDAV share and never looks for tasks.
+func (s *Server) handleCalDAVOptions(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("DAV", "1, 2, 3, calendar-access")
+ w.Header().Set("Allow", "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT")
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleCalDAVPrincipal answers the discovery request: who am I, and where are my
+// calendars.
+func (s *Server) handleCalDAVPrincipal(w http.ResponseWriter, r *http.Request) {
+ user := userFrom(r.Context())
+ body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ req := caldav.ParsePropfind(body)
+
+ m := &caldav.Multistatus{}
+ m.AddPrincipal("/caldav/"+user.ID+"/", "/caldav/"+user.ID+"/", user.Name, req)
+ writeMultistatus(w, m)
+}
+
+// handleCalDAVHome lists the calendars: one per project.
+//
+// A project per calendar rather than one calendar for everything, because that is
+// the unit a person shares and the unit a client lets you turn on and off.
+func (s *Server) handleCalDAVHome(w http.ResponseWriter, r *http.Request) {
+ user := userFrom(r.Context())
+ body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ req := caldav.ParsePropfind(body)
+
+ projects, err := s.db.ListProjects(r.Context(), user.ID, false)
+ if err != nil {
+ s.internal(w, "caldav home", err)
+ return
+ }
+
+ m := &caldav.Multistatus{}
+ m.AddCollection("/caldav/"+user.ID+"/", "verdande", "home", req)
+
+ // Depth: 0 means the client wants the home itself and not its children.
+ if r.Header.Get("Depth") != "0" {
+ for _, p := range projects {
+ ctag, err := s.projectCTag(r, p.ID)
+ if err != nil {
+ continue
+ }
+ m.AddCollection("/caldav/"+user.ID+"/"+p.ID+"/", p.Name, ctag, req)
+ }
+ }
+ writeMultistatus(w, m)
+}
+
+// handleCalDAVCalendar lists the tasks in one project.
+func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) {
+ user := userFrom(r.Context())
+ projectID := chi.URLParam(r, "projectID")
+
+ if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleViewer); err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ req := caldav.ParsePropfind(body)
+
+ project, err := s.db.GetProject(r.Context(), projectID, user.ID)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ ctag, _ := s.projectCTag(r, projectID)
+
+ m := &caldav.Multistatus{}
+ m.AddCollection("/caldav/"+user.ID+"/"+projectID+"/", project.Name, ctag, req)
+
+ if r.Header.Get("Depth") != "0" {
+ tasks, err := s.caldavTasks(r, user, projectID)
+ if err != nil {
+ s.internal(w, "caldav tasks", err)
+ return
+ }
+ for _, t := range tasks {
+ m.AddItem(taskHref(user.ID, projectID, t.ID), taskETag(t), "", req)
+ }
+ }
+ writeMultistatus(w, m)
+}
+
+// handleCalDAVReport answers calendar-query and calendar-multiget: the client
+// asking for the actual VTODO bodies.
+func (s *Server) handleCalDAVReport(w http.ResponseWriter, r *http.Request) {
+ user := userFrom(r.Context())
+ projectID := chi.URLParam(r, "projectID")
+
+ if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleViewer); err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ body, _ := io.ReadAll(io.LimitReader(r.Body, 4<<20))
+ req := caldav.ParseReport(body)
+
+ tasks, err := s.caldavTasks(r, user, projectID)
+ if err != nil {
+ s.internal(w, "caldav report", err)
+ return
+ }
+ byID := map[string]store.Task{}
+ for _, t := range tasks {
+ byID[t.ID] = t
+ }
+
+ m := &caldav.Multistatus{}
+
+ if req.Multiget {
+ // The client named specific resources. Anything it asks for that is not
+ // here has to be reported as 404 — that is how it learns a task was
+ // deleted somewhere else.
+ for _, href := range req.Hrefs {
+ id := hrefTaskID(href)
+ task, ok := byID[id]
+ if !ok {
+ m.AddNotFound(href)
+ continue
+ }
+ m.AddItem(href, taskETag(task), s.renderVTODO(r, user, task), req.Props)
+ }
+ } else {
+ for _, t := range tasks {
+ m.AddItem(taskHref(user.ID, projectID, t.ID), taskETag(t), s.renderVTODO(r, user, t), req.Props)
+ }
+ }
+ writeMultistatus(w, m)
+}
+
+// handleCalDAVGet returns one task as a calendar object.
+func (s *Server) handleCalDAVGet(w http.ResponseWriter, r *http.Request) {
+ user := userFrom(r.Context())
+ taskID := strings.TrimSuffix(chi.URLParam(r, "taskFile"), ".ics")
+
+ task, err := s.db.GetTask(r.Context(), taskID, user.ID)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/calendar; charset=utf-8; component=vtodo")
+ w.Header().Set("ETag", `"`+taskETag(*task)+`"`)
+ w.Write([]byte(s.renderVTODO(r, user, *task)))
+}
+
+// handleCalDAVPut creates or updates a task from a VTODO the client sends.
+//
+// This is the half that makes CalDAV two-way: ticking something off in Apple
+// Reminders arrives here as a PUT with STATUS:COMPLETED.
+func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
+ user := userFrom(r.Context())
+ projectID := chi.URLParam(r, "projectID")
+ taskID := strings.TrimSuffix(chi.URLParam(r, "taskFile"), ".ics")
+
+ if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+
+ body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ if err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ parsed, err := ics.ParseVTODO(string(body))
+ if err != nil {
+ http.Error(w, "could not read the calendar object", http.StatusBadRequest)
+ return
+ }
+
+ existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID)
+ if getErr == nil {
+ // An update. Only the fields a task client can express are touched;
+ // everything else the task carries stays as it is.
+ content := parsed.Summary
+ update := store.TaskUpdate{Content: &content, Description: &parsed.Description}
+ update.SetDue = true
+ update.DueDate = parsed.DueDate
+ update.DueDatetime = parsed.DueDatetime
+
+ if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil {
+ s.internal(w, "caldav update", err)
+ return
+ }
+ if parsed.Completed && !existing.Completed() {
+ if _, err := s.db.CompleteTask(r.Context(), taskID, user.ID); err != nil {
+ s.log.Warn("caldav complete", "err", err)
+ }
+ }
+ if !parsed.Completed && existing.Completed() {
+ if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
+ s.log.Warn("caldav reopen", "err", err)
+ }
+ }
+
+ updated, _ := s.db.GetTask(r.Context(), taskID, user.ID)
+ if updated != nil {
+ s.publish(projectID, "task.updated", toTaskJSON(*updated))
+ w.Header().Set("ETag", `"`+taskETag(*updated)+`"`)
+ }
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ // A new task. The client chose the resource name, so that becomes the id —
+ // which is what lets it address the task it just created without re-syncing.
+ task := &store.Task{
+ ID: taskID, ProjectID: projectID, Content: parsed.Summary,
+ Description: parsed.Description, Priority: parsed.Priority,
+ DueDate: parsed.DueDate, DueDatetime: parsed.DueDatetime,
+ RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
+ }
+ if strings.TrimSpace(task.Content) == "" {
+ task.Content = "(uden titel)"
+ }
+ if err := s.db.CreateTask(r.Context(), task, nil); err != nil {
+ s.internal(w, "caldav create", err)
+ return
+ }
+ s.publish(projectID, "task.created", toTaskJSON(*task))
+
+ w.Header().Set("ETag", `"`+taskETag(*task)+`"`)
+ w.WriteHeader(http.StatusCreated)
+}
+
+func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) {
+ user := userFrom(r.Context())
+ taskID := strings.TrimSuffix(chi.URLParam(r, "taskFile"), ".ics")
+
+ role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
+ if err != nil || !role.CanEdit() {
+ http.NotFound(w, r)
+ return
+ }
+ if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
+ s.internal(w, "caldav delete", err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// --- helpers ---------------------------------------------------------------------------
+
+func (s *Server) caldavTasks(r *http.Request, user *store.User, projectID string) ([]store.Task, error) {
+ return s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
+ ProjectIDs: []string{projectID},
+ // Completed tasks stay in the collection: a client that stops seeing them
+ // treats them as deleted and removes them from its own store, which loses
+ // the record of having done them.
+ IncludeCompleted: true,
+ Limit: 2000,
+ })
+}
+
+func (s *Server) renderVTODO(r *http.Request, user *store.User, t store.Task) string {
+ return ics.Render(ics.Calendar{
+ Name: "verdande",
+ Domain: feedDomain(s.cfg.BaseURL),
+ Tasks: []ics.Task{{
+ ID: t.ID, Content: t.Content, Description: t.Description,
+ DueDate: t.DueDate, DueDatetime: t.DueDatetime, DurationMin: t.DurationMin,
+ Priority: t.Priority, Recurrence: t.RecurrenceRule,
+ Completed: t.Completed(), CompletedAt: t.CompletedAt,
+ CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
+ }},
+ })
+}
+
+// projectCTag changes whenever anything in the project does, which is what tells a
+// polling client to re-sync. The newest update time plus the row count catches both
+// an edit and a deletion — an edit alone would miss a task being removed.
+func (s *Server) projectCTag(r *http.Request, projectID string) (string, error) {
+ var newest, count int64
+ err := s.db.QueryRowContext(r.Context(),
+ `SELECT COALESCE(max(updated_at), 0), count(*) FROM tasks WHERE project_id = ?`,
+ projectID).Scan(&newest, &count)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%d-%d", newest, count), nil
+}
+
+// taskETag identifies one version of one task. A client compares it to decide
+// whether to fetch the body again.
+func taskETag(t store.Task) string {
+ return fmt.Sprintf("%d", t.UpdatedAt.Unix())
+}
+
+func taskHref(userID, projectID, taskID string) string {
+ return "/caldav/" + userID + "/" + projectID + "/" + taskID + ".ics"
+}
+
+func hrefTaskID(href string) string {
+ parts := strings.Split(strings.TrimSuffix(href, ".ics"), "/")
+ if len(parts) == 0 {
+ return ""
+ }
+ return parts[len(parts)-1]
+}
+
+func writeMultistatus(w http.ResponseWriter, m *caldav.Multistatus) {
+ w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+ w.Header().Set("DAV", "1, 2, 3, calendar-access")
+ w.WriteHeader(http.StatusMultiStatus)
+ w.Write([]byte(m.XML()))
+}
+
+// caldavRoutes mounts the whole thing. Registered outside /api/v1 because CalDAV
+// clients do well-known-path discovery against the root.
+func (s *Server) caldavRoutes(r chi.Router) {
+ r.Use(s.caldavAuth)
+
+ r.Method(http.MethodOptions, "/*", http.HandlerFunc(s.handleCalDAVOptions))
+
+ r.Method("PROPFIND", "/", http.HandlerFunc(s.handleCalDAVPrincipal))
+ r.Method("PROPFIND", "/{userID}/", http.HandlerFunc(s.handleCalDAVHome))
+ r.Method("PROPFIND", "/{userID}/{projectID}/", http.HandlerFunc(s.handleCalDAVCalendar))
+ r.Method("REPORT", "/{userID}/{projectID}/", http.HandlerFunc(s.handleCalDAVReport))
+
+ r.Get("/{userID}/{projectID}/{taskFile}", s.handleCalDAVGet)
+ r.Put("/{userID}/{projectID}/{taskFile}", s.handleCalDAVPut)
+ r.Delete("/{userID}/{projectID}/{taskFile}", s.handleCalDAVDelete)
+}
+
+// wellKnownCalDAV is where clients look first. Apple Reminders asks for
+// /.well-known/caldav on the bare hostname before it will try anything else.
+func (s *Server) wellKnownCalDAV(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/caldav/", http.StatusMovedPermanently)
+}
+
+var _ = time.Now
diff --git a/internal/httpapi/filters_handlers.go b/internal/httpapi/filters_handlers.go
index 1c4bc8e..c70e2d1 100644
--- a/internal/httpapi/filters_handlers.go
+++ b/internal/httpapi/filters_handlers.go
@@ -181,6 +181,12 @@ func filterContext(user *store.User) filterquery.Context {
}
}
+// parseFilter compiles an expression for a user. Shared with the MCP tools, so a
+// filter written in Claude means exactly what the same filter means in the app.
+func parseFilter(query string, user *store.User) (filterquery.Compiled, error) {
+ return filterquery.Parse(query, filterContext(user))
+}
+
func checkFilterQuery(query string, user *store.User) string {
if strings.TrimSpace(query) == "" {
return "required"
diff --git a/internal/httpapi/integrations_handlers.go b/internal/httpapi/integrations_handlers.go
new file mode 100644
index 0000000..655897d
--- /dev/null
+++ b/internal/httpapi/integrations_handlers.go
@@ -0,0 +1,401 @@
+package httpapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/kristianwind/verdande/internal/ai"
+ "github.com/kristianwind/verdande/internal/auth"
+ "github.com/kristianwind/verdande/internal/quickadd"
+ "github.com/kristianwind/verdande/internal/store"
+)
+
+// --- mail to task -------------------------------------------------------------------
+
+type mailAddressResponse struct {
+ Address string `json:"address"`
+}
+
+// handleGetMailAddress returns the personal address that turns an email into a
+// task, minting the token on first ask.
+//
+// The token goes in the local part rather than the domain — todo+@domain —
+// because that is what a single Mailcow alias can route without a wildcard domain
+// or a DNS change per user.
+func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
+ user := userFrom(r.Context())
+
+ token, err := s.db.EnsureMailToken(r.Context(), user.ID)
+ if err != nil {
+ s.internal(w, "mail token", err)
+ return
+ }
+ writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
+}
+
+func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
+ token, err := auth.NewToken()
+ if err != nil {
+ s.internal(w, "generate mail token", err)
+ return
+ }
+ if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
+ s.internal(w, "set mail token", err)
+ return
+ }
+ writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
+}
+
+func (s *Server) mailAddress(token string) string {
+ domain := feedDomain(s.cfg.BaseURL)
+ if from := s.cfg.SMTP.From; strings.Contains(from, "@") {
+ // The mail server's own domain, which is the one that can actually route
+ // this — the web address may well be a tunnel hostname that receives no mail.
+ domain = from[strings.Index(from, "@")+1:]
+ }
+ return "todo+" + token + "@" + domain
+}
+
+type inboundMailRequest struct {
+ // To is the address the mail was sent to, from which the token is read.
+ To string `json:"to"`
+ From string `json:"from"`
+ Subject string `json:"subject"`
+ Body string `json:"body"`
+}
+
+// handleInboundMail turns a delivered email into a task.
+//
+// Mailcow delivers to this endpoint over LMTP or a small forwarding script; either
+// way what arrives here is already-parsed mail. Authentication is the token in the
+// recipient address, which is the only credential an inbound mail can carry.
+//
+// The subject is parsed with the quick-add parser, so "Fakturer Anders p1 #Firma"
+// in a subject line does what it does everywhere else in the app.
+func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
+ var req inboundMailRequest
+ if err := decodeJSON(w, r, &req); err != nil {
+ return
+ }
+
+ token := mailToken(req.To)
+ if token == "" {
+ writeError(w, http.StatusNotFound, CodeNotFound, "no such address")
+ return
+ }
+ user, err := s.db.UserByMailToken(r.Context(), token)
+ if err != nil {
+ // Deliberately identical to a malformed address: an inbound endpoint that
+ // distinguishes them is an oracle for which tokens are live.
+ writeError(w, http.StatusNotFound, CodeNotFound, "no such address")
+ return
+ }
+
+ subject := strings.TrimSpace(req.Subject)
+ if subject == "" {
+ subject = "(uden emne)"
+ }
+
+ parsed := quickadd.Parse(subject, time.Now().In(userLocation(user.Timezone)), user.Locale)
+ content := parsed.Content
+ if strings.TrimSpace(content) == "" {
+ content = subject
+ }
+
+ projectID := ""
+ if parsed.Project != "" {
+ if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
+ projectID = id
+ }
+ }
+ if projectID == "" {
+ if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
+ s.internal(w, "inbox", err)
+ return
+ }
+ }
+
+ task := &store.Task{
+ ProjectID: projectID, Content: content, Priority: parsed.Priority,
+ Description: strings.TrimSpace(req.Body), DueDate: parsed.DueDate,
+ RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
+ }
+ if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
+ s.internal(w, "create task from mail", err)
+ return
+ }
+
+ s.publish(projectID, "task.created", toTaskJSON(*task))
+ s.log.Info("task created from mail", "user", user.ID, "from", req.From)
+ writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID})
+}
+
+// mailToken reads the token out of "Name ".
+func mailToken(address string) string {
+ if i := strings.LastIndex(address, "<"); i >= 0 {
+ address = address[i+1:]
+ }
+ address = strings.TrimSuffix(strings.TrimSpace(address), ">")
+
+ local, _, ok := strings.Cut(address, "@")
+ if !ok {
+ return ""
+ }
+ _, token, ok := strings.Cut(local, "+")
+ if !ok {
+ return ""
+ }
+ return token
+}
+
+// --- AI ---------------------------------------------------------------------------------
+
+type aiSettings struct {
+ Provider string `json:"provider"`
+ BaseURL string `json:"base_url"`
+ Model string `json:"model"`
+ // APIKey is written but never read back — see handleGetAISettings.
+ APIKey string `json:"api_key,omitempty"`
+ // HasKey tells the settings page whether a key is stored, without showing it.
+ HasKey bool `json:"has_key"`
+}
+
+func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) {
+ cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
+ if err != nil {
+ s.internal(w, "ai settings", err)
+ return
+ }
+ // The key is never sent back. A settings page that repopulates a password
+ // field is a settings page that will eventually leak one into a screenshot.
+ writeJSON(w, http.StatusOK, aiSettings{
+ Provider: string(cfg.Provider), BaseURL: cfg.BaseURL,
+ Model: cfg.Model, HasKey: cfg.APIKey != "",
+ })
+}
+
+func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
+ var req aiSettings
+ if err := decodeJSON(w, r, &req); err != nil {
+ return
+ }
+ user := userFrom(r.Context())
+
+ switch ai.Provider(req.Provider) {
+ case ai.ProviderAnthropic, ai.ProviderOpenAI, ai.ProviderGoogle, ai.ProviderCompatible, "":
+ default:
+ writeFieldErrors(w, map[string]string{
+ "provider": "must be anthropic, openai, google or compatible",
+ })
+ return
+ }
+
+ existing, err := s.aiConfig(r, user.ID)
+ if err != nil {
+ s.internal(w, "ai settings", err)
+ return
+ }
+ // An empty key means "leave it alone", not "delete it" — otherwise saving a
+ // changed model would silently clear the key.
+ key := existing.APIKey
+ if req.APIKey != "" {
+ key = req.APIKey
+ }
+
+ settings := map[string]any{
+ "provider": req.Provider, "base_url": req.BaseURL,
+ "model": req.Model, "api_key": key,
+ }
+ if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil {
+ s.internal(w, "save ai settings", err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (s *Server) aiConfig(r *http.Request, userID string) (ai.Config, error) {
+ values, err := s.db.UserSettings(r.Context(), userID, "ai")
+ if err != nil {
+ return ai.Config{}, err
+ }
+ str := func(key string) string {
+ v, _ := values[key].(string)
+ return v
+ }
+ return ai.Config{
+ Provider: ai.Provider(str("provider")),
+ APIKey: str("api_key"),
+ BaseURL: str("base_url"),
+ Model: str("model"),
+ }, nil
+}
+
+// handleAISplit breaks a task into sub-tasks and creates them.
+func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
+ user := userFrom(r.Context())
+ taskID := chi.URLParam(r, "taskID")
+
+ role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
+ if err != nil || !role.CanEdit() {
+ writeError(w, http.StatusNotFound, CodeNotFound, "not found")
+ return
+ }
+ cfg, err := s.aiConfig(r, user.ID)
+ if err != nil {
+ s.internal(w, "ai settings", err)
+ return
+ }
+ if !cfg.Configured() {
+ // Not an error the user did anything to cause: the feature is simply off.
+ writeError(w, http.StatusConflict, CodeConflict,
+ "der er ikke sat en AI-udbyder op under indstillinger")
+ return
+ }
+
+ task, err := s.db.GetTask(r.Context(), taskID, user.ID)
+ if err != nil {
+ s.storeError(w, "get task", err)
+ return
+ }
+
+ ctx, cancel := contextWithTimeout(r, 90*time.Second)
+ defer cancel()
+
+ subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
+ if err != nil {
+ s.log.Warn("ai split", "err", err)
+ writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
+ return
+ }
+
+ created := make([]taskJSON, 0, len(subtasks))
+ for _, content := range subtasks {
+ child := &store.Task{
+ ProjectID: task.ProjectID, ParentID: task.ID, Content: content,
+ Priority: task.Priority, CreatedBy: user.ID,
+ }
+ if err := s.db.CreateTask(r.Context(), child, nil); err != nil {
+ s.internal(w, "create subtask", err)
+ return
+ }
+ created = append(created, toTaskJSON(*child))
+ }
+
+ s.activity(r, task.ProjectID, task.ID, "task.split", map[string]any{"count": len(created)})
+ writeJSON(w, http.StatusCreated, map[string]any{"subtasks": created})
+}
+
+// handleAISummary writes a short prioritisation note over what is outstanding.
+func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
+ user := userFrom(r.Context())
+
+ cfg, err := s.aiConfig(r, user.ID)
+ if err != nil {
+ s.internal(w, "ai settings", err)
+ return
+ }
+ if !cfg.Configured() {
+ writeError(w, http.StatusConflict, CodeConflict,
+ "der er ikke sat en AI-udbyder op under indstillinger")
+ return
+ }
+
+ tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
+ if err != nil {
+ s.internal(w, "list tasks", err)
+ return
+ }
+ if len(tasks) == 0 {
+ writeJSON(w, http.StatusOK, map[string]string{"summary": "Der er ingen åbne opgaver."})
+ return
+ }
+
+ lines := make([]string, 0, len(tasks))
+ for _, t := range tasks {
+ line := "- " + t.Content
+ if t.DueDate != "" {
+ line += " (forfalder " + t.DueDate + ")"
+ }
+ if t.Priority < 4 {
+ line += " [P" + string(rune('0'+t.Priority)) + "]"
+ }
+ lines = append(lines, line)
+ }
+
+ ctx, cancel := contextWithTimeout(r, 90*time.Second)
+ defer cancel()
+
+ summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
+ if err != nil {
+ s.log.Warn("ai summary", "err", err)
+ writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
+}
+
+// --- Gmail ----------------------------------------------------------------------------
+
+type gmailSettings struct {
+ Connected bool `json:"connected"`
+ Email string `json:"email,omitempty"`
+ // Trigger is what causes a task to be created: a starred message, a label, or
+ // both. One-way by design — unstarring does nothing to the task.
+ Trigger string `json:"trigger"`
+ Label string `json:"label,omitempty"`
+}
+
+func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
+ values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
+ if err != nil {
+ s.internal(w, "gmail settings", err)
+ return
+ }
+ str := func(key string) string {
+ v, _ := values[key].(string)
+ return v
+ }
+ writeJSON(w, http.StatusOK, gmailSettings{
+ Connected: str("refresh_token") != "",
+ Email: str("email"),
+ Trigger: str("trigger"),
+ Label: str("label"),
+ })
+}
+
+func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
+ var req gmailSettings
+ if err := decodeJSON(w, r, &req); err != nil {
+ return
+ }
+ user := userFrom(r.Context())
+
+ values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
+ if err != nil {
+ s.internal(w, "gmail settings", err)
+ return
+ }
+ values["trigger"] = req.Trigger
+ values["label"] = req.Label
+
+ if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil {
+ s.internal(w, "save gmail settings", err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// handleDisconnectGmail forgets the connection, tokens and all.
+func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
+ if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil {
+ s.internal(w, "disconnect gmail", err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+var _ = json.Marshal
diff --git a/internal/httpapi/integrations_test.go b/internal/httpapi/integrations_test.go
new file mode 100644
index 0000000..c9fc5cc
--- /dev/null
+++ b/internal/httpapi/integrations_test.go
@@ -0,0 +1,584 @@
+package httpapi
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+// --- MCP -----------------------------------------------------------------------------
+
+// rpc sends one JSON-RPC message to the MCP endpoint.
+func (ts *testServer) rpc(t *testing.T, method string, params any) map[string]any {
+ t.Helper()
+
+ body := map[string]any{"jsonrpc": "2.0", "id": 1, "method": method}
+ if params != nil {
+ body["params"] = params
+ }
+ _, decoded := ts.do(t, "POST", "/api/v1/mcp", body)
+ return decoded
+}
+
+func TestMCPHandshake(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ init := ts.rpc(t, "initialize", map[string]any{
+ "protocolVersion": "2024-11-05",
+ "clientInfo": map[string]any{"name": "claude", "version": "1"},
+ })
+ result, _ := init["result"].(map[string]any)
+ if result == nil {
+ t.Fatalf("initialize returned no result: %v", init)
+ }
+ if result["protocolVersion"] == nil {
+ t.Error("no protocol version was announced")
+ }
+ caps, _ := result["capabilities"].(map[string]any)
+ if _, ok := caps["tools"]; !ok {
+ t.Errorf("tools were not announced as a capability: %v", caps)
+ }
+
+ listed := ts.rpc(t, "tools/list", nil)
+ result, _ = listed["result"].(map[string]any)
+ tools, _ := result["tools"].([]any)
+ if len(tools) == 0 {
+ t.Fatal("no tools were listed")
+ }
+
+ // The tools the brief names have to be there, each with a schema a model can
+ // actually call from.
+ names := map[string]bool{}
+ for _, raw := range tools {
+ tool := raw.(map[string]any)
+ name := tool["name"].(string)
+ names[name] = true
+
+ if tool["description"] == "" {
+ t.Errorf("tool %q has no description", name)
+ }
+ if _, ok := tool["inputSchema"].(map[string]any); !ok {
+ t.Errorf("tool %q has no input schema", name)
+ }
+ }
+ for _, want := range []string{
+ "search_tasks", "create_task", "update_task", "complete_task",
+ "list_projects", "add_comment",
+ } {
+ if !names[want] {
+ t.Errorf("the %q tool is missing", want)
+ }
+ }
+}
+
+// A notification has no id, and the spec says to answer with nothing at all.
+func TestMCPNotificationGetsNoResponse(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ resp, _ := ts.do(t, "POST", "/api/v1/mcp", map[string]any{
+ "jsonrpc": "2.0", "method": "notifications/initialized",
+ })
+ if resp.StatusCode != http.StatusAccepted {
+ t.Errorf("a notification got status %d, want 202 with no body", resp.StatusCode)
+ }
+}
+
+func TestMCPToolsDoTheWork(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ // create_task, through the natural-language path a model is told to prefer.
+ created := ts.callTool(t, "create_task", map[string]any{
+ "text": "betal moms i morgen kl 10 p1",
+ })
+ if created["id"] == nil {
+ t.Fatalf("create_task returned no id: %v", created)
+ }
+ if created["priority"] != float64(1) {
+ t.Errorf("priority = %v, want 1 — the text was not parsed", created["priority"])
+ }
+ taskID := created["id"].(string)
+
+ // search_tasks, with the filter language.
+ found := ts.callTool(t, "search_tasks", map[string]any{"query": "p1"})
+ tasks, _ := found["tasks"].([]any)
+ if len(tasks) != 1 {
+ t.Fatalf("search_tasks found %d tasks, want 1", len(tasks))
+ }
+
+ // list_projects
+ projects := ts.callTool(t, "list_projects", map[string]any{})
+ if got, _ := projects["projects"].([]any); len(got) == 0 {
+ t.Error("list_projects returned nothing")
+ }
+
+ // update_task
+ updated := ts.callTool(t, "update_task", map[string]any{
+ "task_id": taskID, "priority": 3,
+ })
+ if updated["priority"] != float64(3) {
+ t.Errorf("priority = %v after update, want 3", updated["priority"])
+ }
+
+ // add_comment
+ comment := ts.callTool(t, "add_comment", map[string]any{
+ "task_id": taskID, "body": "sagt af Claude",
+ })
+ if comment["id"] == nil {
+ t.Errorf("add_comment returned no id: %v", comment)
+ }
+
+ // complete_task
+ done := ts.callTool(t, "complete_task", map[string]any{"task_id": taskID})
+ if done["completed"] != true {
+ t.Errorf("complete_task did not complete: %v", done)
+ }
+}
+
+// A tool called wrongly must produce a readable tool error, not a protocol error —
+// a model can recover from the first and can do nothing with the second.
+func TestMCPToolErrorsAreReadable(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ response := ts.rpc(t, "tools/call", map[string]any{
+ "name": "complete_task",
+ "arguments": map[string]any{"task_id": "findes-ikke"},
+ })
+
+ if response["error"] != nil {
+ t.Errorf("a failed tool call produced a JSON-RPC error rather than a tool result: %v", response["error"])
+ }
+ result, _ := response["result"].(map[string]any)
+ if result["isError"] != true {
+ t.Fatalf("the result is not marked as an error: %v", result)
+ }
+ content, _ := result["content"].([]any)
+ if len(content) == 0 {
+ t.Fatal("the error has no content for the model to read")
+ }
+ text, _ := content[0].(map[string]any)["text"].(string)
+ if !strings.Contains(text, "findes-ikke") {
+ t.Errorf("the message does not say what was wrong: %q", text)
+ }
+}
+
+// An MCP connector reaches exactly what its owner reaches, and nothing else.
+func TestMCPCannotReachAnotherUsersTasks(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+ other := ts.newUser(t, "anden@example.dk", "Anden")
+
+ ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min private opgave"})
+
+ found := other.callTool(t, "search_tasks", map[string]any{"text": "private"})
+ if tasks, _ := found["tasks"].([]any); len(tasks) != 0 {
+ t.Errorf("another user's MCP session found %d of the owner's tasks", len(tasks))
+ }
+}
+
+// callTool runs one tool and returns its decoded result.
+func (ts *testServer) callTool(t *testing.T, name string, args map[string]any) map[string]any {
+ t.Helper()
+
+ response := ts.rpc(t, "tools/call", map[string]any{"name": name, "arguments": args})
+ if response["error"] != nil {
+ t.Fatalf("%s: protocol error %v", name, response["error"])
+ }
+ result, _ := response["result"].(map[string]any)
+ if result == nil {
+ t.Fatalf("%s: no result", name)
+ }
+ if result["isError"] == true {
+ content, _ := result["content"].([]any)
+ text := ""
+ if len(content) > 0 {
+ text, _ = content[0].(map[string]any)["text"].(string)
+ }
+ t.Fatalf("%s failed: %s", name, text)
+ }
+
+ content, _ := result["content"].([]any)
+ if len(content) == 0 {
+ t.Fatalf("%s: no content", name)
+ }
+ text, _ := content[0].(map[string]any)["text"].(string)
+
+ var decoded map[string]any
+ if err := json.Unmarshal([]byte(text), &decoded); err != nil {
+ t.Fatalf("%s: the result is not JSON: %v\n%s", name, err, text)
+ }
+ return decoded
+}
+
+// --- CalDAV ---------------------------------------------------------------------------
+
+// dav sends a WebDAV request with Basic auth, as a real client would.
+func (ts *testServer) dav(t *testing.T, method, path, token, email, body string) *http.Response {
+ t.Helper()
+
+ req, err := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.SetBasicAuth(email, token)
+ if body != "" {
+ req.Header.Set("Content-Type", "application/xml")
+ }
+ resp, err := (&http.Client{}).Do(req)
+ if err != nil {
+ t.Fatalf("%s %s: %v", method, path, err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+// apiToken mints a personal token, which is what CalDAV clients authenticate with.
+func (ts *testServer) apiToken(t *testing.T, userID string) string {
+ t.Helper()
+ token, _, err := ts.db.CreateAPIToken(t.Context(), userID, "caldav", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return token
+}
+
+func TestCalDAVRequiresCredentials(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ resp, err := (&http.Client{}).Get(ts.URL + "/caldav/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("status %d, want 401", resp.StatusCode)
+ }
+ // Without a challenge, a client has nothing to prompt with.
+ if resp.Header.Get("WWW-Authenticate") == "" {
+ t.Error("no WWW-Authenticate header was sent")
+ }
+}
+
+// The account password must not work here — a client stores what it is given, and
+// what it is given should not be able to sign in to the app.
+func TestCalDAVRejectsTheAccountPassword(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ resp := ts.dav(t, "PROPFIND", "/caldav/", "syv heste over marken", "kristian@example.dk", "")
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("the account password was accepted: status %d", resp.StatusCode)
+ }
+}
+
+func TestCalDAVDiscovery(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
+ if err != nil {
+ t.Fatal(err)
+ }
+ token := ts.apiToken(t, user.ID)
+
+ // OPTIONS is what a client reads first. Without calendar-access in the DAV
+ // header, Apple Reminders decides this is plain WebDAV and stops.
+ options := ts.dav(t, "OPTIONS", "/caldav/", token, user.Email, "")
+ if dav := options.Header.Get("DAV"); !strings.Contains(dav, "calendar-access") {
+ t.Errorf("DAV header = %q, want calendar-access in it", dav)
+ }
+
+ principal := ts.dav(t, "PROPFIND", "/caldav/", token, user.Email, "")
+ if principal.StatusCode != http.StatusMultiStatus {
+ t.Fatalf("PROPFIND on the root: status %d, want 207", principal.StatusCode)
+ }
+ body, _ := io.ReadAll(principal.Body)
+ if !strings.Contains(string(body), "calendar-home-set") {
+ t.Errorf("the principal response does not point at a calendar home:\n%s", body)
+ }
+
+ // The home lists one collection per project.
+ home := ts.dav(t, "PROPFIND", "/caldav/"+user.ID+"/", token, user.Email, "")
+ if home.StatusCode != http.StatusMultiStatus {
+ t.Fatalf("PROPFIND on the home: status %d", home.StatusCode)
+ }
+ homeBody, _ := io.ReadAll(home.Body)
+ if !strings.Contains(string(homeBody), "") {
+ t.Errorf("the collections do not declare VTODO support:\n%s", homeBody)
+ }
+ if !strings.Contains(string(homeBody), "getctag") {
+ t.Error("no ctag was returned; clients would re-sync everything every time")
+ }
+}
+
+// The half that makes CalDAV two-way: a client creating and completing a task.
+func TestCalDAVReadWrite(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
+ if err != nil {
+ t.Fatal(err)
+ }
+ token := ts.apiToken(t, user.ID)
+ inbox, err := ts.db.InboxID(t.Context(), user.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // A client PUTs a VTODO it made up an id for.
+ vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\n" +
+ "UID:fra-reminders\r\nSUMMARY:Købt af Apple Reminders\r\n" +
+ "DUE;VALUE=DATE:20260315\r\nPRIORITY:1\r\nSTATUS:NEEDS-ACTION\r\n" +
+ "END:VTODO\r\nEND:VCALENDAR\r\n"
+
+ put := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+inbox+"/fra-reminders.ics",
+ token, user.Email, vtodo)
+ if put.StatusCode != http.StatusCreated {
+ body, _ := io.ReadAll(put.Body)
+ t.Fatalf("PUT: status %d, %s", put.StatusCode, body)
+ }
+
+ // It is a real task now.
+ task, err := ts.db.GetTask(t.Context(), "fra-reminders", user.ID)
+ if err != nil {
+ t.Fatalf("the task was not created: %v", err)
+ }
+ if task.Content != "Købt af Apple Reminders" {
+ t.Errorf("content = %q", task.Content)
+ }
+ if task.DueDate != "2026-03-15" {
+ t.Errorf("due_date = %q", task.DueDate)
+ }
+ if task.Priority != 1 {
+ t.Errorf("priority = %d, want 1 (iCalendar's 1)", task.Priority)
+ }
+
+ // And it reads back as a VTODO.
+ get := ts.dav(t, "GET", "/caldav/"+user.ID+"/"+inbox+"/fra-reminders.ics", token, user.Email, "")
+ if get.StatusCode != http.StatusOK {
+ t.Fatalf("GET: status %d", get.StatusCode)
+ }
+ body, _ := io.ReadAll(get.Body)
+ if !strings.Contains(string(body), "BEGIN:VTODO") {
+ t.Errorf("the response is not a VTODO:\n%s", body)
+ }
+ if !strings.Contains(string(body), "SUMMARY:Købt af Apple Reminders") {
+ t.Errorf("the summary is missing:\n%s", body)
+ }
+
+ // Ticking it off in the client arrives as a PUT with STATUS:COMPLETED.
+ completed := strings.Replace(vtodo, "STATUS:NEEDS-ACTION", "STATUS:COMPLETED", 1)
+ done := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+inbox+"/fra-reminders.ics",
+ token, user.Email, completed)
+ if done.StatusCode != http.StatusNoContent {
+ t.Fatalf("PUT to complete: status %d", done.StatusCode)
+ }
+
+ after, err := ts.db.GetTask(t.Context(), "fra-reminders", user.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !after.Completed() {
+ t.Error("completing the task in a CalDAV client did not complete it here")
+ }
+
+ // A task completed in the app must stay visible to the client, or the client
+ // treats it as deleted and drops its own record of having done it.
+ report := ts.dav(t, "REPORT", "/caldav/"+user.ID+"/"+inbox+"/", token, user.Email,
+ ``+
+ ``)
+ if report.StatusCode != http.StatusMultiStatus {
+ t.Fatalf("REPORT: status %d", report.StatusCode)
+ }
+ reportBody, _ := io.ReadAll(report.Body)
+ if !strings.Contains(string(reportBody), "fra-reminders") {
+ t.Errorf("a completed task vanished from the collection:\n%s", reportBody)
+ }
+}
+
+// --- mail to task -------------------------------------------------------------------------
+
+func TestMailToTask(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ _, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
+ projectID := project["id"].(string)
+
+ resp, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("mail address: %d %v", resp.StatusCode, body)
+ }
+ address, _ := body["address"].(string)
+ if !strings.HasPrefix(address, "todo+") {
+ t.Fatalf("address = %q, want todo+@domain", address)
+ }
+
+ // Delivered mail. The subject goes through the quick-add parser.
+ resp, created := ts.do(t, "POST", "/inbound/mail", map[string]any{
+ "to": address,
+ "from": "revisor@example.dk",
+ "subject": "Send årsregnskab p1 #Firma",
+ "body": "Vedhæftet er sidste års tal.",
+ })
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("inbound mail: %d %v", resp.StatusCode, created)
+ }
+
+ _, list := ts.do(t, "GET", "/api/v1/tasks?project_id="+projectID, nil)
+ tasks, _ := list["tasks"].([]any)
+ if len(tasks) != 1 {
+ t.Fatalf("got %d tasks in #Firma, want the one from the mail", len(tasks))
+ }
+ task := tasks[0].(map[string]any)
+ if task["content"] != "Send årsregnskab" {
+ t.Errorf("content = %v", task["content"])
+ }
+ if task["priority"] != float64(1) {
+ t.Errorf("priority = %v, want 1", task["priority"])
+ }
+ if task["description"] != "Vedhæftet er sidste års tal." {
+ t.Errorf("the body did not become the description: %v", task["description"])
+ }
+}
+
+// An invented address must not create anything, and must not say whether the token
+// merely does not exist — that would be an oracle for which addresses are live.
+func TestInboundMailToAnUnknownAddress(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ resp, _ := ts.do(t, "POST", "/inbound/mail", map[string]any{
+ "to": "todo+opfundet@example.dk", "subject": "hej", "body": "",
+ })
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("status %d, want 404", resp.StatusCode)
+ }
+
+ malformed, _ := ts.do(t, "POST", "/inbound/mail", map[string]any{
+ "to": "ikke-en-adresse", "subject": "hej", "body": "",
+ })
+ if malformed.StatusCode != resp.StatusCode {
+ t.Errorf("a malformed address answers %d and an unknown one %d; they must match",
+ malformed.StatusCode, resp.StatusCode)
+ }
+}
+
+func TestRotatingTheMailAddress(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ _, first := ts.do(t, "GET", "/api/v1/mail-address", nil)
+ old := first["address"].(string)
+
+ _, second := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
+ fresh := second["address"].(string)
+
+ if old == fresh {
+ t.Fatal("rotating produced the same address")
+ }
+ resp, _ := ts.do(t, "POST", "/inbound/mail", map[string]any{
+ "to": old, "subject": "til den gamle adresse", "body": "",
+ })
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("the old address still works: status %d", resp.StatusCode)
+ }
+}
+
+// --- AI ---------------------------------------------------------------------------------
+
+// With no provider configured the AI features are off, not broken. That is the
+// promise: the app works without them.
+func TestAIDegradesWhenNotConfigured(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ resp, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("ai settings: %d %v", resp.StatusCode, body)
+ }
+ if body["has_key"] != false {
+ t.Errorf("has_key = %v on a fresh instance", body["has_key"])
+ }
+
+ _, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "flyt"})
+ resp, _ = ts.do(t, "POST", "/api/v1/ai/tasks/"+task["id"].(string)+"/split", nil)
+ if resp.StatusCode != http.StatusConflict {
+ t.Errorf("split with no provider: status %d, want 409", resp.StatusCode)
+ }
+
+ resp, _ = ts.do(t, "POST", "/api/v1/ai/summary", nil)
+ if resp.StatusCode != http.StatusConflict {
+ t.Errorf("summary with no provider: status %d, want 409", resp.StatusCode)
+ }
+}
+
+// The API key is stored but never handed back — a settings page that repopulates a
+// password field is one that will eventually leak it into a screenshot.
+func TestAIKeyIsNeverReturned(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ resp, _ := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
+ "provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-hemmelig-nøgle",
+ })
+ if resp.StatusCode != http.StatusNoContent {
+ t.Fatalf("save settings: status %d", resp.StatusCode)
+ }
+
+ _, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
+ if body["has_key"] != true {
+ t.Error("has_key is false after saving a key")
+ }
+ if body["api_key"] != nil && body["api_key"] != "" {
+ t.Errorf("the key was returned: %v", body["api_key"])
+ }
+ if body["model"] != "claude-sonnet-5" {
+ t.Errorf("model = %v", body["model"])
+ }
+
+ // Saving again without a key must keep the stored one, or changing the model
+ // would silently disconnect the provider.
+ ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
+ "provider": "anthropic", "model": "claude-opus-5",
+ })
+ _, after := ts.do(t, "GET", "/api/v1/ai/settings", nil)
+ if after["has_key"] != true {
+ t.Error("the stored key was cleared by a save that did not include one")
+ }
+ if after["model"] != "claude-opus-5" {
+ t.Errorf("model = %v after update", after["model"])
+ }
+}
+
+func TestGmailSettings(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ resp, body := ts.do(t, "GET", "/api/v1/gmail", nil)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("gmail settings: %d %v", resp.StatusCode, body)
+ }
+ if body["connected"] != false {
+ t.Error("a fresh instance reports Gmail as connected")
+ }
+
+ resp, _ = ts.do(t, "PUT", "/api/v1/gmail", map[string]any{
+ "trigger": "starred", "label": "Til handling",
+ })
+ if resp.StatusCode != http.StatusNoContent {
+ t.Fatalf("save: status %d", resp.StatusCode)
+ }
+ _, after := ts.do(t, "GET", "/api/v1/gmail", nil)
+ if after["trigger"] != "starred" || after["label"] != "Til handling" {
+ t.Errorf("settings did not persist: %v", after)
+ }
+}
diff --git a/internal/httpapi/mcp_handlers.go b/internal/httpapi/mcp_handlers.go
new file mode 100644
index 0000000..88449cb
--- /dev/null
+++ b/internal/httpapi/mcp_handlers.go
@@ -0,0 +1,413 @@
+package httpapi
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/kristianwind/verdande/internal/mcp"
+ "github.com/kristianwind/verdande/internal/quickadd"
+ "github.com/kristianwind/verdande/internal/store"
+)
+
+// buildMCP registers the tools verdande exposes to Claude.
+//
+// The descriptions are written for a model to read. That means saying *when* to
+// reach for a tool, not only what it does — a model choosing between
+// `search_tasks` and `list_projects` is answering "which of these gets me closer",
+// and a description that only names the endpoint gives it nothing to decide with.
+func (s *Server) buildMCP() *mcp.Server {
+ m := mcp.NewServer("verdande", "1")
+
+ m.Register(mcp.Tool{
+ Name: "list_projects",
+ Description: "List the user's projects, including which are shared and how many people " +
+ "can see them. Use this first when the user names a project, to turn that name into an id.",
+ InputSchema: mcp.Schema(map[string]any{
+ "include_archived": mcp.Bool("Include archived projects. Defaults to false."),
+ }),
+ }, s.mcpListProjects)
+
+ m.Register(mcp.Tool{
+ Name: "search_tasks",
+ Description: "Find tasks by text, project, label, priority, due date or assignee. " +
+ "This is the tool for any question about what the user has to do — " +
+ "\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
+ "The query accepts the same filter language as the app: \"today & p1\", " +
+ "\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
+ InputSchema: mcp.Schema(map[string]any{
+ "query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
+ "text": mcp.Str("Free text to search task titles and descriptions for."),
+ "limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
+ "include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
+ }),
+ }, s.mcpSearchTasks)
+
+ m.Register(mcp.Tool{
+ Name: "create_task",
+ Description: "Create a task. Prefer passing the whole thing as natural language in " +
+ "`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
+ "parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
+ "and English. Use the explicit fields only when the user was explicit.",
+ InputSchema: mcp.Schema(map[string]any{
+ "text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
+ "content": mcp.Str("The task title, if not using `text`."),
+ "project_id": mcp.Str("Which project. Defaults to the Inbox."),
+ "due_date": mcp.Str("A date as YYYY-MM-DD."),
+ "priority": mcp.Int("1 (highest) to 4 (none)."),
+ "description": mcp.Str("Longer notes on the task."),
+ "labels": mcp.StrArray("Label names."),
+ }),
+ }, s.mcpCreateTask)
+
+ m.Register(mcp.Tool{
+ Name: "update_task",
+ Description: "Change an existing task: its title, description, priority, due date or " +
+ "project. Only the fields given are changed. To move a due date, pass due_date; " +
+ "to clear it, pass an empty string.",
+ InputSchema: mcp.Schema(map[string]any{
+ "task_id": mcp.Str("The task to change."),
+ "content": mcp.Str("A new title."),
+ "description": mcp.Str("New notes."),
+ "priority": mcp.Int("1 (highest) to 4 (none)."),
+ "due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
+ "project_id": mcp.Str("Move the task to another project."),
+ "labels": mcp.StrArray("Replace the task's labels."),
+ }, "task_id"),
+ }, s.mcpUpdateTask)
+
+ m.Register(mcp.Tool{
+ Name: "complete_task",
+ Description: "Mark a task done. A repeating task advances to its next occurrence " +
+ "instead of closing, and the result says so.",
+ InputSchema: mcp.Schema(map[string]any{
+ "task_id": mcp.Str("The task to complete."),
+ }, "task_id"),
+ }, s.mcpCompleteTask)
+
+ m.Register(mcp.Tool{
+ Name: "add_comment",
+ Description: "Add a comment to a task. Everyone else with access to the project is notified.",
+ InputSchema: mcp.Schema(map[string]any{
+ "task_id": mcp.Str("The task to comment on."),
+ "body": mcp.Str("What to say."),
+ }, "task_id", "body"),
+ }, s.mcpAddComment)
+
+ return m
+}
+
+// handleMCP is the Streamable HTTP transport: one POST carries one JSON-RPC
+// message and the response comes back in the body.
+//
+// Authentication is the ordinary API-token path, so a connector reaches exactly
+// what its owner reaches. That is why there is no separate scope model here —
+// a token is a person, and a person's permissions are already decided.
+func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
+ user := userFrom(r.Context())
+
+ body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, CodeBadRequest, "could not read the request")
+ return
+ }
+
+ response := s.mcp.Handle(r.Context(), user.ID, body)
+ if response == nil {
+ // A notification. The spec says answer with nothing.
+ w.WriteHeader(http.StatusAccepted)
+ return
+ }
+ writeJSON(w, http.StatusOK, response)
+}
+
+// --- tool implementations ------------------------------------------------------------
+
+func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
+ var params struct {
+ IncludeArchived bool `json:"include_archived"`
+ }
+ _ = json.Unmarshal(args, ¶ms)
+
+ projects, err := s.db.ListProjects(ctx, userID, params.IncludeArchived)
+ if err != nil {
+ return nil, err
+ }
+
+ out := make([]map[string]any, 0, len(projects))
+ for _, p := range projects {
+ out = append(out, map[string]any{
+ "id": p.ID, "name": p.Name, "is_inbox": p.IsInbox,
+ "shared": p.MemberCount > 1, "role": string(p.Role), "view": p.ViewMode,
+ })
+ }
+ return map[string]any{"projects": out}, nil
+}
+
+func (s *Server) mcpSearchTasks(ctx context.Context, userID string, args json.RawMessage) (any, error) {
+ var params struct {
+ Query string `json:"query"`
+ Text string `json:"text"`
+ Limit int `json:"limit"`
+ IncludeCompleted bool `json:"include_completed"`
+ }
+ _ = json.Unmarshal(args, ¶ms)
+
+ user, err := s.db.UserByID(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+
+ filter := store.TaskFilter{
+ Search: params.Text,
+ IncludeCompleted: params.IncludeCompleted,
+ Limit: clamp(params.Limit, 50, 200),
+ }
+ if params.Query != "" {
+ compiled, err := parseFilter(params.Query, user)
+ if err != nil {
+ return nil, mcp.ArgError("that filter could not be read: %v. "+
+ "Valid examples: \"today\", \"overdue\", \"today & p1\", \"#Firma & @regnskab\", \"7 days\".", err)
+ }
+ filter.FilterSQL, filter.FilterArgs = compiled.SQL, compiled.Args
+ }
+
+ tasks, err := s.db.ListTasks(ctx, userID, filter)
+ if err != nil {
+ return nil, err
+ }
+
+ projects, _ := s.db.ListProjects(ctx, userID, true)
+ names := map[string]string{}
+ for _, p := range projects {
+ names[p.ID] = p.Name
+ }
+
+ out := make([]map[string]any, 0, len(tasks))
+ for _, t := range tasks {
+ entry := map[string]any{
+ "id": t.ID, "content": t.Content, "priority": t.Priority,
+ "project": names[t.ProjectID], "project_id": t.ProjectID,
+ "completed": t.Completed(),
+ }
+ if t.DueDate != "" {
+ entry["due_date"] = t.DueDate
+ }
+ if t.Description != "" {
+ entry["description"] = t.Description
+ }
+ if len(t.Labels) > 0 {
+ entry["labels"] = t.Labels
+ }
+ if t.RecurrenceRule != "" {
+ entry["repeats"] = t.RecurrenceRule
+ }
+ out = append(out, entry)
+ }
+ return map[string]any{"tasks": out, "count": len(out)}, nil
+}
+
+func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
+ var params struct {
+ Text string `json:"text"`
+ Content string `json:"content"`
+ ProjectID string `json:"project_id"`
+ DueDate string `json:"due_date"`
+ Priority int `json:"priority"`
+ Description string `json:"description"`
+ Labels []string `json:"labels"`
+ }
+ _ = json.Unmarshal(args, ¶ms)
+
+ user, err := s.db.UserByID(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+
+ task := &store.Task{CreatedBy: userID, Priority: 4}
+ labels := params.Labels
+
+ if text := strings.TrimSpace(params.Text); text != "" {
+ parsed := quickadd.Parse(text, time.Now().In(userLocation(user.Timezone)), user.Locale)
+ task.Content = parsed.Content
+ task.Priority = parsed.Priority
+ task.DueDate = parsed.DueDate
+ task.RecurrenceRule = parsed.Recurrence
+ if len(labels) == 0 {
+ labels = parsed.Labels
+ }
+ if parsed.Project != "" && params.ProjectID == "" {
+ if id, err := s.db.ProjectByName(ctx, userID, parsed.Project); err == nil {
+ task.ProjectID = id
+ }
+ }
+ }
+
+ if params.Content != "" {
+ task.Content = params.Content
+ }
+ if strings.TrimSpace(task.Content) == "" {
+ return nil, mcp.ArgError("there is no task text — pass either `text` or `content`")
+ }
+ if params.Description != "" {
+ task.Description = params.Description
+ }
+ if params.Priority >= 1 && params.Priority <= 4 {
+ task.Priority = params.Priority
+ }
+ if params.DueDate != "" {
+ if _, err := time.Parse("2006-01-02", params.DueDate); err != nil {
+ return nil, mcp.ArgError("due_date must look like 2026-03-15, got %q", params.DueDate)
+ }
+ task.DueDate = params.DueDate
+ }
+ if params.ProjectID != "" {
+ task.ProjectID = params.ProjectID
+ }
+ if task.ProjectID == "" {
+ if task.ProjectID, err = s.db.InboxID(ctx, userID); err != nil {
+ return nil, err
+ }
+ }
+
+ if _, err := store.RequireProjectRole(ctx, s.db, task.ProjectID, userID, store.RoleEditor); err != nil {
+ return nil, mcp.ArgError("no project with id %q that you can write to", task.ProjectID)
+ }
+ if err := s.db.CreateTask(ctx, task, labels); err != nil {
+ return nil, err
+ }
+
+ s.hub.Publish(task.ProjectID, "task.created", toTaskJSON(*task))
+ return map[string]any{
+ "id": task.ID, "content": task.Content, "priority": task.Priority,
+ "due_date": task.DueDate, "project_id": task.ProjectID,
+ }, nil
+}
+
+func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
+ var params struct {
+ TaskID string `json:"task_id"`
+ Content *string `json:"content"`
+ Description *string `json:"description"`
+ Priority *int `json:"priority"`
+ DueDate *string `json:"due_date"`
+ ProjectID *string `json:"project_id"`
+ Labels *[]string `json:"labels"`
+ }
+ _ = json.Unmarshal(args, ¶ms)
+
+ if params.TaskID == "" {
+ return nil, mcp.ArgError("task_id is required — find one with search_tasks first")
+ }
+ role, err := store.TaskRole(ctx, s.db, params.TaskID, userID)
+ if err != nil || !role.CanEdit() {
+ return nil, mcp.ArgError("no task with id %q that you can change", params.TaskID)
+ }
+
+ update := store.TaskUpdate{
+ Content: params.Content, Description: params.Description,
+ Priority: params.Priority, SectionID: nil,
+ }
+ if params.Priority != nil && (*params.Priority < 1 || *params.Priority > 4) {
+ return nil, mcp.ArgError("priority must be 1, 2, 3 or 4")
+ }
+ if params.DueDate != nil {
+ update.SetDue = true
+ if *params.DueDate != "" {
+ if _, err := time.Parse("2006-01-02", *params.DueDate); err != nil {
+ return nil, mcp.ArgError("due_date must look like 2026-03-15, or be empty to clear it")
+ }
+ update.DueDate = *params.DueDate
+ }
+ }
+ if params.Labels != nil {
+ update.SetLabels, update.Labels = true, *params.Labels
+ }
+ if params.ProjectID != nil {
+ if _, err := store.RequireProjectRole(ctx, s.db, *params.ProjectID, userID, store.RoleEditor); err != nil {
+ return nil, mcp.ArgError("no project with id %q that you can write to", *params.ProjectID)
+ }
+ }
+
+ if err := s.db.UpdateTask(ctx, params.TaskID, userID, update); err != nil {
+ return nil, err
+ }
+ task, err := s.db.GetTask(ctx, params.TaskID, userID)
+ if err != nil {
+ return nil, err
+ }
+ s.hub.Publish(task.ProjectID, "task.updated", toTaskJSON(*task))
+
+ return map[string]any{
+ "id": task.ID, "content": task.Content, "priority": task.Priority,
+ "due_date": task.DueDate, "labels": task.Labels,
+ }, nil
+}
+
+func (s *Server) mcpCompleteTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
+ var params struct {
+ TaskID string `json:"task_id"`
+ }
+ _ = json.Unmarshal(args, ¶ms)
+
+ if params.TaskID == "" {
+ return nil, mcp.ArgError("task_id is required — find one with search_tasks first")
+ }
+ role, err := store.TaskRole(ctx, s.db, params.TaskID, userID)
+ if err != nil || !role.CanEdit() {
+ return nil, mcp.ArgError("no task with id %q that you can complete", params.TaskID)
+ }
+
+ result, err := s.db.CompleteTask(ctx, params.TaskID, userID)
+ if err != nil {
+ return nil, err
+ }
+ task, err := s.db.GetTask(ctx, params.TaskID, userID)
+ if err != nil {
+ return nil, err
+ }
+ s.hub.Publish(task.ProjectID, "task.completed", toTaskJSON(*task))
+
+ out := map[string]any{"id": task.ID, "content": task.Content, "completed": task.Completed()}
+ if result.Recurred {
+ out["recurred"] = true
+ out["next_due"] = result.NextDue
+ out["note"] = "This task repeats; it has moved to its next occurrence rather than closing."
+ }
+ return out, nil
+}
+
+func (s *Server) mcpAddComment(ctx context.Context, userID string, args json.RawMessage) (any, error) {
+ var params struct {
+ TaskID string `json:"task_id"`
+ Body string `json:"body"`
+ }
+ _ = json.Unmarshal(args, ¶ms)
+
+ if params.TaskID == "" || strings.TrimSpace(params.Body) == "" {
+ return nil, mcp.ArgError("both task_id and body are required")
+ }
+ role, err := store.TaskRole(ctx, s.db, params.TaskID, userID)
+ if err != nil || !role.CanEdit() {
+ return nil, mcp.ArgError("no task with id %q that you can comment on", params.TaskID)
+ }
+
+ comment, err := s.db.CreateComment(ctx, params.TaskID, userID, strings.TrimSpace(params.Body))
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"id": comment.ID, "task_id": comment.TaskID, "body": comment.Body}, nil
+}
+
+func clamp(value, fallback, max int) int {
+ if value <= 0 {
+ return fallback
+ }
+ if value > max {
+ return max
+ }
+ return value
+}
diff --git a/internal/httpapi/middleware.go b/internal/httpapi/middleware.go
index 7d7d320..5668246 100644
--- a/internal/httpapi/middleware.go
+++ b/internal/httpapi/middleware.go
@@ -151,6 +151,12 @@ func bearerToken(r *http.Request) string {
return ""
}
+// withUser puts a user on the context. Used by the CalDAV handlers, which
+// authenticate with Basic rather than through the session middleware.
+func withUser(ctx context.Context, user *store.User) context.Context {
+ return context.WithValue(ctx, ctxUser, user)
+}
+
func userFrom(ctx context.Context) *store.User {
u, _ := ctx.Value(ctxUser).(*store.User)
return u
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index bf28016..a48e04a 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -18,6 +18,7 @@ import (
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/mail"
+ "github.com/kristianwind/verdande/internal/mcp"
"github.com/kristianwind/verdande/internal/realtime"
"github.com/kristianwind/verdande/internal/store"
)
@@ -29,6 +30,7 @@ type Server struct {
web fs.FS
mail *mail.Sender
hub *realtime.Hub
+ mcp *mcp.Server
// Password guessing is the attack this application is actually exposed to, so
// the endpoints that check a secret are limited separately and more tightly
@@ -54,6 +56,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
resetLimiter: newLimiter(5, time.Hour),
}
s.csp = contentSecurityPolicy(scriptHashes(web))
+ s.mcp = s.buildMCP()
r := chi.NewRouter()
r.Use(middleware.RequestID)
@@ -71,6 +74,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// client cannot log in, so the token in the path is the whole credential.
r.Get("/ics/{token}", s.handleICSFeed)
+ // CalDAV, outside /api/v1 because clients do well-known-path discovery
+ // against the root and authenticate with Basic rather than a session.
+ r.Get("/.well-known/caldav", s.wellKnownCalDAV)
+
+ // Inbound mail. Delivered by the mail server rather than by a browser, and
+ // authenticated by the token in the recipient address.
+ r.Post("/inbound/mail", s.handleInboundMail)
+ r.Route("/caldav", s.caldavRoutes)
+
r.Route("/api/v1", func(r chi.Router) {
r.Use(s.requireCSRF)
@@ -119,6 +131,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/ws", s.handleWebSocket)
+ // The MCP endpoint. Reachable with a personal API token, so a connector
+ // sees exactly what its owner sees.
+ r.Post("/mcp", s.handleMCP)
+
r.Get("/today", s.handleToday)
r.Get("/filters/preview", s.handlePreviewFilter)
@@ -155,6 +171,22 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
r.Post("/feed/rotate", s.handleRotateFeed)
+ r.Get("/mail-address", s.handleGetMailAddress)
+ r.Post("/mail-address/rotate", s.handleRotateMailAddress)
+
+ r.Route("/ai", func(r chi.Router) {
+ r.Get("/settings", s.handleGetAISettings)
+ r.Put("/settings", s.handleSetAISettings)
+ r.Post("/summary", s.handleAISummary)
+ r.Post("/tasks/{taskID}/split", s.handleAISplit)
+ })
+
+ r.Route("/gmail", func(r chi.Router) {
+ r.Get("/", s.handleGetGmail)
+ r.Put("/", s.handleSetGmail)
+ r.Delete("/", s.handleDisconnectGmail)
+ })
+
r.Route("/import", func(r chi.Router) {
r.Post("/todoist", s.handleImportTodoist)
r.Post("/csv", s.handleImportCSV)
diff --git a/internal/ics/ics.go b/internal/ics/ics.go
index 9558170..dd8a218 100644
--- a/internal/ics/ics.go
+++ b/internal/ics/ics.go
@@ -13,6 +13,7 @@ package ics
import (
"fmt"
+ "strconv"
"strings"
"time"
)
@@ -221,3 +222,148 @@ func Filename(name string) string {
}
return fmt.Sprintf("%s.ics", safe)
}
+
+// --- reading ----------------------------------------------------------------------
+
+// Parsed is what a client's VTODO says, reduced to the fields verdande stores.
+type Parsed struct {
+ UID string
+ Summary string
+ Description string
+ DueDate string
+ DueDatetime *time.Time
+ Priority int
+ Recurrence string
+ Completed bool
+}
+
+// ParseVTODO reads a calendar object sent by a CalDAV client.
+//
+// Deliberately forgiving: this is the input side of a protocol spoken by Apple
+// Reminders, Thunderbird and a long tail of phone apps, each with its own habits
+// about folding, parameters and which properties it bothers to send. Anything not
+// understood is ignored rather than refused, because refusing a PUT makes a client
+// show a sync error and stop — losing the edit the person just made.
+func ParseVTODO(body string) (Parsed, error) {
+ var p Parsed
+ inTodo := false
+
+ for _, line := range unfold(body) {
+ name, params, value := splitLine(line)
+
+ switch strings.ToUpper(name) {
+ case "BEGIN":
+ if strings.EqualFold(value, "VTODO") {
+ inTodo = true
+ }
+ case "END":
+ if strings.EqualFold(value, "VTODO") {
+ inTodo = false
+ }
+ }
+ if !inTodo {
+ continue
+ }
+
+ switch strings.ToUpper(name) {
+ case "UID":
+ p.UID = value
+ case "SUMMARY":
+ p.Summary = unescape(value)
+ case "DESCRIPTION":
+ p.Description = unescape(value)
+ case "RRULE":
+ p.Recurrence = value
+ case "STATUS":
+ p.Completed = strings.EqualFold(value, "COMPLETED")
+ case "PERCENT-COMPLETE":
+ if value == "100" {
+ p.Completed = true
+ }
+ case "COMPLETED":
+ p.Completed = true
+ case "PRIORITY":
+ // RFC 5545 runs 1..9 with 0 meaning undefined; verdande runs 1..4 with
+ // 4 meaning none. The bands here are the inverse of what the writer does.
+ switch n, _ := strconv.Atoi(value); {
+ case n == 0:
+ p.Priority = 4
+ case n <= 2:
+ p.Priority = 1
+ case n <= 5:
+ p.Priority = 2
+ case n <= 7:
+ p.Priority = 3
+ default:
+ p.Priority = 4
+ }
+ case "DUE":
+ if strings.Contains(strings.ToUpper(params), "VALUE=DATE") || len(value) == 8 {
+ if t, err := time.Parse("20060102", value); err == nil {
+ p.DueDate = t.Format("2006-01-02")
+ }
+ continue
+ }
+ for _, layout := range []string{"20060102T150405Z", "20060102T150405"} {
+ if t, err := time.Parse(layout, value); err == nil {
+ utc := t.UTC()
+ p.DueDate = utc.Format("2006-01-02")
+ p.DueDatetime = &utc
+ break
+ }
+ }
+ }
+ }
+
+ if p.Priority == 0 {
+ p.Priority = 4
+ }
+ if p.Summary == "" && p.UID == "" {
+ return p, fmt.Errorf("ics: no VTODO found")
+ }
+ return p, nil
+}
+
+// unfold rejoins continuation lines, which the spec folds at 75 octets by beginning
+// the next line with a space or tab.
+func unfold(body string) []string {
+ raw := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n")
+
+ var out []string
+ for _, line := range raw {
+ if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') && len(out) > 0 {
+ out[len(out)-1] += line[1:]
+ continue
+ }
+ out = append(out, line)
+ }
+ return out
+}
+
+// splitLine separates NAME;PARAMS:VALUE. The parameters can themselves contain a
+// colon inside quotes, so the split is on the first colon outside quotes.
+func splitLine(line string) (name, params, value string) {
+ inQuotes := false
+ for i := 0; i < len(line); i++ {
+ switch line[i] {
+ case '"':
+ inQuotes = !inQuotes
+ case ':':
+ if inQuotes {
+ continue
+ }
+ left := line[:i]
+ value = line[i+1:]
+ if semi := strings.Index(left, ";"); semi >= 0 {
+ return left[:semi], left[semi+1:], value
+ }
+ return left, "", value
+ }
+ }
+ return line, "", ""
+}
+
+func unescape(s string) string {
+ r := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`)
+ return r.Replace(s)
+}
diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go
new file mode 100644
index 0000000..fbd8370
--- /dev/null
+++ b/internal/mcp/mcp.go
@@ -0,0 +1,229 @@
+// Package mcp implements a Model Context Protocol server over Streamable HTTP, so
+// verdande can be added to Claude as a connector.
+//
+// MCP is JSON-RPC 2.0 with a fixed handshake and a fixed set of methods. It is
+// implemented directly here rather than through an SDK: the surface verdande needs
+// is initialize, tools/list and tools/call, and the whole thing is smaller than the
+// dependency would be.
+//
+// Authentication is a personal API token in the Authorization header, checked by
+// the same middleware as the rest of the API — so a tool call can reach exactly
+// what its owner can reach, and nothing else. There is no separate service identity
+// to reason about, which is the point.
+package mcp
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+)
+
+// The protocol version this server speaks. Claude sends its own in initialize; a
+// mismatch is not fatal, and answering with what we actually implement is what the
+// spec asks for.
+const ProtocolVersion = "2024-11-05"
+
+// --- JSON-RPC ---------------------------------------------------------------------
+
+type Request struct {
+ JSONRPC string `json:"jsonrpc"`
+ ID json.RawMessage `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params json.RawMessage `json:"params,omitempty"`
+}
+
+type Response struct {
+ JSONRPC string `json:"jsonrpc"`
+ ID json.RawMessage `json:"id,omitempty"`
+ Result any `json:"result,omitempty"`
+ Error *RPCError `json:"error,omitempty"`
+}
+
+type RPCError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Data any `json:"data,omitempty"`
+}
+
+// The JSON-RPC error codes, as the spec numbers them.
+const (
+ CodeParseError = -32700
+ CodeInvalidRequest = -32600
+ CodeMethodNotFound = -32601
+ CodeInvalidParams = -32602
+ CodeInternalError = -32603
+)
+
+func result(id json.RawMessage, value any) *Response {
+ return &Response{JSONRPC: "2.0", ID: id, Result: value}
+}
+
+func failure(id json.RawMessage, code int, message string) *Response {
+ return &Response{JSONRPC: "2.0", ID: id, Error: &RPCError{Code: code, Message: message}}
+}
+
+// --- tools -------------------------------------------------------------------------
+
+// Tool is one thing Claude can do. The schema is what it reads to decide how to
+// call it, so the descriptions are written for a model rather than for a developer:
+// they say when to use the tool, not merely what it does.
+type Tool struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ InputSchema map[string]any `json:"inputSchema"`
+}
+
+// Handler executes a tool call for a particular user. Returning an error produces a
+// tool error rather than a protocol error — a model can read and recover from the
+// first, and can do nothing at all with the second.
+type Handler func(ctx context.Context, userID string, args json.RawMessage) (any, error)
+
+type Server struct {
+ tools []Tool
+ handlers map[string]Handler
+ // Name and Version identify this instance in the connector list.
+ Name string
+ Version string
+}
+
+func NewServer(name, version string) *Server {
+ return &Server{Name: name, Version: version, handlers: map[string]Handler{}}
+}
+
+func (s *Server) Register(tool Tool, handler Handler) {
+ s.tools = append(s.tools, tool)
+ s.handlers[tool.Name] = handler
+}
+
+// Handle dispatches one JSON-RPC message.
+//
+// A nil response means the message was a notification — it had no id, so the spec
+// says to answer with nothing at all. Answering anyway is a protocol violation that
+// some clients tolerate and others disconnect over.
+func (s *Server) Handle(ctx context.Context, userID string, raw []byte) *Response {
+ var req Request
+ if err := json.Unmarshal(raw, &req); err != nil {
+ return failure(nil, CodeParseError, "invalid JSON")
+ }
+ if req.JSONRPC != "2.0" {
+ return failure(req.ID, CodeInvalidRequest, "jsonrpc must be \"2.0\"")
+ }
+
+ isNotification := len(req.ID) == 0
+
+ switch req.Method {
+ case "initialize":
+ return result(req.ID, map[string]any{
+ "protocolVersion": ProtocolVersion,
+ "capabilities": map[string]any{
+ // Only tools. verdande has no prompts or resources to offer that
+ // the tools do not already cover, and declaring capabilities that
+ // answer with nothing wastes a round trip on every connection.
+ "tools": map[string]any{},
+ },
+ "serverInfo": map[string]any{"name": s.Name, "version": s.Version},
+ })
+
+ case "notifications/initialized", "initialized":
+ return nil
+
+ case "ping":
+ return result(req.ID, map[string]any{})
+
+ case "tools/list":
+ return result(req.ID, map[string]any{"tools": s.tools})
+
+ case "tools/call":
+ return s.callTool(ctx, userID, req)
+
+ default:
+ if isNotification {
+ return nil
+ }
+ return failure(req.ID, CodeMethodNotFound, "unknown method: "+req.Method)
+ }
+}
+
+type toolCallParams struct {
+ Name string `json:"name"`
+ Arguments json.RawMessage `json:"arguments"`
+}
+
+func (s *Server) callTool(ctx context.Context, userID string, req Request) *Response {
+ var params toolCallParams
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ return failure(req.ID, CodeInvalidParams, "could not read the call parameters")
+ }
+
+ handler, ok := s.handlers[params.Name]
+ if !ok {
+ return failure(req.ID, CodeMethodNotFound, "unknown tool: "+params.Name)
+ }
+
+ value, err := handler(ctx, userID, params.Arguments)
+ if err != nil {
+ // A failed tool call is a *result* with isError set, not a JSON-RPC error.
+ // The distinction matters: this way the model is told what went wrong and
+ // can try something else, rather than the conversation failing.
+ return result(req.ID, map[string]any{
+ "content": []map[string]any{{"type": "text", "text": err.Error()}},
+ "isError": true,
+ })
+ }
+
+ // Tool results are text content. JSON is rendered indented because a model
+ // reads it as text, and one line of dense JSON is markedly harder for it to
+ // pick a field out of than a formatted block.
+ encoded, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ return failure(req.ID, CodeInternalError, "could not encode the result")
+ }
+ return result(req.ID, map[string]any{
+ "content": []map[string]any{{"type": "text", "text": string(encoded)}},
+ })
+}
+
+// --- schema helpers -------------------------------------------------------------------
+
+// Schema builds a JSON Schema object for a tool's arguments.
+func Schema(properties map[string]any, required ...string) map[string]any {
+ if required == nil {
+ required = []string{}
+ }
+ return map[string]any{
+ "type": "object",
+ "properties": properties,
+ "required": required,
+ }
+}
+
+func Str(description string) map[string]any {
+ return map[string]any{"type": "string", "description": description}
+}
+
+func Int(description string) map[string]any {
+ return map[string]any{"type": "integer", "description": description}
+}
+
+func Bool(description string) map[string]any {
+ return map[string]any{"type": "boolean", "description": description}
+}
+
+func Enum(description string, values ...any) map[string]any {
+ return map[string]any{"type": "string", "description": description, "enum": values}
+}
+
+func StrArray(description string) map[string]any {
+ return map[string]any{
+ "type": "array",
+ "description": description,
+ "items": map[string]any{"type": "string"},
+ }
+}
+
+// ArgError is what a handler returns when the model called it wrongly. Phrased as
+// an instruction rather than a complaint, because the reader is a model deciding
+// what to do next.
+func ArgError(format string, args ...any) error {
+ return fmt.Errorf(format, args...)
+}
diff --git a/internal/store/notifications.go b/internal/store/notifications.go
index 6437067..840515b 100644
--- a/internal/store/notifications.go
+++ b/internal/store/notifications.go
@@ -3,7 +3,10 @@ package store
import (
"context"
"database/sql"
+ "encoding/json"
"time"
+
+ "github.com/kristianwind/verdande/internal/auth"
)
type Notification struct {
@@ -230,3 +233,78 @@ func (db *DB) InstanceKeys(ctx context.Context, generate func() (public, private
}
return public, private, nil
}
+
+// --- mail-to-task tokens -------------------------------------------------------------
+
+func (db *DB) EnsureMailToken(ctx context.Context, userID string) (string, error) {
+ var token sql.NullString
+ err := db.QueryRowContext(ctx, `SELECT mail_token FROM users WHERE id = ?`, userID).Scan(&token)
+ if err != nil {
+ return "", err
+ }
+ if token.Valid && token.String != "" {
+ return token.String, nil
+ }
+
+ fresh, err := auth.NewToken()
+ if err != nil {
+ return "", err
+ }
+ if err := db.SetMailToken(ctx, userID, fresh); err != nil {
+ return "", err
+ }
+ return fresh, nil
+}
+
+func (db *DB) SetMailToken(ctx context.Context, userID, token string) error {
+ _, err := db.ExecContext(ctx,
+ `UPDATE users SET mail_token = ?, updated_at = ? WHERE id = ?`,
+ token, time.Now().Unix(), userID)
+ return err
+}
+
+func (db *DB) UserByMailToken(ctx context.Context, token string) (*User, error) {
+ if token == "" {
+ return nil, ErrNotFound
+ }
+ return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE mail_token = ?`, token)
+}
+
+// --- per-user settings ------------------------------------------------------------------
+
+// UserSettings reads one scope of a person's integration settings. A missing row is
+// an empty map, not an error: nobody has settings until they set some.
+func (db *DB) UserSettings(ctx context.Context, userID, scope string) (map[string]any, error) {
+ var raw string
+ err := db.QueryRowContext(ctx,
+ `SELECT values_json FROM user_settings WHERE user_id = ? AND scope = ?`,
+ userID, scope).Scan(&raw)
+ if err == sql.ErrNoRows {
+ return map[string]any{}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ values := map[string]any{}
+ if err := json.Unmarshal([]byte(raw), &values); err != nil {
+ return map[string]any{}, nil
+ }
+ return values, nil
+}
+
+func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values map[string]any) error {
+ raw, err := json.Marshal(values)
+ if err != nil {
+ return err
+ }
+ _, err = db.ExecContext(ctx, `
+ INSERT INTO user_settings (user_id, scope, values_json, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT (user_id) DO UPDATE SET
+ scope = excluded.scope,
+ values_json = excluded.values_json,
+ updated_at = excluded.updated_at`,
+ userID, scope, string(raw), time.Now().Unix())
+ return err
+}