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"
|
|
|
|
|
"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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
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 {
|
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)
|
|
|
|
|
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 {
|
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)
|
|
|
|
|
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 {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "gmail settings", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
str := func(key string) string {
|
|
|
|
|
v, _ := values[key].(string)
|
|
|
|
|
return v
|
|
|
|
|
}
|
2026-08-18 13:28:44 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 07:53:53 +02:00
|
|
|
writeJSON(w, http.StatusOK, gmailSettings{
|
|
|
|
|
Connected: str("refresh_token") != "",
|
2026-08-18 13:28:44 +02:00
|
|
|
Email: email,
|
2026-08-17 07:53:53 +02:00
|
|
|
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 {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "gmail settings", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
values["trigger"] = req.Trigger
|
|
|
|
|
values["label"] = req.Label
|
|
|
|
|
|
|
|
|
|
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); 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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-08-17 22:58:02 +02:00
|
|
|
s.internal(w, r, "disconnect gmail", err)
|
2026-08-17 07:53:53 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var _ = json.Marshal
|