Files
stalwart-migrator/internal/stalwartapi/mailbox.go
T
jcoffey-dev 719a945d64 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.
2026-08-22 18:17:17 -07:00

113 lines
4.5 KiB
Go

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
}