307 lines
9.4 KiB
Go
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)
|
||
|
|
}
|