Fyrre kopier af én mail ankom i samme sekund. Én kørsel kan højst lave femogtyve, så der var to i gang: et tryk på "Hent nu" mens ti-minutters-fejningen allerede var inde i den samme postkasse. Begge læste markøren som den stod, begge hentede de samme beskeder, begge lavede opgaver ud af dem. Markør-rettelsen fra i går var altså rigtig, men ikke nok — den virker, når der kun er én læser. Rækken læses nu igen under låsen, og låsen holdes hele vejen igennem: slap man den, før markøren var skrevet, ville hullet stå åbent præcis der, hvor det skulle lukkes. Et kort pr. postkasse frem for én lås. To menneskers postkasser har intet at sige hinanden, og én fælles lås ville gøre én langsom vært til alles problem. Prøven kører to kørsler oven i hinanden med vilje og tæller, hvor mange der er inde ad gangen — en lås er kun værd at have, hvis noget prøver døren. Den holder også øje med, at to forskellige postkasser ikke venter på hinanden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
7.2 KiB
Go
241 lines
7.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/kristianwind/verdande/internal/imap"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
// defaultMailboxSyncBudget bounds one read of one mailbox when somebody is waiting.
|
|
// The same reasoning as Gmail's: a mailbox that has stopped answering must not
|
|
// hold a request open until whatever sits in front of this server answers for it.
|
|
const defaultMailboxSyncBudget = 25 * time.Second
|
|
|
|
func (s *Server) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
list, err := s.db.Mailboxes(r.Context(), user.ID)
|
|
if err != nil {
|
|
s.internal(w, r, "list mailboxes", err)
|
|
return
|
|
}
|
|
if list == nil {
|
|
list = []store.Mailbox{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"mailboxes": list})
|
|
}
|
|
|
|
// handleAddMailbox connects one, after proving it can actually be read.
|
|
//
|
|
// Tested before it is saved, not after: a mailbox that was accepted and then
|
|
// silently fails every ten minutes is worse than one that refused at the door,
|
|
// where the person is still holding the password they just typed.
|
|
func (s *Server) handleAddMailbox(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Host string `json:"host"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
Folder string `json:"folder"`
|
|
}
|
|
if err := decodeJSON(w, r, &body); err != nil {
|
|
return
|
|
}
|
|
|
|
body.Host = strings.TrimSpace(body.Host)
|
|
body.Username = strings.TrimSpace(body.Username)
|
|
if body.Host == "" || body.Username == "" || body.Password == "" {
|
|
writeError(w, http.StatusBadRequest, CodeValidation,
|
|
"a mailbox needs a host, a username and a password")
|
|
return
|
|
}
|
|
// A host with no port is the mistake everyone makes once, and 993 is the answer
|
|
// for every host worth naming.
|
|
if !strings.Contains(body.Host, ":") {
|
|
body.Host += ":993"
|
|
}
|
|
|
|
account := imap.Account{
|
|
Host: body.Host, Username: body.Username,
|
|
Password: body.Password, Folder: body.Folder,
|
|
}
|
|
client, err := imap.Dial(account)
|
|
if err != nil {
|
|
s.upstream(w, r, CodeMailboxFailed, "connect mailbox", err)
|
|
return
|
|
}
|
|
client.Close()
|
|
|
|
user := userFrom(r.Context())
|
|
m := &store.Mailbox{
|
|
UserID: user.ID, Kind: "imap", Name: strings.TrimSpace(body.Name),
|
|
Host: body.Host, Username: body.Username,
|
|
Password: body.Password, Folder: body.Folder,
|
|
}
|
|
if m.Name == "" {
|
|
m.Name = body.Username
|
|
}
|
|
if err := s.db.SaveMailbox(r.Context(), m); err != nil {
|
|
// The unique index. Saying which mailbox is already here beats "constraint
|
|
// failed", which is true and useless.
|
|
if strings.Contains(err.Error(), "UNIQUE") {
|
|
writeError(w, http.StatusConflict, CodeValidation,
|
|
fmt.Sprintf("%s on %s is already connected", body.Username, body.Host))
|
|
return
|
|
}
|
|
s.internal(w, r, "save mailbox", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, m)
|
|
}
|
|
|
|
func (s *Server) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
if err := s.db.DeleteMailbox(r.Context(), user.ID, chi.URLParam(r, "mailboxID")); err != nil {
|
|
s.internal(w, r, "delete mailbox", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleSyncMailbox reads one mailbox now, within a budget of its own.
|
|
func (s *Server) handleSyncMailbox(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
m, err := s.db.Mailbox(r.Context(), user.ID, chi.URLParam(r, "mailboxID"))
|
|
if err != nil {
|
|
s.internal(w, r, "read mailbox", err)
|
|
return
|
|
}
|
|
if m == nil {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such mailbox")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), defaultMailboxSyncBudget)
|
|
defer cancel()
|
|
|
|
created, err := s.SyncMailbox(ctx, user, m)
|
|
if err != nil {
|
|
// Whatever was made by the deadline is kept: each task is committed as it
|
|
// is made, so a short budget is a shorter run rather than a lost one.
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"created": created, "partial": true})
|
|
return
|
|
}
|
|
s.upstream(w, r, CodeMailboxFailed, "sync mailbox", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"created": created})
|
|
}
|
|
|
|
// SyncMailbox turns the flagged mail newer than the last run into tasks.
|
|
//
|
|
// Exported because the background job calls it too, on the same terms and without
|
|
// a budget: nobody is waiting there.
|
|
func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mailbox) (int, error) {
|
|
// One reader of this mailbox at a time. Held for the whole run, marker and all:
|
|
// releasing before the marker is written would leave exactly the gap this is
|
|
// here to close.
|
|
unlock := s.lockMailbox(m.ID)
|
|
defer unlock()
|
|
|
|
// Re-read under the lock. The row that came in may have been fetched before the
|
|
// run that just finished moved the marker.
|
|
if fresh, err := s.db.Mailbox(ctx, m.UserID, m.ID); err == nil && fresh != nil {
|
|
m = fresh
|
|
}
|
|
|
|
client, err := imap.Dial(imap.Account{
|
|
Host: m.Host, Username: m.Username, Password: m.Password, Folder: m.Folder,
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer client.Close()
|
|
|
|
messages, searched, err := client.Since(m.LastUID, 25)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(messages) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
projectID, err := s.db.InboxID(ctx, user.ID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
created := 0
|
|
highest := m.LastUID
|
|
for _, msg := range messages {
|
|
if ctx.Err() != nil {
|
|
// What was made so far is already committed and counted, and the marker
|
|
// below has to be written for it: a run that created tasks and forgot how
|
|
// far it got would make them all again on the next pass.
|
|
break
|
|
}
|
|
|
|
subject := msg.Subject
|
|
if subject == "" {
|
|
subject = "(uden emne)"
|
|
}
|
|
content := subject
|
|
if msg.From != "" {
|
|
content = msg.From + ": " + subject
|
|
}
|
|
|
|
task := &store.Task{
|
|
ProjectID: projectID,
|
|
Content: content,
|
|
Description: msg.Snippet,
|
|
Priority: 4,
|
|
CreatedBy: user.ID,
|
|
}
|
|
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
|
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
|
|
continue
|
|
}
|
|
created++
|
|
if msg.UID > highest {
|
|
highest = msg.UID
|
|
}
|
|
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
|
}
|
|
|
|
// A run that got all the way through moves the marker to the whole of what the
|
|
// search considered, not merely to the last message it happened to read. That
|
|
// is what stops the same mail becoming a task again on the next run when the
|
|
// server did not put a uid in the fetch reply.
|
|
if ctx.Err() == nil && searched > highest {
|
|
highest = searched
|
|
}
|
|
|
|
// Written even when the loop was cut short, and outside the request's context
|
|
// so a cancelled request still records what it did.
|
|
if highest > m.LastUID {
|
|
if err := s.db.MarkMailboxRead(context.WithoutCancel(ctx), m.ID, highest, time.Now()); err != nil {
|
|
s.log.Warn("mark mailbox read", "err", err, "mailbox", m.ID)
|
|
}
|
|
}
|
|
if ctx.Err() != nil {
|
|
return created, ctx.Err()
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
// lockMailbox serialises one mailbox against itself and returns the release.
|
|
func (s *Server) lockMailbox(id string) func() {
|
|
value, _ := s.syncing.LoadOrStore(id, &sync.Mutex{})
|
|
mu := value.(*sync.Mutex)
|
|
mu.Lock()
|
|
return mu.Unlock
|
|
}
|