first commit
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user