Release / Build and publish image (push) Failing after 10m6s
Reported from use: "Fetch now" said "something went wrong" and the error log was empty. Both halves were the same bug wearing two hats. `handleGmailSyncNow` answered 502 with the code `internal_error`, which the interface maps to a generic sentence — so Google's own words, which are the whole diagnosis, were replaced by one that says nothing. And `writeError` records nothing; only `s.internal` does. So there was no message to read and nowhere to look it up afterwards. `s.upstream()` is the answer: 502 because this is somebody else's service saying no rather than this server breaking, a code of its own so the interface can quote what was said, and a row in the error log — which is the entire reason that log exists. A Gmail sync failing quietly for a week is otherwise invisible: the background job writes a line the next restart erases. The same connection also left the mailbox unnamed. The address is learned at connect time and that call can fail; a warning went to the log and an empty string to the database, so the page said "an unknown account" and kept saying it. The only way back was to disconnect and reconnect, which nobody would guess. The status endpoint asks again when it finds none, so a wrong answer lasts until the next look rather than until somebody reconnects. Ten Danish strings were hiding in `confirm()` and `app.toast()` calls, including every destructive confirmation in the app — the worst possible set to leave in one language, since it is what somebody reads in the half-second before agreeing to lose something. The i18n guard reads markup and attributes, and a string argument lives in the script block. It reads those now, and found four my own grep missed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
440 lines
13 KiB
Go
440 lines
13 KiB
Go
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"`
|
|
// 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+<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 {
|
|
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})
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
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, 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, 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, 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, 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, r, "gmail settings", err)
|
|
return
|
|
}
|
|
str := func(key string) string {
|
|
v, _ := values[key].(string)
|
|
return v
|
|
}
|
|
// 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
|
|
// rather than until somebody reconnects.
|
|
email := str("email")
|
|
if email == "" && str("refresh_token") != "" {
|
|
if fetched, err := s.gmailProfile(r.Context(), userFrom(r.Context()), values); err == nil {
|
|
email = fetched
|
|
} else {
|
|
s.log.Warn("gmail profile", "err", err)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, gmailSettings{
|
|
Connected: str("refresh_token") != "",
|
|
Email: 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, r, "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, r, "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, r, "disconnect gmail", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
var _ = json.Marshal
|