Files
2026-08-09 18:03:09 +01:00

307 lines
9.4 KiB
Go

// 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)
}