ultisuite-backend/internal/api/mail/handlers_account_oauth.go
R3D347HR4Y 20c4fef3c6
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
docxi import lol
2026-06-10 00:27:21 +02:00

197 lines
5.8 KiB
Go

package mail
import (
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/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/mail/connect"
mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth"
)
func (h *Handler) TestAccountConnection(w http.ResponseWriter, r *http.Request) {
h.testAccountConnection(w, r, "")
}
func (h *Handler) TestStoredAccountConnection(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountID")
if d := validateAccountUUID(accountID); d != nil {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.testAccountConnection(w, r, accountID)
}
func (h *Handler) testAccountConnection(w http.ResponseWriter, r *http.Request, accountIDFromURL string) {
claims := middleware.ClaimsFromContext(r.Context())
var req testAccountRequest
if err := apivalidate.DecodeJSON(w, r, maxAccountRequestBody, &req); err != nil {
return
}
if accountIDFromURL != "" {
req.AccountID = accountIDFromURL
}
if verr := validateTestAccount(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
cred, err := h.svc.CredentialForConnectionTest(r.Context(), claims.Sub, &req)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
if errors.Is(err, ErrInvalidAccountCredentials) {
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "missing credentials for connection test", nil)
return
}
if errors.Is(err, ErrCredentialsUnavailable) {
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "credentials encryption unavailable", nil)
return
}
h.logger.Error("resolve test credentials", "error", err)
apivalidate.WriteInternal(w, r)
return
}
result := connect.Test(r.Context(), connect.ServerConfig{
IMAPHost: req.IMAPHost,
IMAPPort: req.IMAPPort,
IMAPTLS: req.IMAPTLS,
SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort,
SMTPTLS: req.SMTPTLS,
}, cred)
if result.IMAPError != "" {
result.IMAPError = connect.SanitizeError(result.IMAPError)
}
if result.SMTPError != "" {
result.SMTPError = connect.SanitizeError(result.SMTPError)
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) ListOAuthProviders(w http.ResponseWriter, r *http.Request) {
if h.oauth == nil {
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"providers": []string{}})
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
"providers": h.oauth.EnabledProviders(),
})
}
func (h *Handler) StartOAuthAccount(w http.ResponseWriter, r *http.Request) {
if h.oauth == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeInternal, "mail oauth unavailable", nil)
return
}
claims := middleware.ClaimsFromContext(r.Context())
var req oauthStartRequest
if err := apivalidate.DecodeJSON(w, r, maxAccountRequestBody, &req); err != nil {
return
}
if verr := validateOAuthStart(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
pending := h.oauthPendingFromRequest(&req)
authURL, state, err := h.oauth.Start(r.Context(), claims.Sub, mailoauth.Provider(req.Provider), pending)
if err != nil {
h.logger.Error("oauth start", "error", err, "provider", req.Provider)
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]string{
"authorization_url": authURL,
"state": state,
})
}
func (h *Handler) OAuthCallback(w http.ResponseWriter, r *http.Request) {
if h.oauth == nil || h.svc == nil {
http.Redirect(w, r, h.oauthReturnURL("error", "oauth_unavailable"), http.StatusFound)
return
}
state := strings.TrimSpace(r.URL.Query().Get("state"))
code := strings.TrimSpace(r.URL.Query().Get("code"))
if errParam := r.URL.Query().Get("error"); errParam != "" {
http.Redirect(w, r, h.oauthReturnURL("error", errParam), http.StatusFound)
return
}
if state == "" || code == "" {
http.Redirect(w, r, h.oauthReturnURL("error", "missing_code"), http.StatusFound)
return
}
pending, token, err := h.oauth.Exchange(r.Context(), state, code)
if err != nil {
h.logger.Error("oauth exchange", "error", err)
http.Redirect(w, r, h.oauthReturnURL("error", "exchange_failed"), http.StatusFound)
return
}
cred := mailoauth.CredentialFromToken(pending.Email, pending.Provider, token)
_, err = h.svc.CreateAccountWithCredential(r.Context(), pending.UserExternalID, &createAccountRequest{
Name: pending.Name,
Email: pending.Email,
Provider: pending.ProviderID,
IMAPHost: pending.IMAPHost,
IMAPPort: pending.IMAPPort,
IMAPTLS: pending.IMAPTLS,
SMTPHost: pending.SMTPHost,
SMTPPort: pending.SMTPPort,
SMTPTLS: pending.SMTPTLS,
}, cred)
if err != nil {
h.logger.Error("oauth create account", "error", err)
http.Redirect(w, r, h.oauthReturnURL("error", "create_failed"), http.StatusFound)
return
}
http.Redirect(w, r, h.oauthReturnURL("success", ""), http.StatusFound)
}
func (h *Handler) oauthPendingFromRequest(req *oauthStartRequest) mailoauth.PendingAccount {
name := req.Name
if name == "" {
name = req.Email
}
return mailoauth.PendingAccount{
Email: req.Email,
Name: name,
ProviderID: req.ProviderID,
IMAPHost: req.IMAPHost,
IMAPPort: req.IMAPPort,
IMAPTLS: req.IMAPTLS,
SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort,
SMTPTLS: req.SMTPTLS,
}
}
func (h *Handler) oauthReturnURL(status, code string) string {
base := strings.TrimRight(h.appURL, "/")
if base == "" {
base = "http://localhost:3004"
}
u := base + "/mail/settings/accounts?oauth=" + status
if code != "" {
u += "&code=" + code
}
return u
}