From ba0569c1fa0cb2b0697b8f71f7f02695f66e3650 Mon Sep 17 00:00:00 2001 From: Yiming Qi Date: Sun, 29 Mar 2026 20:13:45 +0800 Subject: [PATCH] feat(email): use popup flow for gmail oauth callback (#307) * feat(email): use popup flow for gmail oauth callback * fix(email): satisfy lint in oauth callback helper --- apps/web/src/i18n/locales/en.json | 2 +- apps/web/src/i18n/locales/zh.json | 2 +- .../email/components/provider-setting.vue | 31 +++++++++++- internal/handlers/email_oauth.go | 49 ++++++++++++++++--- 4 files changed, 73 insertions(+), 11 deletions(-) diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index b69aeedf..975549e4 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -413,7 +413,7 @@ "title": "OAuth2 Authorization", "description": "Authorize this provider to send emails on your behalf. You will be redirected to the provider's login page.", "authorize": "Authorize", - "authorizeOpened": "Authorization page opened in a new tab", + "authorizeOpened": "Authorization completed", "authorizeFailed": "Failed to start authorization", "status": { "checking": "Checking authorization status...", diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index 7688a669..e2108a2d 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -409,7 +409,7 @@ "title": "OAuth2 授权", "description": "授权此提供商以您的名义发送邮件,系统将跳转到提供商登录页面。", "authorize": "授权", - "authorizeOpened": "授权页面已在新标签页打开", + "authorizeOpened": "授权完成", "authorizeFailed": "启动授权失败", "status": { "checking": "正在检查授权状态...", diff --git a/apps/web/src/pages/email/components/provider-setting.vue b/apps/web/src/pages/email/components/provider-setting.vue index 3dee3312..2f830dc9 100644 --- a/apps/web/src/pages/email/components/provider-setting.vue +++ b/apps/web/src/pages/email/components/provider-setting.vue @@ -379,8 +379,35 @@ async function handleAuthorize() { if (error || !data?.auth_url) { throw new Error(t('email.oauth.authorizeFailed')) } - window.open(data.auth_url, '_blank', 'noopener,noreferrer') - toast.success(t('email.oauth.authorizeOpened')) + + const popup = window.open(data.auth_url, 'email-oauth', 'width=600,height=720') + if (!popup) { + throw new Error(t('email.oauth.authorizeFailed')) + } + + await new Promise((resolve, reject) => { + const cleanup = () => { + window.removeEventListener('message', onMessage) + } + + const onMessage = async (event: MessageEvent) => { + if (event.data?.type !== 'memoh-email-oauth-callback') return + if (event.data?.providerId && event.data.providerId !== curProviderId.value) return + + cleanup() + + if (event.data?.status === 'success') { + await fetchOAuthStatus() + toast.success(t('email.oauth.authorizeOpened')) + resolve() + return + } + + reject(new Error(typeof event.data?.error === 'string' && event.data.error ? event.data.error : t('email.oauth.authorizeFailed'))) + } + + window.addEventListener('message', onMessage) + }) } catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('email.oauth.authorizeFailed')) } finally { diff --git a/internal/handlers/email_oauth.go b/internal/handlers/email_oauth.go index 032835d4..ce26131f 100644 --- a/internal/handlers/email_oauth.go +++ b/internal/handlers/email_oauth.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/hex" "errors" + "html/template" "log/slog" "net/http" "net/url" @@ -115,10 +116,10 @@ func (h *EmailOAuthHandler) Callback(c echo.Context) error { state := strings.TrimSpace(c.QueryParam("state")) if code == "" { - return echo.NewHTTPError(http.StatusBadRequest, "code is required") + return renderEmailOAuthCallbackResult(c, http.StatusBadRequest, "", "error", "code is required") } if state == "" { - return echo.NewHTTPError(http.StatusBadRequest, "state is required") + return renderEmailOAuthCallbackResult(c, http.StatusBadRequest, "", "error", "state is required") } ctx := c.Request().Context() @@ -126,16 +127,16 @@ func (h *EmailOAuthHandler) Callback(c echo.Context) error { stored, err := h.tokenStore.GetByState(ctx, state) if err != nil { h.logger.Error("oauth callback: state not found", slog.String("state", state), slog.Any("error", err)) - return echo.NewHTTPError(http.StatusBadRequest, "invalid or expired state") + return renderEmailOAuthCallbackResult(c, http.StatusBadRequest, "", "error", "invalid or expired state") } provider, err := h.service.GetProvider(ctx, stored.ProviderID) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "provider not found") + return renderEmailOAuthCallbackResult(c, http.StatusInternalServerError, stored.ProviderID, "error", "provider not found") } if email.ProviderName(provider.Provider) != emailgmail.ProviderName { - return echo.NewHTTPError(http.StatusBadRequest, "provider does not support OAuth2") + return renderEmailOAuthCallbackResult(c, http.StatusBadRequest, stored.ProviderID, "error", "provider does not support OAuth2") } adapter := emailgmail.New(h.logger, h.tokenStore) redirectURI := callbackURLFromState(state) @@ -144,11 +145,11 @@ func (h *EmailOAuthHandler) Callback(c echo.Context) error { } if err := adapter.ExchangeCode(ctx, provider.Config, stored.ProviderID, code, redirectURI); err != nil { h.logger.Error("gmail code exchange failed", slog.Any("error", err)) - return echo.NewHTTPError(http.StatusInternalServerError, "token exchange failed") + return renderEmailOAuthCallbackResult(c, http.StatusInternalServerError, stored.ProviderID, "error", "token exchange failed") } h.logger.Info("email oauth authorized", slog.String("provider_id", stored.ProviderID), slog.String("provider", provider.Provider)) - return c.JSON(http.StatusOK, map[string]string{"status": "authorized"}) + return renderEmailOAuthCallbackResult(c, http.StatusOK, stored.ProviderID, "success", "") } // Status godoc @@ -328,3 +329,37 @@ func callbackURLFromState(state string) string { } return strings.TrimSpace(string(callbackURL)) } + +func renderEmailOAuthCallbackResult(c echo.Context, statusCode int, providerID, status, errorMessage string) error { + page := template.Must(template.New("email-oauth-result").Parse(` + + + + {{if eq .Status "success"}}Gmail OAuth Connected{{else}}Gmail OAuth Failed{{end}} + + + {{if eq .Status "success"}} +

Gmail OAuth connected

+

You can close this window and return to Memoh.

+ {{else}} +

Gmail OAuth failed

+

{{.Error}}

+ {{end}} + + +`)) + + return c.HTML(statusCode, executeHTMLTemplate(page, map[string]string{ + "ProviderID": providerID, + "Status": status, + "Error": errorMessage, + })) +}