Files
verdande/internal/httpapi/errors.go
T
Kristian WindandClaude Opus 5 6b4ef5b2f2 feat(imap): postkasser kan læses, og det er bevist mod en rigtig server
Handlere til at forbinde, koble fra og hente nu, plus baggrundssynk hvert tiende
minut ved siden af Gmails. Forbindelsen prøves FØR den gemmes: en postkasse, der
blev taget imod og så fejler stille hvert tiende minut, er værre end en, der blev
afvist ved døren, mens man stadig sidder med kodeordet i hånden.

Ingen "set"-liste til deduplikering som Gmail har. Uid'er er monotone, så
last_uid er hele svaret — og den skrives også, når budgettet løb ud, for en
kørsel, der lavede opgaver og glemte hvor langt den nåede, laver dem alle igen
næste gang.

Rundturen køres nu mod go-imap's egen server i processen: flaget post kommer med,
uflaget gør ikke, og fortsættelsen fra et uid henter kun det nyere. De hidtidige
prøver dækkede de rene dele og ville alle have bestået med selve hentningen i
stykker.

Testen kræver et selvsigneret certifikat, og klienten nægter dem. Account.RootCAs
lader en test TILFØJE tillid; der er stadig ingen måde at fjerne den på. Forskellen
er en søm og ikke et hul — en prøve viser, at samme server afvises uden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:15:50 +02:00

152 lines
5.9 KiB
Go

package httpapi
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
)
// APIError is the one shape every failure takes. A client that has learned to read
// it once can read all of them.
//
// `code` is a stable machine-readable string; `error` is English prose for a
// developer reading a log. Neither is what the user sees — the frontend maps `code`
// to a Danish or English message, because the server has no business deciding which
// language somebody's browser is in.
type APIError struct {
Code string `json:"code"`
Message string `json:"error"`
Fields map[string]string `json:"fields,omitempty"`
}
func (e APIError) Error() string { return e.Message }
// The codes the API can return. Kept in one list so the frontend's mapping can be
// checked against it rather than discovered from whatever a handler happened to write.
const (
CodeBadRequest = "bad_request"
CodeValidation = "validation_failed"
CodeUnauthorized = "unauthorized"
CodeTOTPRequired = "totp_required"
CodeForbidden = "forbidden"
CodeNotFound = "not_found"
CodeConflict = "conflict"
CodeRateLimited = "rate_limited"
CodeInternal = "internal_error"
CodePayloadTooLarge = "payload_too_large"
// Four situations that used to answer plain `conflict`, which the frontend
// renders as "that already exists". For these that is not merely vague, it is
// wrong: an unconfigured Gmail is not a Gmail that is already there, and a
// second factor that is off is not one that is on. A code exists so the
// interface can say the true thing — the server's own prose is English for a
// log, and deciding what a person reads is the frontend's job.
CodeGmailNotConfigured = "gmail_not_configured"
CodeAINotConfigured = "ai_not_configured"
CodeTOTPNotEnabled = "totp_not_enabled"
CodeInboxProtected = "inbox_protected"
// And a fifth, for the same reason: refusing to remove the last administrator
// is not "that already exists" either, and it is the one refusal on the user
// page that a person needs explained rather than merely reported.
CodeLastAdmin = "last_admin"
// Gmail said no. Its own words are the diagnosis — "invalid_grant",
// "insufficient authentication scopes" — and they are useless behind a generic
// sentence, which is what `internal_error` gave: a person clicked Fetch now,
// read "something went wrong", and had nowhere to go. The interface wraps the
// message in one that says where it came from.
CodeGmailFailed = "gmail_failed"
// And the same for a mailbox read over IMAP. A host's refusal is the whole
// diagnosis — a wrong app password, a host that is not listening, a folder that
// is not there — and it says it in its own words.
CodeMailboxFailed = "mailbox_failed"
)
// errLastAdmin is returned to the caller that already wrote the response, so it
// stops rather than carrying on. It is never rendered anywhere.
var errLastAdmin = errors.New("httpapi: refused to remove the last administrator")
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, APIError{Code: code, Message: message})
}
func writeFieldErrors(w http.ResponseWriter, fields map[string]string) {
writeJSON(w, http.StatusUnprocessableEntity, APIError{
Code: CodeValidation,
Message: "one or more fields are not valid",
Fields: fields,
})
}
// maxBodyBytes caps a JSON request body at 1 MiB. Task text and comments are small;
// anything larger is either a mistake or an attempt to make the server allocate.
// File uploads do not come through here.
const maxBodyBytes = 1 << 20
// decodeJSON reads a request body strictly: unknown fields are an error rather than
// being ignored.
//
// Silently dropping a field the client sent is the worst of the options — a typo in
// `priorty` becomes "the API accepted my request and ignored half of it", which is
// discovered much later and by the user rather than by the developer.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
if ct := r.Header.Get("Content-Type"); ct != "" {
if mt := strings.TrimSpace(strings.Split(ct, ";")[0]); mt != "application/json" {
writeError(w, http.StatusUnsupportedMediaType, CodeBadRequest,
"request body must be application/json")
return errors.New("wrong content type")
}
}
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
var maxErr *http.MaxBytesError
switch {
case errors.As(err, &maxErr):
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"request body is too large")
case errors.Is(err, io.EOF):
writeError(w, http.StatusBadRequest, CodeBadRequest, "request body is empty")
default:
writeError(w, http.StatusBadRequest, CodeBadRequest, jsonErrorMessage(err))
}
return err
}
// Exactly one JSON value, so a body with a second object appended cannot smuggle
// anything past a handler that only reads the first.
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, CodeBadRequest,
"request body must contain a single JSON object")
return errors.New("trailing data")
}
return nil
}
// jsonErrorMessage turns the standard library's decoder errors into something that
// names the offending field, since "json: cannot unmarshal string into Go struct
// field taskRequest.priority of type int" is not a sentence to put in an API.
func jsonErrorMessage(err error) string {
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
if typeErr.Field != "" {
return "field " + typeErr.Field + " has the wrong type"
}
return "a field has the wrong type"
}
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
return "request body is not valid JSON"
}
if strings.HasPrefix(err.Error(), "json: unknown field ") {
return "unknown field " + strings.TrimPrefix(err.Error(), "json: unknown field ")
}
return "request body could not be read"
}