ultisuite-backend/internal/mail/hosted/service.go
R3D347HR4Y bda75aeb0d
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(config): enhance AI gateway and model management features
- Updated .env.example to include new configuration options for AI gateway and WebUI secret key.
- Modified Nginx configuration to support additional API routes for model management and migration.
- Implemented new API endpoints for discovering organization-level LLM models and managing hosted mail services.
- Enhanced AI gateway logic to support organization-specific model access and permissions.
- Improved error handling and response structures in the AI and mail APIs.
- Added integration tests for new features and updated existing tests for model access control.
2026-06-13 20:38:26 +02:00

616 lines
20 KiB
Go

package hosted
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
"github.com/ultisuite/ulti-backend/internal/mail/stalwart"
)
var (
ErrAddressTaken = errors.New("mail address already taken")
ErrInvalidLocalPart = errors.New("invalid local part")
ErrDomainNotActive = errors.New("mail domain not active")
)
type Service struct {
db *pgxpool.Pool
stlw *stalwart.Client
creds *credentials.Manager
imapHost string
imapPort int
imapTLS bool
smtpHost string
smtpPort int
smtpTLS bool
}
func NewService(db *pgxpool.Pool, stlw *stalwart.Client, creds *credentials.Manager) *Service {
s := &Service{db: db, stlw: stlw, creds: creds}
if stlw != nil {
s.imapHost, s.imapPort, s.imapTLS = stlw.IMAPEndpoint()
s.smtpHost, s.smtpPort, s.smtpTLS = stlw.SMTPEndpoint()
}
return s
}
type DomainRow struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
VerificationToken string `json:"verification_token,omitempty"`
DKIMSelector string `json:"dkim_selector,omitempty"`
DKIMPublicKey string `json:"dkim_public_key,omitempty"`
StalwartDomainID string `json:"stalwart_domain_id,omitempty"`
IsPlatformDomain bool `json:"is_platform_domain"`
MXVerifiedAt *string `json:"mx_verified_at,omitempty"`
TXTVerifiedAt *string `json:"txt_verified_at,omitempty"`
CreatedAt string `json:"created_at"`
}
type MailboxRow struct {
ID string `json:"id"`
DomainID string `json:"domain_id"`
LocalPart string `json:"local_part"`
Email string `json:"email"`
UserID string `json:"user_id,omitempty"`
MailAccountID string `json:"mail_account_id,omitempty"`
StalwartAccountID string `json:"stalwart_account_id,omitempty"`
QuotaBytes int64 `json:"quota_bytes"`
Status string `json:"status"`
}
func normalizeLocalPart(v string) (string, error) {
v = strings.ToLower(strings.TrimSpace(v))
if v == "" || len(v) > 64 {
return "", ErrInvalidLocalPart
}
for _, ch := range v {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '.' || ch == '-' || ch == '_' || ch == '+' {
continue
}
return "", ErrInvalidLocalPart
}
return v, nil
}
type EndpointInfo struct {
IMAPHost string `json:"imap_host"`
IMAPPort int `json:"imap_port"`
IMAPTLS bool `json:"imap_tls"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
SMTPTLS bool `json:"smtp_tls"`
}
func (s *Service) Available() bool {
return s != nil && s.stlw != nil && s.stlw.Enabled()
}
func (s *Service) Endpoints() EndpointInfo {
return EndpointInfo{
IMAPHost: s.imapHost,
IMAPPort: s.imapPort,
IMAPTLS: s.imapTLS,
SMTPHost: s.smtpHost,
SMTPPort: s.smtpPort,
SMTPTLS: s.smtpTLS,
}
}
func (s *Service) GetPlatformDomain(ctx context.Context) (DomainRow, error) {
var row DomainRow
err := s.db.QueryRow(ctx, `
SELECT id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
FROM mail_domains
WHERE is_platform_domain = true
ORDER BY created_at ASC
LIMIT 1
`).Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
)
if err != nil {
return DomainRow{}, err
}
return row, nil
}
func (s *Service) GetUserMailbox(ctx context.Context, userID string) (MailboxRow, error) {
userID = strings.TrimSpace(userID)
if userID == "" {
return MailboxRow{}, fmt.Errorf("user id required")
}
var row MailboxRow
err := s.db.QueryRow(ctx, `
SELECT mb.id::text, mb.domain_id::text, mb.local_part,
lower(mb.local_part || '@' || md.name),
COALESCE(mb.user_id::text, ''),
COALESCE(mb.mail_account_id::text, ''),
mb.stalwart_account_id, mb.quota_bytes, mb.status
FROM mailboxes mb
JOIN mail_domains md ON md.id = mb.domain_id
WHERE mb.user_id = $1::uuid
ORDER BY mb.created_at ASC
LIMIT 1
`, userID).Scan(
&row.ID, &row.DomainID, &row.LocalPart, &row.Email,
&row.UserID, &row.MailAccountID, &row.StalwartAccountID, &row.QuotaBytes, &row.Status,
)
return row, err
}
func (s *Service) IsAddressAvailable(ctx context.Context, domainName, localPart string) (bool, error) {
localPart, err := normalizeLocalPart(localPart)
if err != nil {
return false, err
}
domainName = strings.ToLower(strings.TrimSpace(domainName))
var exists bool
err = s.db.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM mailboxes m
JOIN mail_domains d ON d.id = m.domain_id
WHERE d.name = $1 AND m.local_part = $2
)
`, domainName, localPart).Scan(&exists)
return !exists, err
}
func (s *Service) EnsurePlatformDomain(ctx context.Context, name string) (DomainRow, error) {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return DomainRow{}, fmt.Errorf("platform domain name required")
}
var row DomainRow
err := s.db.QueryRow(ctx, `
SELECT id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
FROM mail_domains WHERE name = $1
`, name).Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
)
if err == nil {
return row, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return DomainRow{}, err
}
token, err := randomToken(16)
if err != nil {
return DomainRow{}, err
}
stlwID := ""
if s.stlw != nil && s.stlw.Enabled() {
d, err := s.stlw.CreateDomain(ctx, name)
if err != nil {
return DomainRow{}, fmt.Errorf("stalwart create domain: %w", err)
}
stlwID = d.ID
}
status := "active"
if !strings.HasSuffix(name, ".local") {
status = "pending_verification"
}
err = s.db.QueryRow(ctx, `
INSERT INTO mail_domains (name, status, verification_token, stalwart_domain_id, is_platform_domain, txt_verified_at)
VALUES ($1, $2, $3, $4, true, CASE WHEN $2 = 'active' THEN NOW() ELSE NULL END)
RETURNING id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
`, name, status, token, stlwID).Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
)
return row, err
}
func (s *Service) CreateDomain(ctx context.Context, name string, platform bool) (DomainRow, error) {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return DomainRow{}, fmt.Errorf("domain name required")
}
token, err := randomToken(16)
if err != nil {
return DomainRow{}, err
}
stlwID := ""
if s.stlw != nil && s.stlw.Enabled() {
d, err := s.stlw.CreateDomain(ctx, name)
if err != nil {
return DomainRow{}, fmt.Errorf("stalwart create domain: %w", err)
}
stlwID = d.ID
}
var row DomainRow
err = s.db.QueryRow(ctx, `
INSERT INTO mail_domains (name, status, verification_token, stalwart_domain_id, is_platform_domain)
VALUES ($1, 'pending_verification', $2, $3, $4)
RETURNING id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
`, name, token, stlwID, platform).Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
)
return row, err
}
func (s *Service) ListDomains(ctx context.Context) ([]DomainRow, error) {
rows, err := s.db.Query(ctx, `
SELECT id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
FROM mail_domains ORDER BY is_platform_domain DESC, name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DomainRow
for rows.Next() {
var row DomainRow
if err := rows.Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
func (s *Service) GetDomain(ctx context.Context, domainID string) (DomainRow, error) {
var row DomainRow
err := s.db.QueryRow(ctx, `
SELECT id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
FROM mail_domains WHERE id = $1
`, domainID).Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
)
return row, err
}
func (s *Service) MarkDomainVerified(ctx context.Context, domainID string) (DomainRow, error) {
var row DomainRow
err := s.db.QueryRow(ctx, `
UPDATE mail_domains
SET status = 'active', txt_verified_at = COALESCE(txt_verified_at, NOW()), updated_at = NOW()
WHERE id = $1
RETURNING id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
`, domainID).Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
)
return row, err
}
func (s *Service) MarkDomainMXVerified(ctx context.Context, domainID string) (DomainRow, error) {
var row DomainRow
err := s.db.QueryRow(ctx, `
UPDATE mail_domains
SET mx_verified_at = NOW(), status = 'active', updated_at = NOW()
WHERE id = $1
RETURNING id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
`, domainID).Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
)
return row, err
}
type ProvisionMailboxInput struct {
UserID string
Email string
DisplayName string
Password string
QuotaBytes int64
DomainID string
}
type ProvisionMailboxResult struct {
Mailbox MailboxRow
MailAccountID string
}
// EnsureMailboxProvisioned creates a mailbox or links an existing one to the requested user.
func (s *Service) EnsureMailboxProvisioned(ctx context.Context, in ProvisionMailboxInput) (ProvisionMailboxResult, error) {
email := strings.ToLower(strings.TrimSpace(in.Email))
existing, err := s.lookupMailboxByEmail(ctx, email)
if errors.Is(err, pgx.ErrNoRows) {
return s.ProvisionMailbox(ctx, in)
}
if err != nil {
return ProvisionMailboxResult{}, err
}
return s.reconcileExistingMailbox(ctx, existing, in)
}
func (s *Service) lookupMailboxByEmail(ctx context.Context, email string) (MailboxRow, error) {
email = strings.ToLower(strings.TrimSpace(email))
at := strings.LastIndex(email, "@")
if at <= 0 {
return MailboxRow{}, fmt.Errorf("invalid email")
}
localPart := email[:at]
domainName := email[at+1:]
localPart, err := normalizeLocalPart(localPart)
if err != nil {
return MailboxRow{}, err
}
var row MailboxRow
err = s.db.QueryRow(ctx, `
SELECT mb.id::text, mb.domain_id::text, mb.local_part,
lower(mb.local_part || '@' || md.name),
COALESCE(mb.user_id::text, ''),
COALESCE(mb.mail_account_id::text, ''),
mb.stalwart_account_id, mb.quota_bytes, mb.status
FROM mailboxes mb
JOIN mail_domains md ON md.id = mb.domain_id
WHERE md.name = $1 AND mb.local_part = $2
`, domainName, localPart).Scan(
&row.ID, &row.DomainID, &row.LocalPart, &row.Email,
&row.UserID, &row.MailAccountID, &row.StalwartAccountID, &row.QuotaBytes, &row.Status,
)
return row, err
}
func (s *Service) reconcileExistingMailbox(ctx context.Context, existing MailboxRow, in ProvisionMailboxInput) (ProvisionMailboxResult, error) {
userID := strings.TrimSpace(in.UserID)
if userID != "" && existing.UserID != "" && existing.UserID != userID {
return ProvisionMailboxResult{}, ErrAddressTaken
}
if userID == "" {
userID = existing.UserID
}
mailAccountID := existing.MailAccountID
if userID != "" && existing.UserID != userID {
if err := s.LinkMailboxToUser(ctx, existing.ID, userID); err != nil {
return ProvisionMailboxResult{}, err
}
existing.UserID = userID
}
if userID != "" && mailAccountID == "" && strings.TrimSpace(in.Password) != "" {
email := strings.ToLower(strings.TrimSpace(in.Email))
err := s.db.QueryRow(ctx, `
SELECT id::text FROM mail_accounts
WHERE user_id = $1::uuid AND lower(email) = $2
LIMIT 1
`, userID, email).Scan(&mailAccountID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return ProvisionMailboxResult{}, err
}
if errors.Is(err, pgx.ErrNoRows) {
enc, err := s.encryptHostedCredential(email, in.Password)
if err != nil {
return ProvisionMailboxResult{}, err
}
displayName := strings.TrimSpace(in.DisplayName)
if displayName == "" {
displayName = email
}
err = s.db.QueryRow(ctx, `
INSERT INTO mail_accounts (
user_id, name, email, provider,
imap_host, imap_port, imap_tls,
smtp_host, smtp_port, smtp_tls,
credentials, is_active
)
VALUES ($1, $2, $3, 'hosted', $4, $5, $6, $7, $8, $9, $10, true)
RETURNING id::text
`, userID, displayName, email,
s.imapHost, s.imapPort, s.imapTLS,
s.smtpHost, s.smtpPort, s.smtpTLS,
enc,
).Scan(&mailAccountID)
if err != nil {
return ProvisionMailboxResult{}, err
}
}
_, err = s.db.Exec(ctx, `
UPDATE mailboxes SET mail_account_id = $1::uuid, updated_at = NOW()
WHERE id = $2::uuid AND (mail_account_id IS NULL OR mail_account_id = $1::uuid)
`, mailAccountID, existing.ID)
if err != nil {
return ProvisionMailboxResult{}, err
}
existing.MailAccountID = mailAccountID
}
return ProvisionMailboxResult{
Mailbox: existing,
MailAccountID: mailAccountID,
}, nil
}
func (s *Service) ProvisionMailbox(ctx context.Context, in ProvisionMailboxInput) (ProvisionMailboxResult, error) {
email := strings.ToLower(strings.TrimSpace(in.Email))
at := strings.LastIndex(email, "@")
if at <= 0 {
return ProvisionMailboxResult{}, fmt.Errorf("invalid email")
}
localPart := email[:at]
domainName := email[at+1:]
localPart, err := normalizeLocalPart(localPart)
if err != nil {
return ProvisionMailboxResult{}, err
}
var domain DomainRow
if strings.TrimSpace(in.DomainID) != "" {
domain, err = s.GetDomain(ctx, in.DomainID)
if err != nil {
return ProvisionMailboxResult{}, err
}
if !strings.EqualFold(domain.Name, domainName) {
return ProvisionMailboxResult{}, fmt.Errorf("email domain %q does not match project domain %q", domainName, domain.Name)
}
} else {
err = s.db.QueryRow(ctx, `
SELECT id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
FROM mail_domains WHERE name = $1
`, domainName).Scan(
&domain.ID, &domain.Name, &domain.Status, &domain.VerificationToken, &domain.DKIMSelector, &domain.DKIMPublicKey,
&domain.StalwartDomainID, &domain.IsPlatformDomain, &domain.MXVerifiedAt, &domain.TXTVerifiedAt, &domain.CreatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
domain, err = s.EnsurePlatformDomain(ctx, domainName)
if err != nil {
return ProvisionMailboxResult{}, err
}
} else {
return ProvisionMailboxResult{}, err
}
}
}
if domain.Status != "active" && !domain.IsPlatformDomain {
return ProvisionMailboxResult{}, ErrDomainNotActive
}
available, err := s.IsAddressAvailable(ctx, domainName, localPart)
if err != nil {
return ProvisionMailboxResult{}, err
}
if !available {
return ProvisionMailboxResult{}, ErrAddressTaken
}
quota := in.QuotaBytes
if quota <= 0 {
quota = 5 * 1024 * 1024 * 1024
}
stlwAccountID := ""
if s.stlw != nil {
acct, err := s.stlw.CreateAccount(ctx, domain.StalwartDomainID, localPart, in.Password, quota)
if err != nil && !errors.Is(err, stalwart.ErrDisabled) {
return ProvisionMailboxResult{}, fmt.Errorf("stalwart account: %w", err)
}
stlwAccountID = acct.ID
}
tx, err := s.db.Begin(ctx)
if err != nil {
return ProvisionMailboxResult{}, err
}
defer tx.Rollback(ctx)
var mailboxID string
err = tx.QueryRow(ctx, `
INSERT INTO mailboxes (domain_id, local_part, user_id, stalwart_account_id, quota_bytes, status)
VALUES ($1, $2, NULLIF($3, '')::uuid, $4, $5, 'active')
RETURNING id::text
`, domain.ID, localPart, in.UserID, stlwAccountID, quota).Scan(&mailboxID)
if err != nil {
return ProvisionMailboxResult{}, err
}
var mailAccountID string
if in.UserID != "" {
enc, err := s.encryptHostedCredential(email, in.Password)
if err != nil {
return ProvisionMailboxResult{}, err
}
err = tx.QueryRow(ctx, `
INSERT INTO mail_accounts (
user_id, name, email, provider,
imap_host, imap_port, imap_tls,
smtp_host, smtp_port, smtp_tls,
credentials, is_active
)
VALUES ($1, $2, $3, 'hosted', $4, $5, $6, $7, $8, $9, $10, true)
RETURNING id::text
`, in.UserID, in.DisplayName, email,
s.imapHost, s.imapPort, s.imapTLS,
s.smtpHost, s.smtpPort, s.smtpTLS,
enc,
).Scan(&mailAccountID)
if err != nil {
return ProvisionMailboxResult{}, err
}
_, err = tx.Exec(ctx, `
UPDATE mailboxes SET user_id = $1::uuid, mail_account_id = $2::uuid, updated_at = NOW()
WHERE id = $3::uuid
`, in.UserID, mailAccountID, mailboxID)
if err != nil {
return ProvisionMailboxResult{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return ProvisionMailboxResult{}, err
}
return ProvisionMailboxResult{
Mailbox: MailboxRow{
ID: mailboxID,
DomainID: domain.ID,
LocalPart: localPart,
Email: email,
UserID: in.UserID,
MailAccountID: mailAccountID,
StalwartAccountID: stlwAccountID,
QuotaBytes: quota,
Status: "active",
},
MailAccountID: mailAccountID,
}, nil
}
func (s *Service) encryptHostedCredential(email, password string) ([]byte, error) {
if s.creds == nil {
return nil, fmt.Errorf("credential manager not configured")
}
return s.creds.EncryptCredential(credentials.Credential{
AuthType: credentials.AuthPassword,
Username: email,
Password: password,
})
}
func (s *Service) LinkMailboxToUser(ctx context.Context, mailboxID, userID string) error {
_, err := s.db.Exec(ctx, `
UPDATE mailboxes SET user_id = $1::uuid, updated_at = NOW()
WHERE id = $2::uuid AND (user_id IS NULL OR user_id = $1::uuid)
`, userID, mailboxID)
return err
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func NewInviteToken() (string, error) {
return uuid.NewString(), nil
}