ultisuite-backend/internal/mail/stalwart/client.go
R3D347HR4Y 7143a36c19
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(mail): integrate Stalwart hosted mail and migration features
- Added configuration options for Stalwart hosted mail in .env.example.
- Updated Docker Compose to include Stalwart service with health checks.
- Introduced new API endpoints for managing mail domains and migration projects.
- Enhanced Authentik blueprints for user enrollment and post-migration security.
- Updated OAuth handling for Google and Microsoft migration processes.
- Improved error handling and response structures in the mail API.
- Added integration tests for email claiming and migration workflows.
2026-06-13 12:47:08 +02:00

234 lines
5.5 KiB
Go

package stalwart
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
var (
ErrDisabled = errors.New("stalwart client disabled")
ErrNotFound = errors.New("stalwart resource not found")
)
type Config struct {
Enabled bool
BaseURL string
APIKey string
IMAPHost string
IMAPPort int
IMAPTLS bool
SMTPHost string
SMTPPort int
SMTPTLS bool
HTTPClient *http.Client
}
type Client struct {
cfg Config
}
func NewClient(cfg Config) *Client {
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: 30 * time.Second}
}
if cfg.IMAPPort == 0 {
cfg.IMAPPort = 993
}
if cfg.SMTPPort == 0 {
cfg.SMTPPort = 587
}
return &Client{cfg: cfg}
}
func (c *Client) Enabled() bool {
return c != nil && c.cfg.Enabled && strings.TrimSpace(c.cfg.BaseURL) != ""
}
func (c *Client) IMAPEndpoint() (host string, port int, tls bool) {
return c.cfg.IMAPHost, c.cfg.IMAPPort, c.cfg.IMAPTLS
}
func (c *Client) SMTPEndpoint() (host string, port int, tls bool) {
return c.cfg.SMTPHost, c.cfg.SMTPPort, c.cfg.SMTPTLS
}
type Domain struct {
ID string
Name string
}
type Account struct {
ID string
Email string
}
type jmapRequest struct {
Using []string `json:"using"`
MethodCalls []any `json:"methodCalls"`
}
type jmapResponse struct {
MethodResponses []json.RawMessage `json:"methodResponses"`
}
func (c *Client) call(ctx context.Context, method string, args any, callID string) (json.RawMessage, error) {
if !c.Enabled() {
return nil, ErrDisabled
}
body, err := json.Marshal(jmapRequest{
Using: []string{"urn:ietf:params:jmap:core", "urn:stalwart:jmap"},
MethodCalls: []any{[]any{method, args, callID}},
})
if err != nil {
return nil, err
}
url := strings.TrimRight(c.cfg.BaseURL, "/") + "/api"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if c.cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
}
resp, err := c.cfg.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("stalwart api %s: %s", resp.Status, strings.TrimSpace(string(raw)))
}
var envelope jmapResponse
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, err
}
if len(envelope.MethodResponses) == 0 {
return nil, fmt.Errorf("stalwart: empty method response")
}
var parts []json.RawMessage
if err := json.Unmarshal(envelope.MethodResponses[0], &parts); err != nil {
return nil, err
}
if len(parts) < 2 {
return nil, fmt.Errorf("stalwart: malformed method response")
}
var status string
if err := json.Unmarshal(parts[0], &status); err != nil {
return nil, err
}
if status == "error" {
return nil, fmt.Errorf("stalwart error: %s", string(parts[1]))
}
return parts[1], nil
}
func (c *Client) CreateDomain(ctx context.Context, name string) (Domain, error) {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return Domain{}, fmt.Errorf("domain name required")
}
if !c.Enabled() {
return Domain{ID: "local-" + name, Name: name}, nil
}
raw, err := c.call(ctx, "x:Domain/set", map[string]any{
"create": map[string]any{
"d1": map[string]any{"name": name},
},
}, "c1")
if err != nil {
return Domain{}, err
}
var parsed struct {
Created map[string]struct {
ID string `json:"id"`
} `json:"created"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return Domain{}, err
}
for _, v := range parsed.Created {
return Domain{ID: v.ID, Name: name}, nil
}
return Domain{}, fmt.Errorf("stalwart: domain not created")
}
func (c *Client) CreateAccount(ctx context.Context, domainID, localPart, password string, quotaBytes int64) (Account, error) {
localPart = strings.ToLower(strings.TrimSpace(localPart))
if localPart == "" {
return Account{}, fmt.Errorf("local part required")
}
if !c.Enabled() {
return Account{ID: "local-" + localPart, Email: localPart + "@local"}, nil
}
fields := map[string]any{
"name": localPart,
"domainId": domainID,
}
if password != "" {
fields["credentials"] = map[string]any{"password": password}
}
if quotaBytes > 0 {
fields["quota"] = map[string]any{"maxDiskQuota": quotaBytes}
}
raw, err := c.call(ctx, "x:Account/set", map[string]any{
"create": map[string]any{"a1": fields},
}, "c1")
if err != nil {
return Account{}, err
}
var parsed struct {
Created map[string]struct {
ID string `json:"id"`
} `json:"created"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return Account{}, err
}
for _, v := range parsed.Created {
return Account{ID: v.ID, Email: localPart}, nil
}
return Account{}, fmt.Errorf("stalwart: account not created")
}
func (c *Client) SetAccountPassword(ctx context.Context, accountID, password string) error {
if password == "" {
return fmt.Errorf("password required")
}
if !c.Enabled() {
return nil
}
_, err := c.call(ctx, "x:Account/set", map[string]any{
"update": map[string]any{
accountID: map[string]any{
"credentials": map[string]any{"password": password},
},
},
}, "c1")
return err
}
func (c *Client) DeleteAccount(ctx context.Context, accountID string) error {
if accountID == "" {
return nil
}
if !c.Enabled() {
return nil
}
_, err := c.call(ctx, "x:Account/set", map[string]any{
"destroy": []string{accountID},
}, "c1")
return err
}