- 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.
203 lines
6.2 KiB
Go
203 lines
6.2 KiB
Go
package mail
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
|
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
|
"github.com/ultisuite/ulti-backend/internal/mail/hosted"
|
|
)
|
|
|
|
func (h *Handler) SetHostedService(svc *hosted.Service) {
|
|
if s, ok := h.svc.(*Service); ok {
|
|
s.SetHostedService(svc)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) SetHostedPlatformDomain(domain string) {
|
|
h.hostedPlatformDomain = strings.ToLower(strings.TrimSpace(domain))
|
|
}
|
|
|
|
func (h *Handler) CheckAddressAvailability(w http.ResponseWriter, r *http.Request) {
|
|
svc := h.hostedService()
|
|
if svc == nil {
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"available": true, "reason": "hosted_mail_disabled"})
|
|
return
|
|
}
|
|
local := strings.TrimSpace(r.URL.Query().Get("local"))
|
|
domain := strings.TrimSpace(r.URL.Query().Get("domain"))
|
|
if domain == "" {
|
|
domain = strings.TrimSpace(r.URL.Query().Get("domain_name"))
|
|
}
|
|
if domain == "" {
|
|
domain = h.hostedPlatformDomain
|
|
}
|
|
if local == "" || domain == "" {
|
|
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
|
|
Field: "local", Message: "local and domain required",
|
|
}))
|
|
return
|
|
}
|
|
available, err := svc.IsAddressAvailable(r.Context(), domain, local)
|
|
if err != nil {
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"available": false, "reason": err.Error()})
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"available": available})
|
|
}
|
|
|
|
func (h *Handler) GetHostedMailStatus(w http.ResponseWriter, r *http.Request) {
|
|
svc := h.hostedService()
|
|
if svc == nil || !svc.Available() {
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"enabled": false})
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
domainName := h.hostedPlatformDomain
|
|
domainStatus := ""
|
|
if domain, err := svc.GetPlatformDomain(ctx); err == nil {
|
|
domainName = domain.Name
|
|
domainStatus = domain.Status
|
|
}
|
|
if domainName == "" {
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"enabled": false})
|
|
return
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"enabled": true,
|
|
"platform_domain": domainName,
|
|
"domain_status": domainStatus,
|
|
"endpoints": svc.Endpoints(),
|
|
}
|
|
|
|
claims := middleware.ClaimsFromContext(ctx)
|
|
if claims != nil {
|
|
userID, err := h.svc.ResolveUserID(ctx, claims.Sub)
|
|
if err == nil {
|
|
if mailbox, err := svc.GetUserMailbox(ctx, userID); err == nil {
|
|
resp["mailbox"] = mailbox
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
h.logger.Error("hosted mailbox lookup", "error", err)
|
|
}
|
|
accounts, listErr := h.svc.ListAccounts(ctx, claims.Sub, query.ListParams{})
|
|
if listErr == nil {
|
|
for _, acct := range accounts.Accounts {
|
|
provider, _ := acct["provider"].(string)
|
|
if provider == "hosted" {
|
|
resp["hosted_mail_account_id"] = acct["id"]
|
|
resp["hosted_mail_account_email"] = acct["email"]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
apiresponse.WriteJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
type setupHostedMailboxRequest struct {
|
|
LocalPart string `json:"local_part"`
|
|
Password string `json:"password"`
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
|
|
func (h *Handler) SetupHostedMailbox(w http.ResponseWriter, r *http.Request) {
|
|
svc := h.hostedService()
|
|
if svc == nil || !svc.Available() {
|
|
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "hosted_mail_disabled", "hosted mail is not configured", nil)
|
|
return
|
|
}
|
|
|
|
var req setupHostedMailboxRequest
|
|
if err := apivalidate.DecodeJSON(w, r, 16*1024, &req); err != nil {
|
|
return
|
|
}
|
|
localPart := strings.TrimSpace(req.LocalPart)
|
|
password := req.Password
|
|
if localPart == "" {
|
|
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
|
|
apivalidate.FieldDetail{Field: "local_part", Message: "local part required"},
|
|
))
|
|
return
|
|
}
|
|
if len(password) < 8 {
|
|
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
|
|
apivalidate.FieldDetail{Field: "password", Message: "password must be at least 8 characters"},
|
|
))
|
|
return
|
|
}
|
|
|
|
domainName := h.hostedPlatformDomain
|
|
if domain, err := svc.GetPlatformDomain(r.Context()); err == nil {
|
|
domainName = domain.Name
|
|
}
|
|
if domainName == "" {
|
|
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "hosted_mail_disabled", "platform mail domain not configured", nil)
|
|
return
|
|
}
|
|
|
|
claims := middleware.ClaimsFromContext(r.Context())
|
|
userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub)
|
|
if err != nil {
|
|
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "user not provisioned", nil)
|
|
return
|
|
}
|
|
|
|
displayName := strings.TrimSpace(req.DisplayName)
|
|
if displayName == "" {
|
|
displayName = localPart
|
|
}
|
|
email := strings.ToLower(localPart + "@" + domainName)
|
|
|
|
result, err := svc.EnsureMailboxProvisioned(r.Context(), hosted.ProvisionMailboxInput{
|
|
UserID: userID,
|
|
Email: email,
|
|
DisplayName: displayName,
|
|
Password: password,
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, hosted.ErrAddressTaken):
|
|
apiresponse.WriteError(w, r, http.StatusConflict, "address_taken", err.Error(), nil)
|
|
case errors.Is(err, hosted.ErrInvalidLocalPart):
|
|
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
|
|
apivalidate.FieldDetail{Field: "local_part", Message: err.Error()},
|
|
))
|
|
case errors.Is(err, hosted.ErrDomainNotActive):
|
|
apiresponse.WriteError(w, r, http.StatusConflict, "domain_not_active", err.Error(), nil)
|
|
default:
|
|
h.logger.Error("setup hosted mailbox", "error", err, "email", email)
|
|
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to provision mailbox", nil)
|
|
}
|
|
return
|
|
}
|
|
|
|
if h.accountSync != nil && result.MailAccountID != "" {
|
|
if syncErr := h.accountSync.SyncAccountForUser(r.Context(), claims.Sub, result.MailAccountID); syncErr != nil {
|
|
h.logger.Warn("hosted mailbox initial sync", "error", syncErr, "account_id", result.MailAccountID)
|
|
}
|
|
}
|
|
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
|
|
"mailbox": result.Mailbox,
|
|
"mail_account_id": result.MailAccountID,
|
|
"email": email,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) hostedService() *hosted.Service {
|
|
if s, ok := h.svc.(*Service); ok {
|
|
return s.HostedService()
|
|
}
|
|
return nil
|
|
}
|