2026-08-17 07:53:53 +02:00
|
|
|
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"
|
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})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|