feat: tell the user what to do when they try to connect a Gmail or Microsoft mailbox on a deployment with no OAuth client, replacing the generic 500 with a 503 carrying a stable mailbox_provider_not_configured code, the exact BOX_* variables to set, and a link to the environment setup guide, rendered as an inline panel in the add-mailbox modal instead of a truncated toast

This commit is contained in:
Matthew Meszaros
2026-08-13 09:36:13 +02:00
parent 6fc3836659
commit 446ecad33c
7 changed files with 248 additions and 24 deletions
+18
View File
@@ -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
+13 -7
View File
@@ -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:
+77
View File
@@ -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)
}
}
+11 -3
View File
@@ -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.")
+23 -2
View File
@@ -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"),
})
}
+102 -12
View File
@@ -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<View>("pick");
const [oauthBusy, setOauthBusy] = React.useState<OAuthProvider | null>(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<OAuthProvider | null>(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]"
>
<Header view={view} onBack={() => setView("pick")} onClose={() => user.setAddEmail(false)} />
<Header
view={view}
onBack={() => {
setNotConfigured(null);
setView("pick");
}}
onClose={() => user.setAddEmail(false)}
/>
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden relative">
<AnimatePresence mode="wait" initial={false}>
<motion.div
@@ -180,18 +200,26 @@ export default function AddEmailModal() {
>
{view === "pick" && <PickProvider onPick={setView} />}
{view === "gmail" && (
<OAuthPanel
provider="gmail"
busy={oauthBusy === "gmail"}
onConnect={() => startOAuth("gmail")}
/>
notConfigured === "gmail" ? (
<ProviderNotConfigured provider="gmail" />
) : (
<OAuthPanel
provider="gmail"
busy={oauthBusy === "gmail"}
onConnect={() => startOAuth("gmail")}
/>
)
)}
{view === "outlook" && (
<OAuthPanel
provider="outlook"
busy={oauthBusy === "outlook"}
onConnect={() => startOAuth("outlook")}
/>
notConfigured === "outlook" ? (
<ProviderNotConfigured provider="outlook" />
) : (
<OAuthPanel
provider="outlook"
busy={oauthBusy === "outlook"}
onConnect={() => startOAuth("outlook")}
/>
)
)}
{view === "smtp_imap" && (
<SmtpImapPanel
@@ -255,6 +283,68 @@ function Header({
);
}
// Shown when the API reports this deployment has no OAuth client for the chosen
// provider. Self-host only, and the person seeing it can usually fix it, so it
// names the exact variables and links the setup guide instead of just failing.
const PROVIDER_SETUP: Record<OAuthProvider, { label: string; vars: string[] }> = {
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 (
<div className="p-4">
<div className="rounded-md border border-amber-200 bg-amber-50 p-3">
<div className="flex items-start gap-2.5">
<SettingsIcon className="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-[12.5px] font-medium text-amber-900">
{label} is not configured on this deployment
</p>
<p className="text-[12.5px] text-amber-800 mt-1">
Connecting these mailboxes needs an OAuth client. Add both values to
the <code className="bg-white/70 px-1 rounded">.env</code> at the root of
your Warmbly install, then restart with{" "}
<code className="bg-white/70 px-1 rounded">make up</code>.
</p>
<ul className="mt-2 space-y-1">
{vars.map((v) => (
<li
key={v}
className="text-[12px] font-mono text-amber-900 bg-white/70 rounded px-1.5 py-1"
>
{v}=
</li>
))}
</ul>
</div>
</div>
</div>
<a
href="https://docs.warmbly.com/development/deployment-guide/#connect-mailboxes"
target="_blank"
rel="noreferrer"
className="mt-3 h-7 px-2.5 inline-flex items-center gap-1.5 rounded-md border border-slate-200 text-[12.5px] text-slate-700 hover:bg-slate-50 transition-colors"
>
<ExternalLinkIcon className="w-3.5 h-3.5" />
Full environment setup guide
</a>
<p className="mt-3 text-[12px] text-slate-500">
No setup needed for any other provider: connect it over SMTP and IMAP instead.
</p>
</div>
);
}
function PickProvider({ onPick }: { onPick: (v: View) => void }) {
const rows: Array<{
key: View;
+4
View File
@@ -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,
}
}