diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx index cb413af0..559bd374 100644 --- a/docs/content/docs/api/error-codes.mdx +++ b/docs/content/docs/api/error-codes.mdx @@ -245,6 +245,24 @@ Returned when the service is temporarily unavailable. - Wait and retry with exponential backoff - Check status page for incidents +#### `mailbox_provider_not_configured` + +A `503` whose `code` is `mailbox_provider_not_configured` is not transient and retrying will not help. It means the deployment has no OAuth client for the mailbox provider the request asked for, which only happens on a self-hosted install. + +```json +{ + "error": "Service Unavailable", + "message": "Gmail is not configured on this deployment. Set BOX_GOOGLE_CLIENT_ID and BOX_GOOGLE_CLIENT_SECRET in your .env, then restart.", + "code": "mailbox_provider_not_configured", + "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e" +} +``` + +**How to fix:** +- Set `BOX_GOOGLE_CLIENT_ID` and `BOX_GOOGLE_CLIENT_SECRET`, or `BOX_OUTLOOK_CLIENT_ID` and `BOX_OUTLOOK_CLIENT_SECRET`, in the `.env` at your install root, then restart +- Or connect the mailbox over SMTP and IMAP instead, which needs no configuration +- Full walkthrough: [connect mailboxes](/development/deployment-guide/#connect-mailboxes) + ## Error handling best practices ### Implement retry logic diff --git a/internal/app/email/onboarding.go b/internal/app/email/onboarding.go index 4b6b5645..d4f7a679 100644 --- a/internal/app/email/onboarding.go +++ b/internal/app/email/onboarding.go @@ -265,19 +265,25 @@ func (s *emailService) dispatchAccountConnected(ctx context.Context, orgID *uuid } } +// oauthConfigured reports whether an OAuth client is actually usable, i.e. both +// halves of the credential are present. +func oauthConfigured(cfg *oauth2.Config) bool { + return cfg != nil && cfg.ClientID != "" && cfg.ClientSecret != "" +} + func (s *emailService) oauthConfigFor(provider models.InboxProvider) (*oauth2.Config, *errx.Error) { - if s.oauthInbox == nil { - return nil, errx.InternalError() - } + // LoadOauth2Inbox always returns a config, populated with empty strings when + // the variables are unset, so the credentials themselves are what decides + // whether the provider is actually available here. switch provider { case models.InboxProviderGoogle: - if s.oauthInbox.Google == nil { - return nil, errx.InternalError() + if s.oauthInbox == nil || !oauthConfigured(s.oauthInbox.Google) { + return nil, errx.ErrEmailOnboardGoogleNotConfigured } return s.oauthInbox.Google, nil case models.InboxProviderOutlook: - if s.oauthInbox.Outlook == nil { - return nil, errx.InternalError() + if s.oauthInbox == nil || !oauthConfigured(s.oauthInbox.Outlook) { + return nil, errx.ErrEmailOnboardOutlookNotConfigured } return s.oauthInbox.Outlook, nil default: diff --git a/internal/app/email/onboarding_test.go b/internal/app/email/onboarding_test.go new file mode 100644 index 00000000..cc8b386a --- /dev/null +++ b/internal/app/email/onboarding_test.go @@ -0,0 +1,77 @@ +package email + +import ( + "testing" + + "github.com/warmbly/warmbly/internal/config" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "golang.org/x/oauth2" +) + +// config.LoadOauth2Inbox always returns a non-nil config whose fields are empty +// when the variables are unset, so a nil check alone reports every provider as +// available and the flow fails later with an opaque error from the provider. +func TestOAuthConfigFor_UnconfiguredProviderIsReported(t *testing.T) { + svc := &emailService{oauthInbox: &config.Oauth2Inbox{ + Google: &oauth2.Config{}, + Outlook: &oauth2.Config{}, + }} + + for _, tc := range []struct { + provider models.InboxProvider + want *errx.Error + }{ + {models.InboxProviderGoogle, errx.ErrEmailOnboardGoogleNotConfigured}, + {models.InboxProviderOutlook, errx.ErrEmailOnboardOutlookNotConfigured}, + } { + cfg, err := svc.oauthConfigFor(tc.provider) + if cfg != nil { + t.Errorf("%s: expected no config, got one", tc.provider) + } + if err != tc.want { + t.Errorf("%s: expected %v, got %v", tc.provider, tc.want, err) + } + if err != nil && err.Identifier != "mailbox_provider_not_configured" { + t.Errorf("%s: clients branch on this identifier, got %q", tc.provider, err.Identifier) + } + } +} + +// A half-set credential is still unusable, and silently building an OAuth URL +// from it sends the user to a provider error page instead of telling them what +// to fix. +func TestOAuthConfigFor_PartialCredentialsAreNotConfigured(t *testing.T) { + svc := &emailService{oauthInbox: &config.Oauth2Inbox{ + Google: &oauth2.Config{ClientID: "id-without-secret"}, + Outlook: &oauth2.Config{ClientSecret: "secret-without-id"}, + }} + + if _, err := svc.oauthConfigFor(models.InboxProviderGoogle); err != errx.ErrEmailOnboardGoogleNotConfigured { + t.Errorf("google with no secret should be unconfigured, got %v", err) + } + if _, err := svc.oauthConfigFor(models.InboxProviderOutlook); err != errx.ErrEmailOnboardOutlookNotConfigured { + t.Errorf("outlook with no client id should be unconfigured, got %v", err) + } +} + +func TestOAuthConfigFor_ConfiguredProviderIsReturned(t *testing.T) { + google := &oauth2.Config{ClientID: "id", ClientSecret: "secret"} + svc := &emailService{oauthInbox: &config.Oauth2Inbox{ + Google: google, + Outlook: &oauth2.Config{}, + }} + + cfg, err := svc.oauthConfigFor(models.InboxProviderGoogle) + if err != nil { + t.Fatalf("configured google should be returned, got %v", err) + } + if cfg != google { + t.Error("expected the configured google client") + } + + // One provider being configured must not make the other appear available. + if _, err := svc.oauthConfigFor(models.InboxProviderOutlook); err != errx.ErrEmailOnboardOutlookNotConfigured { + t.Errorf("outlook should still be unconfigured, got %v", err) + } +} diff --git a/internal/errx/common.go b/internal/errx/common.go index eb2bdff9..666881aa 100644 --- a/internal/errx/common.go +++ b/internal/errx/common.go @@ -63,9 +63,17 @@ var ( ErrGroupMax = New(BadRequest, "You reached the maximum amount.") // Email - ErrEmailCredentials = New(BadRequest, "Invalid email credentials.") - ErrEmailValidation = New(BadRequest, "Deadline exceed, try again later.") - ErrEmailOnboardProvider = New(BadRequest, "Unsupported email provider. Use 'gmail', 'outlook', or 'smtp_imap'.") + ErrEmailCredentials = New(BadRequest, "Invalid email credentials.") + ErrEmailValidation = New(BadRequest, "Deadline exceed, try again later.") + ErrEmailOnboardProvider = New(BadRequest, "Unsupported email provider. Use 'gmail', 'outlook', or 'smtp_imap'.") + // Raised when the provider is supported but this deployment has no OAuth + // client for it. Self-host only: the hosted product always has both set. The + // message names the variables because the person hitting this is usually the + // same person who can fix it. + ErrEmailOnboardGoogleNotConfigured = NewWithIdentifier(ServiceUnavailable, "mailbox_provider_not_configured", + "Gmail is not configured on this deployment. Set BOX_GOOGLE_CLIENT_ID and BOX_GOOGLE_CLIENT_SECRET in your .env, then restart. See https://docs.warmbly.com/development/deployment-guide/#connect-mailboxes") + ErrEmailOnboardOutlookNotConfigured = NewWithIdentifier(ServiceUnavailable, "mailbox_provider_not_configured", + "Microsoft 365 is not configured on this deployment. Set BOX_OUTLOOK_CLIENT_ID and BOX_OUTLOOK_CLIENT_SECRET in your .env, then restart. See https://docs.warmbly.com/development/deployment-guide/#connect-mailboxes") ErrEmailOnboardState = New(BadRequest, "Invalid or expired onboarding state.") ErrEmailOnboardCode = New(BadRequest, "Authorization code is missing or invalid.") ErrEmailOnboardExchange = New(BadRequest, "Could not exchange the authorization code with the provider.") diff --git a/internal/errx/errx.go b/internal/errx/errx.go index c6d3a46c..a3b9cda8 100644 --- a/internal/errx/errx.go +++ b/internal/errx/errx.go @@ -11,6 +11,11 @@ import ( type Error struct { Code Code `json:"code"` Message string `json:"message"` + // Identifier optionally overrides the machine-readable `code` in the JSON + // response. Without it every error of the same HTTP class is indistinguishable + // to a client, so a caller that needs to branch on a specific condition has + // nothing stable to match on. Empty means "derive it from Code". + Identifier string `json:"-"` } // Error implements error interface. @@ -23,6 +28,22 @@ func New(code Code, message string) *Error { return &Error{Code: code, Message: message} } +// NewWithIdentifier creates a business error carrying its own machine-readable +// identifier, for conditions a client is expected to detect and handle +// specifically rather than just display. +func NewWithIdentifier(code Code, identifier, message string) *Error { + return &Error{Code: code, Message: message, Identifier: identifier} +} + +// identifier returns the response `code`: the error's own when set, otherwise +// the generic one for its HTTP class. +func (e *Error) identifier() string { + if e.Identifier != "" { + return e.Identifier + } + return codeToIdentifier[e.Code] +} + // --- Predefined errors (exported) --- var ( ErrUnauthorized = New(Unauthorized, "Token not found.") @@ -54,7 +75,7 @@ func Handle(c *gin.Context, err error) { c.JSON(httpCode, response{ Error: httpError, Message: bizErr.Message, - Code: codeToIdentifier[bizErr.Code], + Code: bizErr.identifier(), RequestID: c.GetString("request_id"), }) return @@ -71,7 +92,7 @@ func JSON(c *gin.Context, err *Error) { c.JSON(httpCode, response{ Error: httpError, Message: err.Message, - Code: codeToIdentifier[err.Code], + Code: err.identifier(), RequestID: c.GetString("request_id"), }) } diff --git a/web/src/components/app/modals/AddEmailModal.tsx b/web/src/components/app/modals/AddEmailModal.tsx index 572ce657..c8d70da0 100644 --- a/web/src/components/app/modals/AddEmailModal.tsx +++ b/web/src/components/app/modals/AddEmailModal.tsx @@ -23,7 +23,9 @@ import { KeyRoundIcon, Loader2Icon, MailIcon, + ExternalLinkIcon, SendIcon, + SettingsIcon, ShieldCheckIcon, XIcon, } from "lucide-react"; @@ -76,6 +78,10 @@ export default function AddEmailModal() { const [view, setView] = React.useState("pick"); const [oauthBusy, setOauthBusy] = React.useState(null); + // Set when the deployment has no OAuth client for the provider the user + // picked. Rendered inline rather than as a toast: it is a setup instruction + // with a link, not a transient failure. + const [notConfigured, setNotConfigured] = React.useState(null); const pendingState = React.useRef<{ provider: OAuthProvider; state: string } | null>(null); // Reset when the modal closes. @@ -83,6 +89,7 @@ export default function AddEmailModal() { if (!user.addEmail) { setView("pick"); setOauthBusy(null); + setNotConfigured(null); pendingState.current = null; } }, [user.addEmail]); @@ -131,6 +138,7 @@ export default function AddEmailModal() { async function startOAuth(provider: OAuthProvider) { if (oauthBusy) return; setOauthBusy(provider); + setNotConfigured(null); try { const { url, state } = await onboardOAuthStart(provider); pendingState.current = { provider, state }; @@ -143,7 +151,12 @@ export default function AddEmailModal() { } catch (err) { pendingState.current = null; setOauthBusy(null); - toast.error(buildError(err as AppError)); + const e = err as AppError; + if (e.code === "mailbox_provider_not_configured") { + setNotConfigured(provider); + return; + } + toast.error(buildError(e)); } } @@ -168,7 +181,14 @@ export default function AddEmailModal() { onClick={(e) => e.stopPropagation()} className="w-full max-w-[560px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[88dvh]" > -
setView("pick")} onClose={() => user.setAddEmail(false)} /> +
{ + setNotConfigured(null); + setView("pick"); + }} + onClose={() => user.setAddEmail(false)} + />
{view === "pick" && } {view === "gmail" && ( - startOAuth("gmail")} - /> + notConfigured === "gmail" ? ( + + ) : ( + startOAuth("gmail")} + /> + ) )} {view === "outlook" && ( - startOAuth("outlook")} - /> + notConfigured === "outlook" ? ( + + ) : ( + startOAuth("outlook")} + /> + ) )} {view === "smtp_imap" && ( = { + gmail: { + label: "Gmail and Google Workspace", + vars: ["BOX_GOOGLE_CLIENT_ID", "BOX_GOOGLE_CLIENT_SECRET"], + }, + outlook: { + label: "Outlook and Microsoft 365", + vars: ["BOX_OUTLOOK_CLIENT_ID", "BOX_OUTLOOK_CLIENT_SECRET"], + }, +}; + +function ProviderNotConfigured({ provider }: { provider: OAuthProvider }) { + const { label, vars } = PROVIDER_SETUP[provider]; + return ( +
+
+
+ +
+

+ {label} is not configured on this deployment +

+

+ Connecting these mailboxes needs an OAuth client. Add both values to + the .env at the root of + your Warmbly install, then restart with{" "} + make up. +

+
    + {vars.map((v) => ( +
  • + {v}= +
  • + ))} +
+
+
+
+ + + + Full environment setup guide + + +

+ No setup needed for any other provider: connect it over SMTP and IMAP instead. +

+
+ ); +} + function PickProvider({ onPick }: { onPick: (v: View) => void }) { const rows: Array<{ key: View; diff --git a/web/src/lib/api/client/normalizeError.ts b/web/src/lib/api/client/normalizeError.ts index 9d5d2fd5..ebb2b296 100644 --- a/web/src/lib/api/client/normalizeError.ts +++ b/web/src/lib/api/client/normalizeError.ts @@ -6,6 +6,9 @@ export interface AppError { message: string; status?: number; redirect?: boolean; + /** Stable machine-readable code from the API, for branching on a specific + * condition rather than matching on human-readable text. */ + code?: string; } export function normalizeError(error: unknown): AppError { @@ -43,6 +46,7 @@ export function normalizeError(error: unknown): AppError { error: data.error || "Unknown Error", message: data.message || "Unexpected error occured.", status, + code: data.code, } }