package mail import ( "errors" "log/slog" "net/http" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/api/apiresponse" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/mail/sendguard" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/mail/credentials" "github.com/ultisuite/ulti-backend/internal/mail/limits" mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth" "github.com/ultisuite/ulti-backend/internal/mail/storage" "github.com/ultisuite/ulti-backend/internal/securityaudit" ) type Handler struct { svc ServiceAPI mailSender MailSender logger *slog.Logger sendLimiter *sendguard.RateLimiter oauth *mailoauth.Service appURL string accountSync AccountSyncTrigger } // SetAccountSync wires the IMAP sync worker for on-demand account sync. func (h *Handler) SetAccountSync(trigger AccountSyncTrigger) { h.accountSync = trigger } // SetDriveUploader wires Nextcloud export for mail attachments. func (h *Handler) SetDriveUploader(uploader DriveUploader) { if s, ok := h.svc.(*Service); ok { s.SetDriveUploader(uploader) } } func NewHandlerWithService(svc ServiceAPI) *Handler { return &Handler{ svc: svc, logger: slog.Default().With("component", "mail-api"), } } func NewHandler( db *pgxpool.Pool, audit *securityaudit.Logger, credentialManager *credentials.Manager, objectStorage *storage.Client, attachmentsBucket string, sendLimiter *sendguard.RateLimiter, oauthSvc *mailoauth.Service, appURL string, mailSender MailSender, ) *Handler { h := NewHandlerWithService(NewService(db, audit, credentialManager, objectStorage, attachmentsBucket)) h.mailSender = mailSender h.sendLimiter = sendLimiter h.oauth = oauthSvc h.appURL = appURL return h } func (h *Handler) Routes() chi.Router { r := chi.NewRouter() r.Get("/settings", h.GetMailSettings) r.Patch("/settings", h.UpdateMailSettings) r.Get("/unified-folders", h.ListUnifiedFolders) r.Post("/unified-folders/reorder", h.ReorderUnifiedFolders) r.Post("/unified-folders", h.CreateUnifiedFolder) r.Put("/unified-folders/{folderID}", h.UpdateUnifiedFolder) r.Delete("/unified-folders/{folderID}", h.DeleteUnifiedFolder) r.Get("/accounts", h.ListAccounts) r.Post("/accounts", h.CreateAccount) r.Get("/accounts/discover", h.DiscoverAccountConfig) r.Post("/accounts/test", h.TestAccountConnection) r.Post("/accounts/{accountID}/test", h.TestStoredAccountConnection) r.Get("/accounts/oauth/providers", h.ListOAuthProviders) r.Post("/accounts/oauth/start", h.StartOAuthAccount) r.Get("/accounts/{accountID}", h.GetAccount) r.Put("/accounts/{accountID}", h.UpdateAccount) r.Delete("/accounts/{accountID}", h.DeleteAccount) r.Post("/accounts/{accountID}/resanitize-bodies", h.ResanitizeAccountBodies) r.Post("/accounts/{accountID}/sync", h.SyncAccountNow) r.Get("/accounts/{accountID}/identities", h.ListIdentities) r.Post("/accounts/{accountID}/identities", h.CreateIdentity) r.Get("/identities/{identityID}", h.GetIdentity) r.Put("/identities/{identityID}", h.UpdateIdentity) r.Delete("/identities/{identityID}", h.DeleteIdentity) r.Get("/signatures", h.ListSignatures) r.Post("/signatures", h.CreateSignature) r.Get("/signatures/{signatureID}", h.GetSignature) r.Put("/signatures/{signatureID}", h.UpdateSignature) r.Delete("/signatures/{signatureID}", h.DeleteSignature) r.Mount("/", h.FolderLabelRoutes()) r.Get("/search", h.SearchMessages) r.Get("/drafts", h.ListDrafts) r.Post("/drafts", h.CreateDraft) r.Get("/drafts/{draftID}", h.GetDraft) r.Put("/drafts/{draftID}", h.UpdateDraft) r.Delete("/drafts/{draftID}", h.DeleteDraft) r.Post("/drafts/{draftID}/attachments", h.UploadDraftAttachment) r.Get("/drafts/{draftID}/attachments/{attachmentID}", h.DownloadDraftAttachment) r.Get("/drafts/{draftID}/attachments/{attachmentID}/inline", h.DownloadDraftAttachment) r.Get("/messages", h.ListMessages) r.Get("/messages/{messageID}/attachments", h.ListMessageAttachments) r.Get("/messages/{messageID}/attachments/cid-map", h.MessageAttachmentCIDMap) r.Post("/messages/{messageID}/attachments/reindex", h.ReindexMessageAttachments) r.Post("/messages/{messageID}/attachments", h.UploadMessageAttachment) r.Post("/messages/{messageID}/attachments/save-to-drive", h.SaveMessageAttachmentsToDrive) r.Post("/messages/{messageID}/attachments/{attachmentID}/save-to-drive", h.SaveAttachmentToDrive) r.Post("/messages/{messageID}/list-unsubscribe-mailto", h.SendListUnsubscribeMailto) r.Get("/messages/{messageID}", h.GetMessage) r.Put("/messages/{messageID}/labels", h.UpdateLabels) r.Put("/messages/{messageID}/flags", h.UpdateFlags) r.Delete("/messages/{messageID}", h.DeleteMessage) r.Get("/attachments/{attachmentID}", h.DownloadAttachment) r.Get("/attachments/{attachmentID}/inline", h.DownloadAttachment) r.Get("/threads/{threadID}", h.GetThread) r.Post("/send", h.SendMessage) r.Post("/outbox/{outboxID}/send-now", h.SendOutboxNow) r.Post("/outbox/{outboxID}/reschedule", h.RescheduleOutbox) r.Post("/outbox/{outboxID}/cancel", h.CancelScheduledOutbox) r.Get("/rules", h.ListRules) r.Post("/rules", h.CreateRule) r.Put("/rules/{ruleID}", h.UpdateRule) r.Delete("/rules/{ruleID}", h.DeleteRule) r.Post("/rules/simulate", h.SimulateRule) r.Get("/webhooks", h.ListWebhooks) r.Post("/webhooks", h.CreateWebhook) r.Post("/webhooks/preview", h.PreviewWebhookTemplate) r.Put("/webhooks/{webhookID}", h.UpdateWebhook) r.Delete("/webhooks/{webhookID}", h.DeleteWebhook) r.Get("/api-tokens", h.ListApiTokens) r.Post("/api-tokens", h.CreateApiToken) r.Delete("/api-tokens/{tokenID}", h.RevokeApiToken) return r } func (h *Handler) ListAccounts(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) params, err := query.ParseListRequest(r) if err != nil { apivalidate.WriteQueryError(w, r, err) return } result, err := h.svc.ListAccounts(r.Context(), claims.Sub, params) if err != nil { h.logger.Error("list accounts", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, result) } func (h *Handler) CreateAccount(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) var req createAccountRequest if err := apivalidate.DecodeJSON(w, r, maxAccountRequestBody, &req); err != nil { return } if verr := validateCreateAccount(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } id, err := h.svc.CreateAccount(r.Context(), claims.Sub, &req) if err != nil { if errors.Is(err, ErrCredentialsUnavailable) { apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "credentials encryption unavailable", nil) return } h.logger.Error("create account", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id}) } func (h *Handler) GetAccount(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) accountID := chi.URLParam(r, "accountID") if d := validateAccountUUID(accountID); d != nil { apivalidate.WriteNotFound(w, r, "not found") return } account, err := h.svc.GetAccount(r.Context(), claims.Sub, accountID) if err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("get account", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, account) } func (h *Handler) UpdateAccount(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) accountID := chi.URLParam(r, "accountID") if d := validateAccountUUID(accountID); d != nil { apivalidate.WriteNotFound(w, r, "not found") return } var req updateAccountRequest if err := apivalidate.DecodeJSON(w, r, maxAccountRequestBody, &req); err != nil { return } if verr := validateUpdateAccount(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } if err := h.svc.UpdateAccount(r.Context(), claims.Sub, accountID, &req); err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } if errors.Is(err, ErrUserNotProvisioned) { apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "user not provisioned", nil) return } if errors.Is(err, ErrCredentialsUnavailable) { apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "credentials encryption unavailable", nil) return } if errors.Is(err, ErrOAuthPasswordNotAllowed) { apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "oauth accounts cannot use password credentials", nil) return } if errors.Is(err, ErrInvalidAccountCredentials) { apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "username is required for password authentication", nil) return } h.logger.Error("update account", "error", err) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) DeleteAccount(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) accountID := chi.URLParam(r, "accountID") if d := validateAccountUUID(accountID); d != nil { apivalidate.WriteNotFound(w, r, "not found") return } if err := h.svc.DeleteAccount(r.Context(), claims.Sub, accountID); err != nil { if errors.Is(err, ErrUserNotProvisioned) { apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "user not provisioned", nil) return } if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("delete account", "error", err) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) params, err := query.ParseListRequest(r) if err != nil { apivalidate.WriteQueryError(w, r, err) return } filter := MessageListFilter{ Folder: r.URL.Query().Get("folder"), AccountID: r.URL.Query().Get("account_id"), } h.applyMailListScope(&filter, r) result, err := h.svc.ListMessages(r.Context(), claims.Sub, filter, params) if err != nil { h.logger.Error("list messages", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, result) } func (h *Handler) GetMessage(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) messageID := chi.URLParam(r, "messageID") if h.denyUnlessMessageInScope(w, r, messageID) { return } msg, err := h.svc.GetMessage(r.Context(), claims.Sub, messageID) if err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("get message", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, msg) } func (h *Handler) UpdateLabels(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) var req updateLabelsRequest if err := apivalidate.DecodeJSON(w, r, maxFlagsLabelsBody, &req); err != nil { return } if verr := validateUpdateLabels(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } messageID := chi.URLParam(r, "messageID") if h.denyUnlessMessageInScope(w, r, messageID) { return } if err := h.svc.UpdateLabels(r.Context(), claims.Sub, messageID, req.Labels); err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("update labels", "error", err) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) UpdateFlags(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) var req updateFlagsRequest if err := apivalidate.DecodeJSON(w, r, maxFlagsLabelsBody, &req); err != nil { return } if verr := validateUpdateFlags(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } messageID := chi.URLParam(r, "messageID") if h.denyUnlessMessageInScope(w, r, messageID) { return } if err := h.svc.UpdateFlags(r.Context(), claims.Sub, messageID, req.Flags); err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("update flags", "error", err) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) DeleteMessage(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) messageID := chi.URLParam(r, "messageID") if h.denyUnlessMessageInScope(w, r, messageID) { return } if err := h.svc.DeleteMessage(r.Context(), claims.Sub, messageID); err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("delete message", "error", err) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) SendListUnsubscribeMailto(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) messageID := chi.URLParam(r, "messageID") if h.denyUnlessMessageInScope(w, r, messageID) { return } if h.mailSender == nil { apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeInternal, "mail send unavailable", nil) return } target, err := h.svc.SendMailtoListUnsubscribe(r.Context(), claims.Sub, messageID, h.mailSender) if err != nil { switch { case errors.Is(err, ErrNotFound): apivalidate.WriteNotFound(w, r, "not found") case errors.Is(err, ErrListUnsubscribeNoMailto): apiresponse.WriteError(w, r, http.StatusConflict, apiresponse.CodeInvalidRequest, err.Error(), nil) case errors.Is(err, ErrListUnsubscribeUnavailable): apiresponse.WriteError(w, r, http.StatusConflict, apiresponse.CodeInvalidRequest, "no mailto list-unsubscribe", nil) default: h.logger.Error("list-unsubscribe mailto send", "message_id", messageID, "error", err) apivalidate.WriteInternal(w, r) } return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ "sent": true, "mailto": target.Address, "subject": target.Subject, }) } func (h *Handler) GetThread(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) threadID := chi.URLParam(r, "threadID") if h.denyUnlessThreadInScope(w, r, threadID) { return } result, err := h.svc.GetThread(r.Context(), claims.Sub, threadID, middleware.MailScopeAccountIDs(r.Context())) if err != nil { h.logger.Error("get thread", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, result) } func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub) if err != nil { h.writeUserResolveError(w, r, err) return } if h.sendLimiter != nil { if err := h.sendLimiter.Allow(userID); err != nil { apiresponse.WriteError(w, r, http.StatusTooManyRequests, apiresponse.CodeRateLimited, "send rate limit exceeded", nil) return } } idempotencyKey, ok := normalizeIdempotencyKey(r.Header.Get("Idempotency-Key")) if !ok { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "Idempotency-Key", Message: "invalid", })) return } var req sendMessageRequest if err := apivalidate.DecodeJSON(w, r, limits.MaxSendRequestBodyBytes, &req); err != nil { return } req.IdempotencyKey = idempotencyKey if verr := validateSendMessage(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } if middleware.DenyIfMailAccountOutOfScope(w, r, req.AccountID) { return } id, status, err := h.svc.SendMessage(r.Context(), userID, &req) if err != nil { if errors.Is(err, ErrAccountNotFound) { apivalidate.WriteNotFound(w, r, "account not found") return } h.logger.Error("send message", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusAccepted, map[string]string{"id": id, "status": status}) } func (h *Handler) ListRules(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) params, err := query.ParseListRequest(r) if err != nil { apivalidate.WriteQueryError(w, r, err) return } result, err := h.svc.ListRules(r.Context(), claims.Sub, params) if err != nil { h.logger.Error("list rules", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, result) } func (h *Handler) CreateRule(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub) if err != nil { h.writeUserResolveError(w, r, err) return } var req createRuleRequest if err := apivalidate.DecodeJSON(w, r, maxRulesRequestBody, &req); err != nil { return } if verr := validateCreateRule(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } id, err := h.svc.CreateRule(r.Context(), userID, &req) if err != nil { if errors.Is(err, ErrAccountNotFound) { apivalidate.WriteNotFound(w, r, "account not found") return } h.logger.Error("create rule", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id}) } func (h *Handler) UpdateRule(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) var req updateRuleRequest if err := apivalidate.DecodeJSON(w, r, maxRulesRequestBody, &req); err != nil { return } if verr := validateUpdateRule(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } if err := h.svc.UpdateRule(r.Context(), claims.Sub, chi.URLParam(r, "ruleID"), &req); err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("update rule", "error", err) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) DeleteRule(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) if err := h.svc.DeleteRule(r.Context(), claims.Sub, chi.URLParam(r, "ruleID")); err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("delete rule", "error", err) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) SimulateRule(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) var req simulateRuleRequest if err := apivalidate.DecodeJSON(w, r, maxRulesRequestBody, &req); err != nil { return } if verr := validateSimulateRule(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } result, err := h.svc.SimulateRule(r.Context(), claims.Sub, &req) if err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("simulate rule", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, result) } func (h *Handler) ListWebhooks(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) params, err := query.ParseListRequest(r) if err != nil { apivalidate.WriteQueryError(w, r, err) return } result, err := h.svc.ListWebhooks(r.Context(), claims.Sub, params) if err != nil { h.logger.Error("list webhooks", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, result) } func (h *Handler) CreateWebhook(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) var req createWebhookRequest if err := apivalidate.DecodeJSON(w, r, maxWebhookRequestBody, &req); err != nil { return } method, maxRetries, verr := validateCreateWebhook(&req) if verr != nil { apivalidate.WriteValidationError(w, r, verr) return } id, err := h.svc.CreateWebhook(r.Context(), claims.Sub, &req, method, maxRetries) if err != nil { h.logger.Error("create webhook", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id}) } func (h *Handler) UpdateWebhook(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) var req updateWebhookRequest if err := apivalidate.DecodeJSON(w, r, maxWebhookRequestBody, &req); err != nil { return } method, maxRetries, verr := validateUpdateWebhook(&req) if verr != nil { apivalidate.WriteValidationError(w, r, verr) return } if err := h.svc.UpdateWebhook(r.Context(), claims.Sub, chi.URLParam(r, "webhookID"), &req, method, maxRetries); err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("update webhook", "error", err) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) PreviewWebhookTemplate(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) var req previewWebhookRequest if err := apivalidate.DecodeJSON(w, r, maxWebhookRequestBody, &req); err != nil { return } if verr := validatePreviewWebhook(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } result, err := h.svc.PreviewWebhookTemplate(r.Context(), claims.Sub, &req) if err != nil { h.logger.Error("preview webhook template", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, result) } func (h *Handler) DeleteWebhook(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) if err := h.svc.DeleteWebhook(r.Context(), claims.Sub, chi.URLParam(r, "webhookID")); err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("delete webhook", "error", err) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) writeUserResolveError(w http.ResponseWriter, r *http.Request, err error) { if errors.Is(err, ErrUserNotProvisioned) { apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "user not provisioned", nil) return } h.logger.Error("resolve user id", "error", err) apivalidate.WriteInternal(w, r) }