// Package jmap implements a subset of JMAP Core (RFC 8620) and JMAP Mail // (RFC 8621) — enough for a real JMAP client to discover the session, // list mailboxes, and query/fetch messages. Scoped deliberately: Email/set // (flag changes, delete), Email/import (send), and push (EventSource) are // deferred, along with Sieve/ManageSieve entirely (RFC 5804, not started // this phase — noted here, not silently skipped, since ManageSieve was // originally paired with this phase in the plan). // // This exists alongside — not instead of — Phase 8's direct REST API, // which the webmail SPA still uses. JMAP here is independently testable // and available for third-party JMAP clients per the plan's config toggle // (jmap.external_enabled); a later pass can migrate the SPA's internals to // call this instead without changing its own REST contract. package jmap import ( "context" "encoding/json" "fmt" "net/http" "strings" "gomail/internal/accounts" "gomail/internal/auth" "gomail/internal/db" "gomail/internal/mailstore" ) const ( coreCapability = "urn:ietf:params:jmap:core" mailCapability = "urn:ietf:params:jmap:mail" ) type Handler struct { database *db.DB store *mailstore.Store hostname string } func NewHandler(database *db.DB, store *mailstore.Store, hostname string) *Handler { return &Handler{database: database, store: store, hostname: hostname} } func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/.well-known/jmap", h.session) mux.HandleFunc("/jmap/api", h.api) } // ── Session resource (RFC 8620 §2) ───────────────────────────────────────────── func (h *Handler) session(w http.ResponseWriter, r *http.Request) { user, ok := h.authenticate(r) if !ok { w.Header().Set("WWW-Authenticate", `Basic realm="GoMail JMAP"`) http.Error(w, "unauthorized", http.StatusUnauthorized) return } resp := map[string]any{ "capabilities": map[string]any{ coreCapability: map[string]any{ "maxSizeUpload": 50 * 1024 * 1024, "maxConcurrentUpload": 4, "maxSizeRequest": 10 * 1024 * 1024, "maxConcurrentRequests": 4, "maxCallsInRequest": 16, "maxObjectsInGet": 500, "maxObjectsInSet": 500, }, mailCapability: map[string]any{ "maxMailboxesPerEmail": 10, "maxMailboxDepth": 1, "maxSizeMailboxName": 255, "maxSizeAttachmentsPerEmail": 50 * 1024 * 1024, "emailQuerySortOptions": []string{"receivedAt"}, "mayCreateTopLevelMailbox": false, }, }, "accounts": map[string]any{ user.ID: map[string]any{ "name": user.Email, "isPersonal": true, "isReadOnly": false, "accountCapabilities": map[string]any{mailCapability: map[string]any{}}, }, }, "primaryAccounts": map[string]string{mailCapability: user.ID}, "username": user.Email, "apiUrl": "/jmap/api", "downloadUrl": "/jmap/download/{accountId}/{blobId}/{name}", "uploadUrl": "/jmap/upload/{accountId}", "eventSourceUrl": "/jmap/events", "state": "1", } writeJSON(w, http.StatusOK, resp) } func (h *Handler) authenticate(r *http.Request) (*db.User, bool) { username, password, ok := r.BasicAuth() if !ok { return nil, false } return auth.Authenticate(h.database, username, password, auth.ScopeIMAP) } // ── API endpoint (RFC 8620 §3) ────────────────────────────────────────────────── type request struct { Using []string `json:"using"` MethodCalls [][3]any `json:"methodCalls"` } type response struct { MethodResponses [][3]any `json:"methodResponses"` SessionState string `json:"sessionState"` } func (h *Handler) api(w http.ResponseWriter, r *http.Request) { user, ok := h.authenticate(r) if !ok { w.Header().Set("WWW-Authenticate", `Basic realm="GoMail JMAP"`) http.Error(w, "unauthorized", http.StatusUnauthorized) return } if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JMAP request: "+err.Error(), http.StatusBadRequest) return } resp := response{SessionState: "1"} provider := accounts.NewGoMailProvider(h.database, h.store, user) for _, call := range req.MethodCalls { methodName, _ := call[0].(string) args, _ := call[1].(map[string]any) callID, _ := call[2].(string) result := h.dispatch(r.Context(), provider, user, methodName, args) resp.MethodResponses = append(resp.MethodResponses, [3]any{result.name, result.args, callID}) } writeJSON(w, http.StatusOK, resp) } type methodResult struct { name string args map[string]any } func (h *Handler) dispatch(ctx context.Context, provider *accounts.GoMailProvider, user *db.User, method string, args map[string]any) methodResult { switch method { case "Core/echo": return methodResult{name: "Core/echo", args: args} case "Mailbox/get": return h.mailboxGet(ctx, provider, user) case "Email/query": return h.emailQuery(ctx, provider, args) case "Email/get": return h.emailGet(ctx, provider, args) default: return methodResult{name: "error", args: map[string]any{"type": "unknownMethod", "description": fmt.Sprintf("method %q not implemented", method)}} } } // ── Mailbox/get ─────────────────────────────────────────────────────────────── func (h *Handler) mailboxGet(ctx context.Context, provider *accounts.GoMailProvider, user *db.User) methodResult { folders, err := provider.ListFolders(ctx) if err != nil { return methodResult{name: "error", args: map[string]any{"type": "serverFail", "description": err.Error()}} } var list []map[string]any for _, f := range folders { list = append(list, map[string]any{ "id": f.ID, "name": f.DisplayName, "role": jmapRole(f.Type), "totalEmails": f.TotalCount, "unreadEmails": f.UnreadCount, "parentId": nil, "sortOrder": 0, "isSubscribed": true, }) } return methodResult{name: "Mailbox/get", args: map[string]any{ "accountId": user.ID, "state": "1", "list": list, "notFound": []string{}, }} } func jmapRole(folderType string) any { switch folderType { case "inbox": return "inbox" case "sent": return "sent" case "drafts": return "drafts" case "trash": return "trash" case "junk": return "junk" default: return nil } } // ── Email/query ─────────────────────────────────────────────────────────────── func (h *Handler) emailQuery(ctx context.Context, provider *accounts.GoMailProvider, args map[string]any) methodResult { filter, _ := args["filter"].(map[string]any) mailboxID := "INBOX" if filter != nil { if m, ok := filter["inMailbox"].(string); ok && m != "" { mailboxID = m } } headers, err := provider.ListMessages(ctx, mailboxID, accounts.ListOpts{}) if err != nil { return methodResult{name: "error", args: map[string]any{"type": "serverFail", "description": err.Error()}} } ids := make([]string, len(headers)) for i, hdr := range headers { ids[i] = mailboxID + ":" + hdr.ID // composite ID since JMAP IDs are global, ours are per-folder } return methodResult{name: "Email/query", args: map[string]any{ "ids": ids, "queryState": "1", "canCalculateChanges": false, "position": 0, "total": len(ids), }} } // ── Email/get ───────────────────────────────────────────────────────────────── func (h *Handler) emailGet(ctx context.Context, provider *accounts.GoMailProvider, args map[string]any) methodResult { rawIDs, _ := args["ids"].([]any) var list []map[string]any var notFound []string for _, raw := range rawIDs { compositeID, _ := raw.(string) mailboxID, messageID, ok := splitCompositeID(compositeID) if !ok { notFound = append(notFound, compositeID) continue } full, err := provider.GetMessage(ctx, mailboxID, messageID) if err != nil { notFound = append(notFound, compositeID) continue } list = append(list, map[string]any{ "id": compositeID, "mailboxIds": map[string]bool{mailboxID: true}, "from": []map[string]string{{"email": full.From}}, "to": []map[string]string{{"email": full.To}}, "subject": full.Subject, "receivedAt": full.Date, "size": full.SizeBytes, "preview": truncatePreview(string(full.Raw)), }) } return methodResult{name: "Email/get", args: map[string]any{ "state": "1", "list": list, "notFound": notFound, }} } func splitCompositeID(id string) (mailboxID, messageID string, ok bool) { idx := strings.LastIndex(id, ":") if idx == -1 { return "", "", false } return id[:idx], id[idx+1:], true } func truncatePreview(raw string) string { sep := "\r\n\r\n" body := raw if idx := strings.Index(raw, sep); idx >= 0 { body = raw[idx+len(sep):] } if len(body) > 200 { body = body[:200] } return body } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(v) }