Initial commit: stalwart-migrator design and scaffolding
In-place upgrade tool for Stalwart Mail Server (0.15.5 -> latest) with checkpointed rollback and post-migration validation. Design stage; see ARCHITECTURE.md.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
package stalwartapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is the shared JMAP/management-API client every migration phase
|
||||
// uses to talk to a Stalwart instance. It stays deliberately thin: phases
|
||||
// that need Stalwart-specific behavior (recovery-mode control, apply-plan
|
||||
// replay, account/mailbox introspection) get methods added here only as
|
||||
// their wire-level details are confirmed against Stalwart's actual source
|
||||
// and documentation, never guessed - see management.go and mailbox.go for
|
||||
// what that grounding looked like for account enumeration and mailbox
|
||||
// counts respectively.
|
||||
type Client struct {
|
||||
BaseURL string // e.g. "https://mail.example.com"
|
||||
Username string
|
||||
Password string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
// Ping confirms the instance is reachable and the given credentials are
|
||||
// accepted, via JMAP session discovery (RFC 8620 §2, the well-known
|
||||
// /.well-known/jmap endpoint) over HTTP Basic auth. This is preflight's
|
||||
// "dry-run" reachability check (ARCHITECTURE.md §4.1): it does nothing but
|
||||
// read the session document, so it's safe to run against a live production
|
||||
// server before anything else in the migration happens.
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
url := strings.TrimRight(c.BaseURL, "/") + "/.well-known/jmap"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(c.Username, c.Password)
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stalwartapi: reach %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("stalwartapi: session request to %s returned %s", url, resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot mirrors checkpoint.PreflightSnapshot's shape without this
|
||||
// package depending on the checkpoint package's format.
|
||||
type Snapshot struct {
|
||||
AccountCount int
|
||||
Domains []string
|
||||
MailboxCounts map[string][]MailboxCount // account email -> its mailboxes
|
||||
// MailboxErrors records, per account email, why that account's mailbox
|
||||
// counts couldn't be captured (e.g. impersonation not permitted for
|
||||
// that account). A non-empty entry here means MailboxCounts has no
|
||||
// entry for that account - it's not silently treated as zero messages.
|
||||
MailboxErrors map[string]string
|
||||
}
|
||||
|
||||
type MailboxCount struct {
|
||||
Mailbox string
|
||||
Messages int
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package stalwartapi implements the JMAP and management-API client shared by every other phase.
|
||||
// See ARCHITECTURE.md §7 for the design.
|
||||
package stalwartapi
|
||||
@@ -0,0 +1,112 @@
|
||||
package stalwartapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// jmapSession is the subset of RFC 8620 §2's Session object this package
|
||||
// needs: apiUrl (where to POST standard JMAP method calls - a different
|
||||
// endpoint from Stalwart's /api management API, confirmed in
|
||||
// docs/ref/object/account.md) and primaryAccounts (which accountId the
|
||||
// urn:ietf:params:jmap:mail capability maps to for the authenticated
|
||||
// identity).
|
||||
type jmapSession struct {
|
||||
APIURL string `json:"apiUrl"`
|
||||
PrimaryAccounts map[string]string `json:"primaryAccounts"`
|
||||
}
|
||||
|
||||
const jmapMailCapability = "urn:ietf:params:jmap:mail"
|
||||
|
||||
// fetchSession performs JMAP session discovery (RFC 8620 §2,
|
||||
// /.well-known/jmap) with the given credentials.
|
||||
func (c *Client) fetchSession(ctx context.Context, username, password string) (*jmapSession, error) {
|
||||
url := strings.TrimRight(c.BaseURL, "/") + "/.well-known/jmap"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(username, password)
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("session discovery at %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("session discovery at %s returned %s", url, resp.Status)
|
||||
}
|
||||
var session jmapSession
|
||||
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
||||
return nil, fmt.Errorf("parse session document from %s: %w", url, err)
|
||||
}
|
||||
if session.APIURL == "" {
|
||||
return nil, fmt.Errorf("session document from %s has no apiUrl", url)
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
type mailboxGetEntry struct {
|
||||
Name string `json:"name"`
|
||||
TotalEmails int `json:"totalEmails"`
|
||||
}
|
||||
|
||||
// MailboxSnapshot captures every mailbox's message count for one account,
|
||||
// authenticating as that account via Stalwart's documented impersonation
|
||||
// mechanism rather than assuming this Client's own credentials get direct
|
||||
// cross-account access. That assumption would be wrong: Stalwart's JMAP
|
||||
// session `accounts`/`primaryAccounts` map is built only from the
|
||||
// authenticated identity's own membership and sharing grants - it is NOT
|
||||
// expanded for a superuser (confirmed against
|
||||
// crates/jmap/src/api/session.rs), so a plain Mailbox/get call for an
|
||||
// arbitrary accountId under this Client's own login would be rejected.
|
||||
//
|
||||
// Instead, this Client's Username must hold Stalwart's `impersonate`
|
||||
// permission (see docs/auth/authorization/administrator.md), and this
|
||||
// method logs in AS the target account using the documented composite
|
||||
// login format "<target>%<impersonator>" with the impersonator's password,
|
||||
// then calls standard RFC 8621 Mailbox/get - reading the exact wire
|
||||
// property name `totalEmails` - against the URL that account's own JMAP
|
||||
// session document reports as apiUrl (not the /api management endpoint
|
||||
// x:Account/* uses; confirmed as a distinct endpoint in
|
||||
// docs/ref/object/account.md).
|
||||
func (c *Client) MailboxSnapshot(ctx context.Context, targetEmail string) ([]MailboxCount, error) {
|
||||
impersonatedUser := fmt.Sprintf("%s%%%s", targetEmail, c.Username)
|
||||
|
||||
session, err := c.fetchSession(ctx, impersonatedUser, c.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stalwartapi: impersonate %s: %w", targetEmail, err)
|
||||
}
|
||||
accountID, ok := session.PrimaryAccounts[jmapMailCapability]
|
||||
if !ok || accountID == "" {
|
||||
return nil, fmt.Errorf("stalwartapi: impersonated session for %s has no %s account", targetEmail, jmapMailCapability)
|
||||
}
|
||||
|
||||
responses, err := c.callAs(ctx, impersonatedUser, c.Password, session.APIURL,
|
||||
[]string{"urn:ietf:params:jmap:core", jmapMailCapability},
|
||||
[]any{[]any{"Mailbox/get", map[string]any{"accountId": accountID, "properties": []string{"name", "totalEmails"}}, "m"}},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stalwartapi: Mailbox/get for %s: %w", targetEmail, err)
|
||||
}
|
||||
if len(responses) == 0 {
|
||||
return nil, fmt.Errorf("stalwartapi: Mailbox/get for %s returned no method responses", targetEmail)
|
||||
}
|
||||
if responses[0].Name == "error" {
|
||||
return nil, fmt.Errorf("stalwartapi: Mailbox/get for %s error: %s", targetEmail, responses[0].Args)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
List []mailboxGetEntry `json:"list"`
|
||||
}
|
||||
if err := json.Unmarshal(responses[0].Args, &result); err != nil {
|
||||
return nil, fmt.Errorf("stalwartapi: parse Mailbox/get response for %s: %w", targetEmail, err)
|
||||
}
|
||||
counts := make([]MailboxCount, len(result.List))
|
||||
for i, m := range result.List {
|
||||
counts[i] = MailboxCount{Mailbox: m.Name, Messages: m.TotalEmails}
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package stalwartapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMailboxSnapshotImpersonatesAndFetchesCounts(t *testing.T) {
|
||||
var apiURL string
|
||||
var sessionAuthUser, sessionAuthPass string
|
||||
var gotAccountID string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap":
|
||||
sessionAuthUser, sessionAuthPass, _ = r.BasicAuth()
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"apiUrl": apiURL,
|
||||
"primaryAccounts": map[string]string{jmapMailCapability: "mail-acct-1"},
|
||||
})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/jmap-api":
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
methodCalls := body["methodCalls"].([]any)
|
||||
args := methodCalls[0].([]any)[1].(map[string]any)
|
||||
gotAccountID, _ = args["accountId"].(string)
|
||||
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
|
||||
[]any{"Mailbox/get", map[string]any{"list": []map[string]any{
|
||||
{"name": "Inbox", "totalEmails": 42},
|
||||
{"name": "Sent", "totalEmails": 7},
|
||||
}}, "m"},
|
||||
}})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
apiURL = srv.URL + "/jmap-api"
|
||||
|
||||
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
|
||||
counts, err := client.MailboxSnapshot(context.Background(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatalf("MailboxSnapshot: %v", err)
|
||||
}
|
||||
|
||||
if sessionAuthUser != "[email protected]%admin" || sessionAuthPass != "hunter2" {
|
||||
t.Errorf("session discovery auth = (%s, %s), want ([email protected]%%admin, hunter2)", sessionAuthUser, sessionAuthPass)
|
||||
}
|
||||
if gotAccountID != "mail-acct-1" {
|
||||
t.Errorf("Mailbox/get accountId = %s, want mail-acct-1", gotAccountID)
|
||||
}
|
||||
if len(counts) != 2 || counts[0].Mailbox != "Inbox" || counts[0].Messages != 42 || counts[1].Mailbox != "Sent" || counts[1].Messages != 7 {
|
||||
t.Errorf("counts = %+v, want [{Inbox 42} {Sent 7}]", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxSnapshotFailsWhenImpersonationRejected(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &Client{BaseURL: srv.URL, Username: "notasuperuser", Password: "x"}
|
||||
_, err := client.MailboxSnapshot(context.Background(), "[email protected]")
|
||||
if err == nil {
|
||||
t.Fatal("MailboxSnapshot should error when session discovery (impersonation) is rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxSnapshotFailsWhenSessionHasNoMailAccount(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"apiUrl": "http://unused/",
|
||||
"primaryAccounts": map[string]string{},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
|
||||
_, err := client.MailboxSnapshot(context.Background(), "[email protected]")
|
||||
if err == nil {
|
||||
t.Fatal("MailboxSnapshot should error when the session has no jmap:mail primary account")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxSnapshotPropagatesMailboxGetError(t *testing.T) {
|
||||
var apiURL string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/.well-known/jmap":
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"apiUrl": apiURL,
|
||||
"primaryAccounts": map[string]string{jmapMailCapability: "mail-acct-1"},
|
||||
})
|
||||
case r.URL.Path == "/jmap-api":
|
||||
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
|
||||
[]any{"error", map[string]any{"type": "accountNotFound"}, "m"},
|
||||
}})
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
apiURL = srv.URL + "/jmap-api"
|
||||
|
||||
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
|
||||
_, err := client.MailboxSnapshot(context.Background(), "[email protected]")
|
||||
if err == nil {
|
||||
t.Fatal("MailboxSnapshot should propagate a JMAP-level error from Mailbox/get")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package stalwartapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// managementCapabilities are the JMAP capability URNs Stalwart requires for
|
||||
// its management object calls (x:Account/*): standard JMAP core plus its
|
||||
// own urn:stalwart:jmap extension. Confirmed against
|
||||
// docs/ref/object/account.md and crates/jmap-proto/src/request/capability.rs
|
||||
// in stalwartlabs/stalwart.
|
||||
var managementCapabilities = []string{"urn:ietf:params:jmap:core", "urn:stalwart:jmap"}
|
||||
|
||||
type jmapRequest struct {
|
||||
Using []string `json:"using"`
|
||||
MethodCalls []any `json:"methodCalls"`
|
||||
}
|
||||
|
||||
type jmapRawResponse struct {
|
||||
MethodResponses []json.RawMessage `json:"methodResponses"`
|
||||
}
|
||||
|
||||
// methodResponse is one [name, args, callId] triple from a JMAP response,
|
||||
// per RFC 8620 §3.2 - Stalwart's management API follows the same envelope
|
||||
// shape as its regular JMAP methods.
|
||||
type methodResponse struct {
|
||||
Name string
|
||||
Args json.RawMessage
|
||||
CallID string
|
||||
}
|
||||
|
||||
// call POSTs one JMAP-style request to the management API (Stalwart's /api
|
||||
// endpoint - see docs/ref/object/account.md, which is distinct from /jmap)
|
||||
// using this Client's own credentials, and returns its parsed method
|
||||
// responses in order.
|
||||
func (c *Client) call(ctx context.Context, using []string, methodCalls []any) ([]methodResponse, error) {
|
||||
return c.callAs(ctx, c.Username, c.Password, strings.TrimRight(c.BaseURL, "/")+"/api", using, methodCalls)
|
||||
}
|
||||
|
||||
// callAs is call's underlying primitive: it accepts an explicit
|
||||
// username/password/URL rather than always using this Client's own
|
||||
// credentials and the management endpoint. MailboxSnapshot uses this to
|
||||
// call standard JMAP methods (not Stalwart's x: management objects) against
|
||||
// the URL a JMAP session document says to use, authenticated as an
|
||||
// impersonated identity rather than this Client's own.
|
||||
func (c *Client) callAs(ctx context.Context, username, password, url string, using []string, methodCalls []any) ([]methodResponse, error) {
|
||||
reqBody, err := json.Marshal(jmapRequest{Using: using, MethodCalls: methodCalls})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stalwartapi: encode request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.SetBasicAuth(username, password)
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stalwartapi: call %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("stalwartapi: %s returned %s: %s", url, resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var raw jmapRawResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("stalwartapi: decode response from %s: %w", url, err)
|
||||
}
|
||||
responses := make([]methodResponse, 0, len(raw.MethodResponses))
|
||||
for _, r := range raw.MethodResponses {
|
||||
var triple [3]json.RawMessage
|
||||
if err := json.Unmarshal(r, &triple); err != nil {
|
||||
return nil, fmt.Errorf("stalwartapi: parse method response envelope: %w", err)
|
||||
}
|
||||
var name, callID string
|
||||
if err := json.Unmarshal(triple[0], &name); err != nil {
|
||||
return nil, fmt.Errorf("stalwartapi: parse method response name: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(triple[2], &callID); err != nil {
|
||||
return nil, fmt.Errorf("stalwartapi: parse method response call id: %w", err)
|
||||
}
|
||||
responses = append(responses, methodResponse{Name: name, Args: triple[1], CallID: callID})
|
||||
}
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
// account is the subset of x:Account/get's response fields this tool needs,
|
||||
// confirmed against Stalwart's own docs/ref/object/account.md (whose
|
||||
// stalwart-cli example is `query Account --fields id,name,domainId,usedDiskQuota`).
|
||||
type account struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DomainID string `json:"domainId"`
|
||||
}
|
||||
|
||||
// AccountSnapshot enumerates every account on the instance via Stalwart's
|
||||
// management API - x:Account/query to list ids, then x:Account/get to fetch
|
||||
// their name/domainId - and returns the account count, set of domains in
|
||||
// use, and (via MailboxSnapshot, per account) every mailbox's message
|
||||
// count. This is what preflight's snapshot (ARCHITECTURE.md §4.1) and
|
||||
// validate's directory-integrity and content-integrity checks (§4.7)
|
||||
// compare before and after migration - the latter being the actual
|
||||
// no-data-loss guarantee.
|
||||
//
|
||||
// A per-account mailbox-count failure (most likely: this Client's Username
|
||||
// lacks the `impersonate` permission MailboxSnapshot depends on) does not
|
||||
// fail the whole snapshot - the account/domain enumeration above is already
|
||||
// useful on its own, and one account's failure shouldn't hide a working
|
||||
// result for every other account. Instead it's recorded in
|
||||
// Snapshot.MailboxErrors, keyed by account email, so callers can report
|
||||
// exactly what's missing rather than silently treating an unreachable
|
||||
// account's mailboxes as having zero messages.
|
||||
func (c *Client) AccountSnapshot(ctx context.Context) (*Snapshot, error) {
|
||||
queryResp, err := c.call(ctx, managementCapabilities, []any{
|
||||
[]any{"x:Account/query", map[string]any{"filter": map[string]any{}}, "q"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stalwartapi: Account/query: %w", err)
|
||||
}
|
||||
ids, err := accountQueryIDs(queryResp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return &Snapshot{}, nil
|
||||
}
|
||||
|
||||
getResp, err := c.call(ctx, managementCapabilities, []any{
|
||||
[]any{"x:Account/get", map[string]any{"ids": ids, "properties": []string{"id", "name", "domainId"}}, "g"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stalwartapi: Account/get: %w", err)
|
||||
}
|
||||
accounts, err := accountGetList(getResp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mailboxCounts := map[string][]MailboxCount{}
|
||||
mailboxErrors := map[string]string{}
|
||||
for _, a := range accounts {
|
||||
if a.Name == "" {
|
||||
continue // no login/email to impersonate against
|
||||
}
|
||||
counts, err := c.MailboxSnapshot(ctx, a.Name)
|
||||
if err != nil {
|
||||
mailboxErrors[a.Name] = err.Error()
|
||||
continue
|
||||
}
|
||||
mailboxCounts[a.Name] = counts
|
||||
}
|
||||
|
||||
domainSet := map[string]bool{}
|
||||
for _, a := range accounts {
|
||||
if a.DomainID != "" {
|
||||
domainSet[a.DomainID] = true
|
||||
}
|
||||
}
|
||||
domains := make([]string, 0, len(domainSet))
|
||||
for d := range domainSet {
|
||||
domains = append(domains, d)
|
||||
}
|
||||
sort.Strings(domains)
|
||||
|
||||
return &Snapshot{
|
||||
AccountCount: len(accounts),
|
||||
Domains: domains,
|
||||
MailboxCounts: mailboxCounts,
|
||||
MailboxErrors: mailboxErrors,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func accountQueryIDs(responses []methodResponse) ([]string, error) {
|
||||
if len(responses) == 0 {
|
||||
return nil, fmt.Errorf("stalwartapi: Account/query returned no method responses")
|
||||
}
|
||||
r := responses[0]
|
||||
if r.Name == "error" {
|
||||
return nil, fmt.Errorf("stalwartapi: Account/query error: %s", r.Args)
|
||||
}
|
||||
var result struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := json.Unmarshal(r.Args, &result); err != nil {
|
||||
return nil, fmt.Errorf("stalwartapi: parse Account/query response: %w", err)
|
||||
}
|
||||
return result.IDs, nil
|
||||
}
|
||||
|
||||
func accountGetList(responses []methodResponse) ([]account, error) {
|
||||
if len(responses) == 0 {
|
||||
return nil, fmt.Errorf("stalwartapi: Account/get returned no method responses")
|
||||
}
|
||||
r := responses[0]
|
||||
if r.Name == "error" {
|
||||
return nil, fmt.Errorf("stalwartapi: Account/get error: %s", r.Args)
|
||||
}
|
||||
var result struct {
|
||||
List []account `json:"list"`
|
||||
}
|
||||
if err := json.Unmarshal(r.Args, &result); err != nil {
|
||||
return nil, fmt.Errorf("stalwartapi: parse Account/get response: %w", err)
|
||||
}
|
||||
return result.List, nil
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package stalwartapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// jmapEnvelope mirrors the wire shape this package's call() parses: a
|
||||
// top-level {"methodResponses": [...]} object where each entry is a
|
||||
// [name, args, callId] triple (RFC 8620 §3.2).
|
||||
type jmapEnvelope struct {
|
||||
MethodResponses []any `json:"methodResponses"`
|
||||
}
|
||||
|
||||
// accountManagementAndMailboxServer builds a fake server that answers both
|
||||
// the x:Account/* management calls AccountSnapshot makes directly, and the
|
||||
// session-discovery + Mailbox/get calls it makes indirectly (per account)
|
||||
// via MailboxSnapshot. mailboxesFor maps an account email to the mailbox
|
||||
// list its Mailbox/get should return; an account absent from the map gets a
|
||||
// 403 on session discovery, simulating a missing `impersonate` grant.
|
||||
func accountManagementAndMailboxServer(t *testing.T, mailboxesFor map[string][]map[string]any) (*httptest.Server, *[]string) {
|
||||
t.Helper()
|
||||
var gotPaths []string
|
||||
var apiURL string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPaths = append(gotPaths, r.URL.Path)
|
||||
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
|
||||
user, _, _ := r.BasicAuth()
|
||||
target := strings.SplitN(user, "%", 2)[0]
|
||||
if _, ok := mailboxesFor[target]; !ok {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"apiUrl": apiURL,
|
||||
"primaryAccounts": map[string]string{jmapMailCapability: "mail-" + target},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
methodCalls := body["methodCalls"].([]any)
|
||||
first := methodCalls[0].([]any)
|
||||
methodName := first[0].(string)
|
||||
|
||||
switch methodName {
|
||||
case "x:Account/query":
|
||||
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
|
||||
[]any{"x:Account/query", map[string]any{"ids": []string{"a1", "a2"}}, "q"},
|
||||
}})
|
||||
case "x:Account/get":
|
||||
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
|
||||
[]any{"x:Account/get", map[string]any{"list": []map[string]any{
|
||||
{"id": "a1", "name": "[email protected]", "domainId": "example.com"},
|
||||
{"id": "a2", "name": "[email protected]", "domainId": "example.org"},
|
||||
}}, "g"},
|
||||
}})
|
||||
case "Mailbox/get":
|
||||
args := first[1].(map[string]any)
|
||||
accountID := args["accountId"].(string)
|
||||
target := strings.TrimPrefix(accountID, "mail-")
|
||||
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
|
||||
[]any{"Mailbox/get", map[string]any{"list": mailboxesFor[target]}, "m"},
|
||||
}})
|
||||
default:
|
||||
t.Errorf("unexpected method call: %s", methodName)
|
||||
}
|
||||
}))
|
||||
apiURL = srv.URL + "/api"
|
||||
return srv, &gotPaths
|
||||
}
|
||||
|
||||
func TestAccountSnapshotQueriesThenGets(t *testing.T) {
|
||||
srv, _ := accountManagementAndMailboxServer(t, map[string][]map[string]any{
|
||||
"[email protected]": {{"name": "Inbox", "totalEmails": 10}},
|
||||
"[email protected]": {{"name": "Inbox", "totalEmails": 3}, {"name": "Archive", "totalEmails": 100}},
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
|
||||
snap, err := client.AccountSnapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("AccountSnapshot: %v", err)
|
||||
}
|
||||
if snap.AccountCount != 2 {
|
||||
t.Errorf("AccountCount = %d, want 2", snap.AccountCount)
|
||||
}
|
||||
if len(snap.Domains) != 2 || snap.Domains[0] != "example.com" || snap.Domains[1] != "example.org" {
|
||||
t.Errorf("Domains = %v, want [example.com example.org] (sorted)", snap.Domains)
|
||||
}
|
||||
if len(snap.MailboxErrors) != 0 {
|
||||
t.Errorf("MailboxErrors = %v, want none (both accounts should succeed)", snap.MailboxErrors)
|
||||
}
|
||||
alice := snap.MailboxCounts["[email protected]"]
|
||||
if len(alice) != 1 || alice[0].Mailbox != "Inbox" || alice[0].Messages != 10 {
|
||||
t.Errorf("alice's mailboxes = %+v, want [{Inbox 10}]", alice)
|
||||
}
|
||||
bob := snap.MailboxCounts["[email protected]"]
|
||||
if len(bob) != 2 || bob[1].Mailbox != "Archive" || bob[1].Messages != 100 {
|
||||
t.Errorf("bob's mailboxes = %+v, want Inbox then Archive(100)", bob)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSnapshotRecordsPerAccountMailboxFailureWithoutFailingOverall(t *testing.T) {
|
||||
// [email protected] is deliberately absent from mailboxesFor, simulating
|
||||
// a missing `impersonate` grant for that one account.
|
||||
srv, _ := accountManagementAndMailboxServer(t, map[string][]map[string]any{
|
||||
"[email protected]": {{"name": "Inbox", "totalEmails": 10}},
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
|
||||
snap, err := client.AccountSnapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("AccountSnapshot should not fail overall just because one account's mailbox capture failed: %v", err)
|
||||
}
|
||||
if snap.AccountCount != 2 {
|
||||
t.Errorf("AccountCount = %d, want 2 (account enumeration is unaffected by the mailbox-capture failure)", snap.AccountCount)
|
||||
}
|
||||
if _, ok := snap.MailboxCounts["[email protected]"]; !ok {
|
||||
t.Error("alice's mailbox counts should still be captured")
|
||||
}
|
||||
if _, ok := snap.MailboxCounts["[email protected]"]; ok {
|
||||
t.Error("bob's mailbox counts should NOT be present - his capture failed")
|
||||
}
|
||||
if _, ok := snap.MailboxErrors["[email protected]"]; !ok {
|
||||
t.Error("bob's failure should be recorded in MailboxErrors, not silently dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSnapshotEmptyInstance(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
|
||||
[]any{"x:Account/query", map[string]any{"ids": []string{}}, "q"},
|
||||
}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
|
||||
snap, err := client.AccountSnapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("AccountSnapshot: %v", err)
|
||||
}
|
||||
if snap.AccountCount != 0 {
|
||||
t.Errorf("AccountCount = %d, want 0", snap.AccountCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSnapshotPropagatesJMAPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
|
||||
[]any{"error", map[string]any{"type": "forbidden"}, "q"},
|
||||
}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
|
||||
_, err := client.AccountSnapshot(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("AccountSnapshot should surface a JMAP-level error response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSnapshotPropagatesHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("invalid credentials"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "wrong"}
|
||||
_, err := client.AccountSnapshot(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("AccountSnapshot should error on a non-200 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSnapshotSendsBasicAuth(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "admin" || pass != "hunter2" {
|
||||
t.Errorf("BasicAuth = (%s, %s, %v), want (admin, hunter2, true)", user, pass, ok)
|
||||
}
|
||||
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
|
||||
[]any{"x:Account/query", map[string]any{"ids": []string{}}, "q"},
|
||||
}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
|
||||
if _, err := client.AccountSnapshot(context.Background()); err != nil {
|
||||
t.Fatalf("AccountSnapshot: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user