2026-08-17 07:53:53 +02:00
|
|
|
package httpapi
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
2026-09-08 14:03:32 +02:00
|
|
|
"io"
|
2026-08-17 07:53:53 +02:00
|
|
|
"net/http"
|
2026-09-08 14:03:32 +02:00
|
|
|
"net/url"
|
2026-08-17 07:53:53 +02:00
|
|
|
"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"
|
2026-08-20 08:39:52 +02:00
|
|
|
"github.com/kristianwind/verdande/internal/safedial"
|
2026-08-17 07:53:53 +02:00
|
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// --- mail to task -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
type mailAddressResponse struct {
|
|
|
|
|
Address string `json:"address"`
|
2026-08-17 14:32:42 +02:00
|
|
|
// 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"`
|
2026-08-17 07:53:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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+<token>@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 {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "mail token", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-17 14:32:42 +02:00
|
|
|
writeJSON(w, http.StatusOK, mailAddressResponse{
|
|
|
|
|
Address: s.mailAddress(token),
|
|
|
|
|
Configured: s.cfg.SMTP.Host != "",
|
|
|
|
|
})
|
2026-08-17 07:53:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
token, err := auth.NewToken()
|
|
|
|
|
if err != nil {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "generate mail token", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "set mail token", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-17 14:32:42 +02:00
|
|
|
writeJSON(w, http.StatusOK, mailAddressResponse{
|
|
|
|
|
Address: s.mailAddress(token),
|
|
|
|
|
Configured: s.cfg.SMTP.Host != "",
|
|
|
|
|
})
|
2026-08-17 07:53:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.
|
2026-08-17 14:32:42 +02:00
|
|
|
//
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2026-08-17 07:53:53 +02:00
|
|
|
}
|
|
|
|
|
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 {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "inbox", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
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 {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "create task from mail", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
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})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 14:03:32 +02:00
|
|
|
// --- 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 07:53:53 +02:00
|
|
|
// mailToken reads the token out of "Name <todo+TOKEN@domain>".
|
|
|
|
|
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 {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "ai settings", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 00:03:45 +02:00
|
|
|
// 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.
|
2026-08-20 08:39:52 +02:00
|
|
|
//
|
2026-08-22 00:03:45 +02:00
|
|
|
// 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.
|
2026-08-20 08:39:52 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-22 00:03:45 +02:00
|
|
|
if !user.IsAdmin {
|
|
|
|
|
if reason := safedial.CheckURL(url); reason != "" {
|
|
|
|
|
writeFieldErrors(w, map[string]string{"base_url": reason})
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-20 08:39:52 +02:00
|
|
|
}
|
|
|
|
|
req.BaseURL = url
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 07:53:53 +02:00
|
|
|
existing, err := s.aiConfig(r, user.ID)
|
|
|
|
|
if err != nil {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "ai settings", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
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 {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "save ai settings", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-22 00:03:45 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-08-17 07:53:53 +02:00
|
|
|
return ai.Config{
|
2026-08-22 00:03:45 +02:00
|
|
|
Provider: ai.Provider(str("provider")),
|
|
|
|
|
APIKey: str("api_key"),
|
|
|
|
|
BaseURL: str("base_url"),
|
|
|
|
|
Model: str("model"),
|
|
|
|
|
AllowPrivate: allowPrivate,
|
2026-08-17 07:53:53 +02:00
|
|
|
}, 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 {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "ai settings", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !cfg.Configured() {
|
|
|
|
|
// Not an error the user did anything to cause: the feature is simply off.
|
2026-08-17 14:32:42 +02:00
|
|
|
writeError(w, http.StatusConflict, CodeAINotConfigured,
|
|
|
|
|
"no AI provider is configured")
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
|
|
|
|
if err != nil {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.storeError(w, r, "get task", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
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)
|
2026-08-18 19:17:49 +02:00
|
|
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
2026-08-17 07:53:53 +02:00
|
|
|
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 {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "create subtask", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
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 {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "ai settings", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !cfg.Configured() {
|
2026-08-17 14:32:42 +02:00
|
|
|
writeError(w, http.StatusConflict, CodeAINotConfigured,
|
|
|
|
|
"no AI provider is configured")
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
|
|
|
|
|
if err != nil {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "list tasks", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
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)
|
2026-08-18 19:17:49 +02:00
|
|
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
2026-08-17 07:53:53 +02:00
|
|
|
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) {
|
2026-08-18 22:59:40 +02:00
|
|
|
user := userFrom(r.Context())
|
|
|
|
|
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
2026-08-17 07:53:53 +02:00
|
|
|
if err != nil {
|
2026-08-18 22:59:40 +02:00
|
|
|
s.internal(w, r, "gmail mailbox", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-18 22:59:40 +02:00
|
|
|
if box == nil {
|
|
|
|
|
writeJSON(w, http.StatusOK, gmailSettings{})
|
|
|
|
|
return
|
2026-08-17 07:53:53 +02:00
|
|
|
}
|
2026-08-18 22:59:40 +02:00
|
|
|
|
2026-08-18 13:28:44 +02:00
|
|
|
// The mailbox address, fetched again if it is missing.
|
|
|
|
|
//
|
2026-08-18 22:59:40 +02:00
|
|
|
// 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 {
|
2026-08-18 13:28:44 +02:00
|
|
|
email = fetched
|
|
|
|
|
} else {
|
|
|
|
|
s.log.Warn("gmail profile", "err", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 07:53:53 +02:00
|
|
|
writeJSON(w, http.StatusOK, gmailSettings{
|
2026-08-18 22:59:40 +02:00
|
|
|
Connected: box.RefreshToken != "",
|
2026-08-18 13:28:44 +02:00
|
|
|
Email: email,
|
2026-08-18 22:59:40 +02:00
|
|
|
Trigger: box.Trigger,
|
|
|
|
|
Label: box.Label,
|
2026-08-17 07:53:53 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
|
2026-08-18 22:59:40 +02:00
|
|
|
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
2026-08-17 07:53:53 +02:00
|
|
|
if err != nil {
|
2026-08-18 22:59:40 +02:00
|
|
|
s.internal(w, r, "gmail mailbox", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-18 22:59:40 +02:00
|
|
|
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
|
2026-08-17 07:53:53 +02:00
|
|
|
|
2026-08-18 22:59:40 +02:00
|
|
|
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "save gmail settings", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 22:59:40 +02:00
|
|
|
// handleDisconnectGmail forgets the connection, tokens and all. The tasks it made
|
|
|
|
|
// stay: they are the person's work now, not the mailbox's.
|
2026-08-17 07:53:53 +02:00
|
|
|
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
|
2026-08-18 22:59:40 +02:00
|
|
|
user := userFrom(r.Context())
|
|
|
|
|
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "gmail mailbox", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-18 22:59:40 +02:00
|
|
|
if box != nil {
|
|
|
|
|
if err := s.db.DeleteMailbox(r.Context(), user.ID, box.ID); err != nil {
|
|
|
|
|
s.internal(w, r, "disconnect gmail", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 07:53:53 +02:00
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var _ = json.Marshal
|