mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-08 00:02:09 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user