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
This commit is contained in:
Yiming Qi
2026-03-29 20:13:45 +08:00
committed by GitHub
parent c93ddc3a87
commit ba0569c1fa
4 changed files with 73 additions and 11 deletions
+42 -7
View File
@@ -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(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{{if eq .Status "success"}}Gmail OAuth Connected{{else}}Gmail OAuth Failed{{end}}</title>
</head>
<body style="font-family: sans-serif; padding: 24px;">
{{if eq .Status "success"}}
<h2>Gmail OAuth connected</h2>
<p>You can close this window and return to Memoh.</p>
{{else}}
<h2>Gmail OAuth failed</h2>
<p>{{.Error}}</p>
{{end}}
<script>
window.opener?.postMessage({
type: "memoh-email-oauth-callback",
status: "{{.Status}}",
providerId: "{{.ProviderID}}",
error: "{{.Error}}"
}, "*");
setTimeout(() => window.close(), 300);
</script>
</body>
</html>`))
return c.HTML(statusCode, executeHTMLTemplate(page, map[string]string{
"ProviderID": providerID,
"Status": status,
"Error": errorMessage,
}))
}