Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions server/internal/http/handlers/mailboxes.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"

"github.com/go-chi/chi/v5"

Expand Down Expand Up @@ -63,14 +64,72 @@ func (h *Mailboxes) ListMessages(w http.ResponseWriter, r *http.Request) {
httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusBadGateway, httpx.CodeUpstreamFailed, "imap connect", err))
return
}
envelopes, total, err := c.ListMessages(r.Context(), mailbox, limit, uint32(before))
envelopes, total, uidvalidity, err := c.ListMessages(r.Context(), mailbox, limit, uint32(before))
if err != nil {
httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusBadGateway, httpx.CodeUpstreamFailed, "list messages", err))
return
}
resp := map[string]any{"messages": envelopes, "total": total}
resp := map[string]any{"messages": envelopes, "total": total, "uidvalidity": uidvalidity}
if len(envelopes) > 0 {
resp["next_before"] = envelopes[len(envelopes)-1].UID
}
httpx.WriteJSON(w, http.StatusOK, resp)
}

// Changes returns an incremental-sync delta for the named mailbox (proposal §6),
// so the client refreshes by fetching only what changed rather than re-listing.
//
// Query params:
// - uidvalidity (the client's cached UIDVALIDITY; 0/absent on first sync)
// - known (comma-separated UIDs the client currently holds)
// - limit (1..200, default 50 — caps how many new envelopes are returned)
func (h *Mailboxes) Changes(w http.ResponseWriter, r *http.Request) {
sess, ok := middleware.SessionFrom(r.Context())
if !ok {
httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusUnauthorized, httpx.CodeUnauthorized, "no session", nil))
return
}
mailbox, err := url.PathUnescape(chi.URLParam(r, "mailbox"))
if err != nil || mailbox == "" {
httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusBadRequest, httpx.CodeBadRequest, "invalid mailbox name", err))
return
}
q := r.URL.Query()
uidvalidity, _ := strconv.ParseUint(q.Get("uidvalidity"), 10, 32)
limit, _ := strconv.Atoi(q.Get("limit"))
known := parseUIDList(q.Get("known"))

c, err := h.Sessions.IMAPFor(r.Context(), sess)
if err != nil {
httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusBadGateway, httpx.CodeUpstreamFailed, "imap connect", err))
return
}
delta, err := c.MailboxChanges(r.Context(), mailbox, uint32(uidvalidity), known, limit)
if err != nil {
httpx.WriteError(w, r, h.Logger, httpx.NewAPIError(http.StatusBadGateway, httpx.CodeUpstreamFailed, "mailbox changes", err))
return
}
httpx.WriteJSON(w, http.StatusOK, delta)
}

// parseUIDList parses a comma-separated list of UIDs, skipping any malformed or
// zero entries. Caps the input to a sane bound so a runaway query string can't
// force an unbounded FETCH.
func parseUIDList(s string) []uint32 {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
if len(parts) > 1000 {
parts = parts[:1000]
}
out := make([]uint32, 0, len(parts))
for _, p := range parts {
n, err := strconv.ParseUint(strings.TrimSpace(p), 10, 32)
if err != nil || n == 0 {
continue
}
out = append(out, uint32(n))
}
return out
}
48 changes: 48 additions & 0 deletions server/internal/http/handlers/mailboxes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package handlers

import (
"reflect"
"testing"
)

func TestParseUIDList(t *testing.T) {
tests := []struct {
name string
in string
want []uint32
}{
{"empty", "", nil},
{"single", "42", []uint32{42}},
{"multiple", "1,2,3", []uint32{1, 2, 3}},
{"whitespace", " 1 , 2 ,3 ", []uint32{1, 2, 3}},
{"skips zero", "0,5,0,7", []uint32{5, 7}},
{"skips malformed", "1,abc,3,,5", []uint32{1, 3, 5}},
{"all invalid", "x,y,z", []uint32{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseUIDList(tt.in)
if len(got) == 0 && len(tt.want) == 0 {
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseUIDList(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}

func TestParseUIDListCapsInput(t *testing.T) {
// Build a 1500-entry list; parser must cap the parsed set at 1000 so a
// runaway query string can't force an unbounded FETCH.
in := ""
for i := 0; i < 1500; i++ {
if i > 0 {
in += ","
}
in += "1"
}
if got := len(parseUIDList(in)); got > 1000 {
t.Errorf("parseUIDList did not cap input: got %d entries, want <= 1000", got)
}
}
1 change: 1 addition & 0 deletions server/internal/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ func NewRouter(d Deps) http.Handler {
r.Use(requireSession)
r.Get("/mailboxes", mboxH.List)
r.Get("/mailboxes/{mailbox}/messages", mboxH.ListMessages)
r.Get("/mailboxes/{mailbox}/changes", mboxH.Changes)
r.Get("/mailboxes/{mailbox}/messages/{uid}", msgH.Get)
r.Patch("/mailboxes/{mailbox}/messages/{uid}/flags", msgH.SetFlags)
r.Delete("/mailboxes/{mailbox}/messages/{uid}", msgH.Delete)
Expand Down
159 changes: 137 additions & 22 deletions server/internal/imap/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,26 +176,26 @@ func (c *Client) selectMailbox(name string, readOnly bool) error {
// ListMessages returns up to limit envelopes from the given mailbox, newest first.
// If before > 0, only messages with UID < before are returned (cursor-style paging).
// The returned total is the mailbox's full message count (independent of the
// page), suitable for a "1–50 of N" pager.
func (c *Client) ListMessages(ctx context.Context, mailbox string, limit int, before uint32) ([]hmail.Envelope, uint32, error) {
// page), suitable for a "1–50 of N" pager. uidvalidity is the mailbox's current
// UIDVALIDITY, which the client persists to detect cache-invalidating changes
// on a later delta sync.
func (c *Client) ListMessages(ctx context.Context, mailbox string, limit int, before uint32) (envs []hmail.Envelope, total, uidvalidity uint32, err error) {
if limit <= 0 || limit > 200 {
limit = 50
}
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureLive(ctx); err != nil {
return nil, 0, err
}
if err := c.selectMailbox(mailbox, true); err != nil {
return nil, 0, err
return nil, 0, 0, err
}
mbox, err := c.conn.Select(mailbox, true)
if err != nil {
return nil, 0, err
return nil, 0, 0, fmt.Errorf("select %q: %w", mailbox, err)
}
total := mbox.Messages
c.selected = mailbox
total, uidvalidity = mbox.Messages, mbox.UidValidity
if total == 0 {
return []hmail.Envelope{}, 0, nil
return []hmail.Envelope{}, 0, uidvalidity, nil
}

// Fetch the highest UID first; if before is set, cap the upper bound there.
Expand All @@ -207,35 +207,150 @@ func (c *Client) ListMessages(ctx context.Context, mailbox string, limit int, be
}
uids, err := c.conn.UidSearch(criteria)
if err != nil {
return nil, 0, fmt.Errorf("uid search: %w", err)
return nil, 0, 0, fmt.Errorf("uid search: %w", err)
}
if len(uids) == 0 {
return []hmail.Envelope{}, total, nil
return []hmail.Envelope{}, total, uidvalidity, nil
}
// Newest first; take the last `limit` UIDs.
if len(uids) > limit {
uids = uids[len(uids)-limit:]
}
envelopes, err := c.fetchEnvelopes(uids)
if err != nil {
return nil, 0, 0, err
}
reverseEnvelopes(envelopes) // UID-ascending fetch → newest-first
return envelopes, total, uidvalidity, nil
}

// MailboxChanges computes an incremental-sync delta for a mailbox given the
// client's last-known state (proposal §6). knownValidity is the UIDVALIDITY the
// client cached for this folder (0 if none); known is the set of UIDs it
// currently holds. See hmail.MailboxDelta for the contract.
//
// Caller does not need the mailbox SELECTed beforehand.
func (c *Client) MailboxChanges(ctx context.Context, mailbox string, knownValidity uint32, known []uint32, limit int) (hmail.MailboxDelta, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
c.mu.Lock()
defer c.mu.Unlock()

var delta hmail.MailboxDelta
if err := c.ensureLive(ctx); err != nil {
return delta, err
}
mbox, err := c.conn.Select(mailbox, true)
if err != nil {
return delta, fmt.Errorf("select %q: %w", mailbox, err)
}
c.selected = mailbox
delta.UIDValidity, delta.Total = mbox.UidValidity, mbox.Messages

// UIDVALIDITY changed → the client's cached UIDs no longer identify the same
// messages. Signal a full resync and skip the (now meaningless) diff.
if knownValidity != 0 && knownValidity != mbox.UidValidity {
delta.Resync = true
return delta, nil
}

// Watermark: IMAP UIDs increase monotonically, so anything strictly greater
// than the highest UID the client holds is genuinely new.
var sinceUID uint32
for _, u := range known {
if u > sinceUID {
sinceUID = u
}
}

// 1. Added — UIDs in (sinceUID+1):*, capped to the newest `limit`.
if mbox.Messages > 0 && sinceUID < ^uint32(0) {
crit := imap.NewSearchCriteria()
seq := new(imap.SeqSet)
seq.AddRange(sinceUID+1, 0) // "(sinceUID+1):*" — 0 means "*"
crit.Uid = seq
newUIDs, err := c.conn.UidSearch(crit)
if err != nil {
return delta, fmt.Errorf("uid search added: %w", err)
}
if len(newUIDs) > limit {
newUIDs = newUIDs[len(newUIDs)-limit:]
}
if len(newUIDs) > 0 {
added, err := c.fetchEnvelopes(newUIDs)
if err != nil {
return delta, err
}
reverseEnvelopes(added) // newest-first, matching ListMessages
delta.Added = added
}
}

// 2. Flags + removals among the known set. A known UID absent from the
// FLAGS fetch has been expunged or moved away.
if len(known) > 0 {
present, err := c.fetchFlags(known)
if err != nil {
return delta, err
}
for _, u := range known {
if fl, ok := present[u]; ok {
delta.Flags = append(delta.Flags, hmail.FlagUpdate{UID: u, Flags: fl})
} else {
delta.Removed = append(delta.Removed, u)
}
}
}
return delta, nil
}

// fetchEnvelopes fetches list-view envelopes for the given UIDs in UID-ascending
// order. Caller must hold c.mu and have the mailbox SELECTed.
func (c *Client) fetchEnvelopes(uids []uint32) ([]hmail.Envelope, error) {
seq := new(imap.SeqSet)
seq.AddNum(uids...)

msgs := make(chan *imap.Message, len(uids))
fetchDone := make(chan error, 1)
done := make(chan error, 1)
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid, imap.FetchBodyStructure}
go func() { fetchDone <- c.conn.UidFetch(seq, items, msgs) }()
go func() { done <- c.conn.UidFetch(seq, items, msgs) }()

var envelopes []hmail.Envelope
var out []hmail.Envelope
for m := range msgs {
envelopes = append(envelopes, envelopeFrom(m))
out = append(out, envelopeFrom(m))
}
if err := <-fetchDone; err != nil {
return nil, 0, fmt.Errorf("fetch envelopes: %w", err)
if err := <-done; err != nil {
return nil, fmt.Errorf("fetch envelopes: %w", err)
}
// Sort newest-first by UID descending.
for i, j := 0, len(envelopes)-1; i < j; i, j = i+1, j-1 {
envelopes[i], envelopes[j] = envelopes[j], envelopes[i]
return out, nil
}

// fetchFlags fetches only the flags for the given UIDs, returned as a uid→flags
// map. UIDs that no longer exist are simply omitted from the result. Caller must
// hold c.mu and have the mailbox SELECTed.
func (c *Client) fetchFlags(uids []uint32) (map[uint32][]string, error) {
seq := new(imap.SeqSet)
seq.AddNum(uids...)
msgs := make(chan *imap.Message, len(uids))
done := make(chan error, 1)
items := []imap.FetchItem{imap.FetchFlags, imap.FetchUid}
go func() { done <- c.conn.UidFetch(seq, items, msgs) }()

out := make(map[uint32][]string, len(uids))
for m := range msgs {
out[m.Uid] = append([]string(nil), m.Flags...)
}
if err := <-done; err != nil {
return nil, fmt.Errorf("fetch flags: %w", err)
}
return out, nil
}

// reverseEnvelopes flips a UID-ascending slice in place to newest-first.
func reverseEnvelopes(e []hmail.Envelope) {
for i, j := 0, len(e)-1; i < j; i, j = i+1, j-1 {
e[i], e[j] = e[j], e[i]
}
return envelopes, total, nil
}

func envelopeFrom(m *imap.Message) hmail.Envelope {
Expand Down
30 changes: 30 additions & 0 deletions server/internal/mail/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,36 @@ type Envelope struct {
Preview string `json:"preview,omitempty"`
}

// FlagUpdate carries the current IMAP flags for a single known message. The
// delta endpoint returns one per message the client already knows about and is
// still present upstream; the client diffs these against its cached flags to
// detect read/unread (and other flag) transitions.
type FlagUpdate struct {
UID uint32 `json:"uid"`
Flags []string `json:"flags"`
}

// MailboxDelta is the incremental-sync payload for one mailbox (proposal §6).
//
// Because go-imap v1 exposes no CONDSTORE/MODSEQ, the delta is computed from
// UIDs and flags rather than a MODSEQ token: the client sends the UIDs it
// already has, and the server returns only what changed —
// - Added: full envelopes for UIDs newer than the client's highest known UID.
// - Flags: current flags for known UIDs still present (client diffs locally).
// - Removed: known UIDs that have since been expunged/moved away.
//
// UIDVALIDITY is the cache-coherence guard: if it differs from the client's
// stored value the cached UIDs are meaningless, so Resync is set and the client
// must discard its cache and refetch the folder from scratch.
type MailboxDelta struct {
UIDValidity uint32 `json:"uidvalidity"`
Total uint32 `json:"total"`
Resync bool `json:"resync"`
Added []Envelope `json:"added"`
Flags []FlagUpdate `json:"flags"`
Removed []uint32 `json:"removed"`
}

// AttachmentMeta describes an attachment without including its bytes.
type AttachmentMeta struct {
ID string `json:"id"`
Expand Down
Loading
Loading