package httpapi import ( "encoding/json" "io" "net/http" "net/url" "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/safedial" "github.com/kristianwind/verdande/internal/store" ) // --- mail to task ------------------------------------------------------------------- type mailAddressResponse struct { Address string `json:"address"` // Configured says whether a mail server has been set up at all. Without one // the address is still minted and still valid — the token is real, and the // endpoint that receives mail works — but nothing on the internet routes to // it yet, and an interface that shows the address without saying so is // promising something that will not happen. Configured bool `json:"configured"` } // 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, r, "mail token", err) return } writeJSON(w, http.StatusOK, mailAddressResponse{ Address: s.mailAddress(token), Configured: s.cfg.SMTP.Host != "", }) } func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) { token, err := auth.NewToken() if err != nil { s.internal(w, r, "generate mail token", err) return } if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil { s.internal(w, r, "set mail token", err) return } writeJSON(w, http.StatusOK, mailAddressResponse{ Address: s.mailAddress(token), Configured: s.cfg.SMTP.Host != "", }) } 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. // // Except when it is the default. VERDANDE_SMTP_FROM falls back to // verdande@localhost, so an instance with no mail server at all was // handing out todo+…@localhost: an address that looks real, cannot // possibly receive anything, and gives no hint which of the two it is. // The public hostname is at least a domain that exists. if d := from[strings.Index(from, "@")+1:]; d != "" && d != "localhost" { domain = d } } 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, r, "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, r, "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}) } // --- en krog ind i indbakken -------------------------------------------------------- type hookURLResponse struct { URL string `json:"url"` } // handleGetHookURL returns the personal address another program can push a task // to, minting the token on first ask. // // Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens // jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have // en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en // mailserver op. Det, der manglede, var en URL, man kan smide en linje tekst efter // — fra en genvej på telefonen, et script, en anden tjeneste. func (s *Server) handleGetHookURL(w http.ResponseWriter, r *http.Request) { token, err := s.db.EnsureHookToken(r.Context(), userFrom(r.Context()).ID) if err != nil { s.internal(w, r, "hook token", err) return } writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)}) } // handleRotateHookURL mints a new one and retires the old. // // Den eneste vej tilbage, når en URL er sluppet ud: den står i en genvej på en // telefon eller i et script på en anden maskine, og der er ikke andet at trække // tilbage end tokenet selv. func (s *Server) handleRotateHookURL(w http.ResponseWriter, r *http.Request) { token, err := auth.NewToken() if err != nil { s.internal(w, r, "generate hook token", err) return } if err := s.db.SetHookToken(r.Context(), userFrom(r.Context()).ID, token); err != nil { s.internal(w, r, "set hook token", err) return } writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)}) } func (s *Server) hookURL(token string) string { return strings.TrimSuffix(s.cfg.BaseURL, "/") + "/inbound/hook/" + token } type hookRequest struct { // Text er linjen, der bliver til opgaven. `title` og `content` tages med som // de samme, fordi det er dem, andre tjenester i forvejen kalder feltet — og en // integration, der fejler på et feltnavn, fejler tavst hos den, der satte den // op. Text string `json:"text"` Title string `json:"title"` Content string `json:"content"` // Note er det, der står under opgaven. `body` og `description` ligeså. Note string `json:"note"` Body string `json:"body"` Description string `json:"description"` } func (h hookRequest) line() string { for _, v := range []string{h.Text, h.Title, h.Content} { if strings.TrimSpace(v) != "" { return v } } return "" } func (h hookRequest) note() string { for _, v := range []string{h.Note, h.Body, h.Description} { if strings.TrimSpace(v) != "" { return v } } return "" } // handleInboundHook turns a POST from somewhere else into a task in the inbox. // // Tokenet i stien er hele legitimationen, som i kalenderfeedet og den indgående // post: den, der kalder, er et script eller en genvej på en telefon, og hverken // kan logge ind. Derfor kan et token herfra også kun én ting — lave en opgave i // den ene persons indbakke — hvor et API-token kan alt. // // Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en // tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en genvej, // der bare sender det, man har markeret. Første linje bliver til opgaven, resten // til noten under den — det er sådan, en tekst, man har delt, ser ud. // // Linjen læses med quick-add-parseren, så "Ring til Anders i morgen p1 #Firma" // betyder det samme her som i feltet øverst i fladen. Det er hele pointen med at // have én parser: den vej, teksten kom ind ad, ændrer ikke, hvad den betyder. func (s *Server) handleInboundHook(w http.ResponseWriter, r *http.Request) { user, err := s.db.UserByHookToken(r.Context(), chi.URLParam(r, "token")) if err != nil { // Samme svar som en adresse, der aldrig har eksisteret: et endpoint, der // kender forskel, er en maskine til at gætte tokens med. writeError(w, http.StatusNotFound, CodeNotFound, "no such address") return } line, note := s.readHookBody(r) if strings.TrimSpace(line) == "" { writeFieldErrors(w, map[string]string{"text": "required"}) return } parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale) content := parsed.Content if strings.TrimSpace(content) == "" { content = line } 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, r, "inbox", err) return } } task := &store.Task{ ProjectID: projectID, Content: content, Priority: parsed.Priority, Description: strings.TrimSpace(note), DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID, } if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil { s.internal(w, r, "create task from hook", err) return } s.publish(projectID, "task.created", toTaskJSON(*task)) s.log.Info("task created from hook", "user", user.ID) writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID, "content": task.Content}) } // readHookBody er de tre måder, en linje tekst kommer ind ad. // // Rækkefølgen er efter, hvor sikkert man ved, hvad man har: en erklæret // Content-Type tros, og ellers falder det tilbage på ren tekst — som er det, en // genvej på en telefon sender, når den ikke er blevet bedt om andet. // // En krop, der siger JSON, men ikke er det, læses ikke som tekst bagefter. Den, // der satte integrationen op, har skrevet noget forkert, og en opgave, der hedder // `{"txt": "køb mælk"}`, er en dårligere måde at finde ud af det på end et 422. func (s *Server) readHookBody(r *http.Request) (line, note string) { const maxBody = 64 << 10 body, err := io.ReadAll(io.LimitReader(r.Body, maxBody)) if err != nil { return "", "" } kind := r.Header.Get("Content-Type") switch { case strings.Contains(kind, "application/json"): var req hookRequest if err := json.Unmarshal(body, &req); err != nil { return "", "" } return req.line(), req.note() case strings.Contains(kind, "application/x-www-form-urlencoded"): values, err := url.ParseQuery(string(body)) if err != nil { return "", "" } req := hookRequest{ Text: values.Get("text"), Title: values.Get("title"), Content: values.Get("content"), Note: values.Get("note"), Body: values.Get("body"), Description: values.Get("description"), } return req.line(), req.note() } // Ren tekst: første linje er opgaven, resten står under den. Sådan ser en tekst, // man har delt fra en anden app, ud — en overskrift og noget under. text := strings.TrimSpace(string(body)) first, rest, _ := strings.Cut(text, "\n") return first, strings.TrimSpace(rest) } // 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, r, "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 } // The address is checked where it is typed, as well as when it is dialled. A // scheme other than http or https is refused outright, for everybody — // file:// and gopher:// are not providers. // // The private-network wall has one exception, and it is the point of the // provider it guards. "OpenAI-compatible (Ollama, LM Studio, …)" means a model // on this machine or on the desk beside it: 127.0.0.1:11434, 192.168.1.150. // safedial refuses exactly those, so the address the field suggested in its // own placeholder was an address the server would not accept. // // Admins only, because the wall is there for a real reason: a server that // fetches a user-supplied address lets the person supplying it reach what // they cannot reach themselves. An admin on a self-hosted instance already // owns the host and everything on its network, so this grants them nothing // new. An invited user is a different person, and for them it stays shut. if url := strings.TrimSpace(req.BaseURL); url != "" { if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"}) return } if !user.IsAdmin { if reason := safedial.CheckURL(url); reason != "" { writeFieldErrors(w, map[string]string{"base_url": reason}) return } } req.BaseURL = url } existing, err := s.aiConfig(r, user.ID) if err != nil { s.internal(w, r, "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, r, "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 } // Worked out per request rather than stored beside the address: it answers // "may this person reach the private network", and somebody who is no longer // an admin should stop being able to. A flag written at save time would // outlive the role that granted it. allowPrivate := false if u := userFrom(r.Context()); u != nil && u.IsAdmin { allowPrivate = true } return ai.Config{ Provider: ai.Provider(str("provider")), APIKey: str("api_key"), BaseURL: str("base_url"), Model: str("model"), AllowPrivate: allowPrivate, }, 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, r, "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, CodeAINotConfigured, "no AI provider is configured") return } task, err := s.db.GetTask(r.Context(), taskID, user.ID) if err != nil { s.storeError(w, r, "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, StatusUpstreamRefused, 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, r, "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, r, "ai settings", err) return } if !cfg.Configured() { writeError(w, http.StatusConflict, CodeAINotConfigured, "no AI provider is configured") return } tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100}) if err != nil { s.internal(w, r, "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, StatusUpstreamRefused, 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) { user := userFrom(r.Context()) box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail") if err != nil { s.internal(w, r, "gmail mailbox", err) return } if box == nil { writeJSON(w, http.StatusOK, gmailSettings{}) return } // The mailbox address, fetched again if it is missing. // // It is stored at connect time, and the call that fetches it can fail — a slow // token, a scope Google had not applied yet. That failure used to be a log line // and an empty string, so the page said "an unknown account" and went on saying // it forever: the only way back was to disconnect and reconnect, which nobody // would guess. Asking again here costs one request on a page nobody opens // often, and it means the wrong answer lasts until the next look. email := box.Username if email == "" && box.RefreshToken != "" { if fetched, err := s.gmailProfile(r.Context(), box); err == nil { email = fetched } else { s.log.Warn("gmail profile", "err", err) } } writeJSON(w, http.StatusOK, gmailSettings{ Connected: box.RefreshToken != "", Email: email, Trigger: box.Trigger, Label: box.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()) box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail") if err != nil { s.internal(w, r, "gmail mailbox", err) return } if box == nil { // Choosing what makes a task before connecting is allowed, and the choice is // kept: the row is the mailbox with no credential in it yet, which reads as // not connected everywhere that asks and is skipped by the sweep. box = &store.Mailbox{UserID: user.ID, Kind: "gmail"} } box.Trigger = req.Trigger box.Label = req.Label if err := s.db.SaveMailbox(r.Context(), box); err != nil { s.internal(w, r, "save gmail settings", err) return } w.WriteHeader(http.StatusNoContent) } // handleDisconnectGmail forgets the connection, tokens and all. The tasks it made // stay: they are the person's work now, not the mailbox's. func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) { user := userFrom(r.Context()) box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail") if err != nil { s.internal(w, r, "gmail mailbox", err) return } if box != nil { if err := s.db.DeleteMailbox(r.Context(), user.ID, box.ID); err != nil { s.internal(w, r, "disconnect gmail", err) return } } w.WriteHeader(http.StatusNoContent) } var _ = json.Marshal