feat(email/oauth): implement OAuth2 support for Gmail provider (#212)

This commit is contained in:
Yiming Qi
2026-03-10 00:37:43 +09:00
committed by GitHub
parent f8bfd7c107
commit a5c364911e
18 changed files with 1339 additions and 4 deletions
+18
View File
@@ -279,6 +279,24 @@
"region": "Region",
"inbound_mode": "Inbound Mode",
"webhook_signing_key": "Webhook Signing Key"
},
"oauth": {
"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",
"authorizeFailed": "Failed to start authorization",
"status": {
"checking": "Checking authorization status...",
"authorized": "Authorized as {email}",
"authorizedUnknown": "Authorized",
"expired": "Authorization expired — please re-authorize.",
"missing": "Not authorized. Authorize to enable Gmail access.",
"notConfigured": "Client ID is missing. Add it before authorizing."
},
"logout": "Log out",
"logoutSuccess": "Authorization revoked",
"logoutFailed": "Failed to revoke authorization"
}
},
"browserContext": {
+18
View File
@@ -275,6 +275,24 @@
"region": "区域",
"inbound_mode": "入站模式",
"webhook_signing_key": "Webhook 签名密钥"
},
"oauth": {
"title": "OAuth2 授权",
"description": "授权此提供商以您的名义发送邮件,系统将跳转到提供商登录页面。",
"authorize": "授权",
"authorizeOpened": "授权页面已在新标签页打开",
"authorizeFailed": "启动授权失败",
"status": {
"checking": "正在检查授权状态...",
"authorized": "已授权账号 {email}",
"authorizedUnknown": "已授权",
"expired": "授权已过期,请重新授权。",
"missing": "尚未授权。完成授权后 Bot 才能访问 Gmail。",
"notConfigured": "缺少 Client ID,填写后才能发起授权。"
},
"logout": "注销授权",
"logoutSuccess": "授权已撤销",
"logoutFailed": "撤销授权失败"
}
},
"browserContext": {
@@ -128,6 +128,67 @@
</div>
</div>
<!-- OAuth authorization button for Gmail -->
<section
v-if="isOAuthProvider"
class="mt-6 p-4 border rounded-lg bg-muted/30"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex-1 min-w-[220px]">
<p class="text-sm font-medium">
{{ $t('emailProvider.oauth.title') }}
</p>
<p class="text-xs text-muted-foreground mt-0.5">
{{ $t('emailProvider.oauth.description') }}
</p>
<p
class="text-xs mt-2"
:class="oauthTokenExpired ? 'text-destructive' : 'text-muted-foreground'"
>
<template v-if="oauthStatusLoading">
{{ $t('emailProvider.oauth.status.checking') }}
</template>
<template v-else-if="oauthStatus && !oauthStatus.configured">
{{ $t('emailProvider.oauth.status.notConfigured') }}
</template>
<template v-else-if="oauthTokenExpired">
{{ $t('emailProvider.oauth.status.expired') }}
</template>
<template v-else-if="oauthStatus && oauthStatus.has_token">
{{ oauthStatus.email_address ? $t('emailProvider.oauth.status.authorized', { email: oauthStatus.email_address }) : $t('emailProvider.oauth.status.authorizedUnknown') }}
</template>
<template v-else>
{{ $t('emailProvider.oauth.status.missing') }}
</template>
</p>
</div>
<div class="flex items-center gap-2">
<LoadingButton
type="button"
variant="outline"
:disabled="!canAuthorize"
:loading="authorizeLoading"
@click="handleAuthorize"
>
<FontAwesomeIcon
:icon="['fas', 'key']"
class="mr-1.5"
/>
{{ $t('emailProvider.oauth.authorize') }}
</LoadingButton>
<LoadingButton
v-if="hasOAuthToken"
type="button"
variant="ghost"
:loading="revokeLoading"
@click="handleRevoke"
>
{{ $t('emailProvider.oauth.logout') }}
</LoadingButton>
</div>
</div>
</section>
<section class="flex justify-end mt-6 gap-4">
<ConfirmPopover
:message="$t('emailProvider.deleteConfirm')"
@@ -180,8 +241,20 @@ import z from 'zod'
import { useForm } from 'vee-validate'
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'
import { putEmailProvidersById, deleteEmailProvidersById, getEmailProvidersMeta } from '@memoh/sdk'
import { client } from '@memoh/sdk/client'
import type { EmailProviderResponse, EmailFieldSchema } from '@memoh/sdk'
interface EmailOAuthStatusResponse {
provider: string
configured: boolean
has_token: boolean
expired: boolean
email_address?: string
expires_at?: string
}
const OAUTH_PROVIDERS = ['gmail']
const { t } = useI18n()
const curProvider = inject('curEmailProvider', ref<EmailProviderResponse>())
const curProviderId = computed(() => curProvider.value?.id)
@@ -205,6 +278,14 @@ const orderedFields = computed<EmailFieldSchema[]>(() => {
return [...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
})
const isOAuthProvider = computed(() =>
OAUTH_PROVIDERS.includes(curProvider.value?.provider ?? ''),
)
const oauthStatus = ref<EmailOAuthStatusResponse | null>(null)
const oauthStatusLoading = ref(false)
const revokeLoading = ref(false)
const queryCache = useQueryCache()
const schema = toTypedSchema(z.object({
@@ -226,9 +307,20 @@ watch(() => curProvider.value?.id, (id) => {
const cfg = p.config ?? {}
Object.keys(configData).forEach((k) => delete configData[k])
Object.assign(configData, { ...cfg })
if (isOAuthProvider.value) {
void fetchOAuthStatus()
}
}
}, { immediate: true })
watch([isOAuthProvider, curProviderId], () => {
if (!isOAuthProvider.value) {
oauthStatus.value = null
return
}
void fetchOAuthStatus()
})
const { mutateAsync: submitUpdate, isLoading: editLoading } = useMutation({
mutation: async (data: { name: string; config: Record<string, unknown> }) => {
if (!curProviderId.value) return
@@ -254,6 +346,9 @@ const handleSave = form.handleSubmit(async (values) => {
try {
await submitUpdate({ name: values.name, config: { ...configData } })
toast.success(t('provider.saveChanges'))
if (isOAuthProvider.value) {
await fetchOAuthStatus()
}
} catch (e: any) {
toast.error(e?.message || t('common.saveFailed'))
}
@@ -267,4 +362,72 @@ async function handleDelete() {
toast.error(e?.message || t('common.saveFailed'))
}
}
const authorizeLoading = ref(false)
const hasOAuthToken = computed(() => Boolean(oauthStatus.value?.has_token))
const oauthTokenExpired = computed(() => Boolean(oauthStatus.value?.has_token && oauthStatus.value?.expired))
const canAuthorize = computed(() => {
if (!isOAuthProvider.value) return false
if (oauthStatusLoading.value) return false
if (oauthStatus.value && !oauthStatus.value.configured) return false
return true
})
async function handleAuthorize() {
if (!curProviderId.value) return
authorizeLoading.value = true
try {
const { data, error } = await client.get<{ auth_url: string }, unknown>({
url: `/email-providers/${curProviderId.value}/oauth/authorize`,
})
if (error || !data?.auth_url) {
throw new Error(t('emailProvider.oauth.authorizeFailed'))
}
window.open(data.auth_url, '_blank', 'noopener,noreferrer')
toast.success(t('emailProvider.oauth.authorizeOpened'))
} catch (e: any) {
toast.error(e?.message || t('emailProvider.oauth.authorizeFailed'))
} finally {
authorizeLoading.value = false
}
}
async function fetchOAuthStatus() {
if (!isOAuthProvider.value || !curProviderId.value) {
oauthStatus.value = null
return
}
oauthStatusLoading.value = true
try {
const { data, error } = await client.get<EmailOAuthStatusResponse, unknown>({
url: `/email-providers/${curProviderId.value}/oauth/status`,
})
if (error) {
throw error
}
oauthStatus.value = data ?? null
} catch (error: any) {
oauthStatus.value = null
console.error('failed to fetch email oauth status', error)
} finally {
oauthStatusLoading.value = false
}
}
async function handleRevoke() {
if (!curProviderId.value) return
revokeLoading.value = true
try {
const { error } = await client.delete({
url: `/email-providers/${curProviderId.value}/oauth/token`,
})
if (error) throw error
toast.success(t('emailProvider.oauth.logoutSuccess'))
await fetchOAuthStatus()
} catch (error: any) {
toast.error(error?.message || t('emailProvider.oauth.logoutFailed'))
} finally {
revokeLoading.value = false
}
}
</script>
+18 -1
View File
@@ -41,6 +41,7 @@ import (
dbsqlc "github.com/memohai/memoh/internal/db/sqlc"
emailpkg "github.com/memohai/memoh/internal/email"
emailgeneric "github.com/memohai/memoh/internal/email/adapters/generic"
emailgmail "github.com/memohai/memoh/internal/email/adapters/gmail"
emailmailgun "github.com/memohai/memoh/internal/email/adapters/mailgun"
"github.com/memohai/memoh/internal/handlers"
"github.com/memohai/memoh/internal/healthcheck"
@@ -172,6 +173,7 @@ func runServe() {
inbox.NewService,
// email infrastructure
emailpkg.NewDBOAuthTokenStore,
provideEmailRegistry,
emailpkg.NewService,
emailpkg.NewOutboxService,
@@ -227,6 +229,7 @@ func runServe() {
provideServerHandler(handlers.NewEmailBindingsHandler),
provideServerHandler(handlers.NewEmailOutboxHandler),
provideServerHandler(handlers.NewEmailWebhookHandler),
provideServerHandler(provideEmailOAuthHandler),
provideServerHandler(handlers.NewMCPHandler),
provideServerHandler(handlers.NewMCPOAuthHandler),
provideOAuthService,
@@ -551,13 +554,27 @@ func provideWebHandler(channelManager *channel.Manager, channelStore *channel.St
// email providers
// ---------------------------------------------------------------------------
func provideEmailRegistry(log *slog.Logger) *emailpkg.Registry {
func provideEmailRegistry(log *slog.Logger, tokenStore *emailpkg.DBOAuthTokenStore) *emailpkg.Registry {
reg := emailpkg.NewRegistry()
reg.Register(emailgeneric.New(log))
reg.Register(emailmailgun.New(log))
reg.Register(emailgmail.New(log, tokenStore))
return reg
}
func provideEmailOAuthHandler(log *slog.Logger, service *emailpkg.Service, tokenStore *emailpkg.DBOAuthTokenStore, cfg config.Config) *handlers.EmailOAuthHandler {
addr := strings.TrimSpace(cfg.Server.Addr)
if addr == "" {
addr = ":8080"
}
host := addr
if strings.HasPrefix(host, ":") {
host = "localhost" + host
}
callbackURL := "http://" + host + "/email/oauth/callback"
return handlers.NewEmailOAuthHandler(log, service, tokenStore, callbackURL)
}
func provideEmailChatGateway(resolver *flow.Resolver, queries *dbsqlc.Queries, cfg config.Config, log *slog.Logger) emailpkg.ChatTriggerer {
return flow.NewEmailChatGateway(resolver, queries, cfg.Auth.JWTSecret, log)
}
+19 -1
View File
@@ -41,6 +41,7 @@ import (
dbsqlc "github.com/memohai/memoh/internal/db/sqlc"
emailpkg "github.com/memohai/memoh/internal/email"
emailgeneric "github.com/memohai/memoh/internal/email/adapters/generic"
emailgmail "github.com/memohai/memoh/internal/email/adapters/gmail"
emailmailgun "github.com/memohai/memoh/internal/email/adapters/mailgun"
"github.com/memohai/memoh/internal/handlers"
"github.com/memohai/memoh/internal/healthcheck"
@@ -146,6 +147,8 @@ func runServe() {
provideServerHandler(handlers.NewEmailBindingsHandler),
provideServerHandler(handlers.NewEmailOutboxHandler),
provideServerHandler(handlers.NewEmailWebhookHandler),
provideServerHandler(provideEmailOAuthHandler),
emailpkg.NewDBOAuthTokenStore,
provideServerHandler(handlers.NewMCPHandler),
provideServerHandler(handlers.NewMCPOAuthHandler),
provideOAuthService,
@@ -424,6 +427,7 @@ var (
"/api/docs",
"/channels/feishu/webhook/",
"/email/mailgun/webhook/",
"/email/oauth/callback",
}
memohSPABackendPrefixes = []string{
"/api",
@@ -613,13 +617,27 @@ func hasAnyPrefix(path string, prefixes []string) bool {
return false
}
func provideEmailRegistry(log *slog.Logger) *emailpkg.Registry {
func provideEmailRegistry(log *slog.Logger, tokenStore *emailpkg.DBOAuthTokenStore) *emailpkg.Registry {
reg := emailpkg.NewRegistry()
reg.Register(emailgeneric.New(log))
reg.Register(emailmailgun.New(log))
reg.Register(emailgmail.New(log, tokenStore))
return reg
}
func provideEmailOAuthHandler(log *slog.Logger, service *emailpkg.Service, tokenStore *emailpkg.DBOAuthTokenStore, cfg config.Config) *handlers.EmailOAuthHandler {
addr := strings.TrimSpace(cfg.Server.Addr)
if addr == "" {
addr = ":8080"
}
host := addr
if strings.HasPrefix(host, ":") {
host = "localhost" + host
}
callbackURL := "http://" + host + "/email/oauth/callback"
return handlers.NewEmailOAuthHandler(log, service, tokenStore, callbackURL)
}
func provideEmailChatGateway(resolver *flow.Resolver, queries *dbsqlc.Queries, cfg config.Config, log *slog.Logger) emailpkg.ChatTriggerer {
return flow.NewEmailChatGateway(resolver, queries, cfg.Auth.JWTSecret, log)
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS email_oauth_tokens;
@@ -0,0 +1,17 @@
-- 0028_email_oauth_tokens
-- Store OAuth2 tokens for Gmail email providers.
CREATE TABLE IF NOT EXISTS email_oauth_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email_provider_id UUID NOT NULL UNIQUE REFERENCES email_providers(id) ON DELETE CASCADE,
email_address TEXT NOT NULL DEFAULT '',
access_token TEXT NOT NULL DEFAULT '',
refresh_token TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ,
scope TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_email_oauth_tokens_state ON email_oauth_tokens(state) WHERE state != '';
+28
View File
@@ -0,0 +1,28 @@
-- name: UpsertEmailOAuthToken :one
INSERT INTO email_oauth_tokens (email_provider_id, email_address, access_token, refresh_token, expires_at, scope, state)
VALUES (sqlc.arg(email_provider_id), sqlc.arg(email_address), sqlc.arg(access_token), sqlc.arg(refresh_token), sqlc.arg(expires_at), sqlc.arg(scope), sqlc.arg(state))
ON CONFLICT (email_provider_id) DO UPDATE SET
email_address = EXCLUDED.email_address,
access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
expires_at = EXCLUDED.expires_at,
scope = EXCLUDED.scope,
state = EXCLUDED.state,
updated_at = now()
RETURNING *;
-- name: GetEmailOAuthTokenByProvider :one
SELECT * FROM email_oauth_tokens WHERE email_provider_id = sqlc.arg(email_provider_id);
-- name: GetEmailOAuthTokenByState :one
SELECT * FROM email_oauth_tokens WHERE state = sqlc.arg(state) AND state != '';
-- name: UpdateEmailOAuthState :exec
INSERT INTO email_oauth_tokens (email_provider_id, state)
VALUES (sqlc.arg(email_provider_id), sqlc.arg(state))
ON CONFLICT (email_provider_id) DO UPDATE SET
state = EXCLUDED.state,
updated_at = now();
-- name: DeleteEmailOAuthToken :exec
DELETE FROM email_oauth_tokens WHERE email_provider_id = sqlc.arg(email_provider_id);
+2 -1
View File
@@ -32,12 +32,14 @@ require (
github.com/wneessen/go-mail v0.7.2
go.uber.org/fx v1.24.0
golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.35.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cyphar.com/go-pathrs v0.2.3 // indirect
github.com/JohannesKaufmann/dom v0.2.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
@@ -128,7 +130,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
+3
View File
@@ -1,4 +1,7 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
+132
View File
@@ -0,0 +1,132 @@
// Hand-written sqlc-style queries for email_oauth_tokens.
package sqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const upsertEmailOAuthToken = `-- name: UpsertEmailOAuthToken :one
INSERT INTO email_oauth_tokens (email_provider_id, email_address, access_token, refresh_token, expires_at, scope, state)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (email_provider_id) DO UPDATE SET
email_address = EXCLUDED.email_address,
access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
expires_at = EXCLUDED.expires_at,
scope = EXCLUDED.scope,
state = EXCLUDED.state,
updated_at = now()
RETURNING id, email_provider_id, email_address, access_token, refresh_token, expires_at, scope, state, created_at, updated_at
`
type UpsertEmailOAuthTokenParams struct {
EmailProviderID pgtype.UUID `json:"email_provider_id"`
EmailAddress string `json:"email_address"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
Scope string `json:"scope"`
State string `json:"state"`
}
func (q *Queries) UpsertEmailOAuthToken(ctx context.Context, arg UpsertEmailOAuthTokenParams) (EmailOAuthToken, error) {
row := q.db.QueryRow(ctx, upsertEmailOAuthToken,
arg.EmailProviderID,
arg.EmailAddress,
arg.AccessToken,
arg.RefreshToken,
arg.ExpiresAt,
arg.Scope,
arg.State,
)
var i EmailOAuthToken
err := row.Scan(
&i.ID,
&i.EmailProviderID,
&i.EmailAddress,
&i.AccessToken,
&i.RefreshToken,
&i.ExpiresAt,
&i.Scope,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getEmailOAuthTokenByProvider = `-- name: GetEmailOAuthTokenByProvider :one
SELECT id, email_provider_id, email_address, access_token, refresh_token, expires_at, scope, state, created_at, updated_at
FROM email_oauth_tokens WHERE email_provider_id = $1
`
func (q *Queries) GetEmailOAuthTokenByProvider(ctx context.Context, providerID pgtype.UUID) (EmailOAuthToken, error) {
row := q.db.QueryRow(ctx, getEmailOAuthTokenByProvider, providerID)
var i EmailOAuthToken
err := row.Scan(
&i.ID,
&i.EmailProviderID,
&i.EmailAddress,
&i.AccessToken,
&i.RefreshToken,
&i.ExpiresAt,
&i.Scope,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getEmailOAuthTokenByState = `-- name: GetEmailOAuthTokenByState :one
SELECT id, email_provider_id, email_address, access_token, refresh_token, expires_at, scope, state, created_at, updated_at
FROM email_oauth_tokens WHERE state = $1 AND state != ''
`
func (q *Queries) GetEmailOAuthTokenByState(ctx context.Context, state string) (EmailOAuthToken, error) {
row := q.db.QueryRow(ctx, getEmailOAuthTokenByState, state)
var i EmailOAuthToken
err := row.Scan(
&i.ID,
&i.EmailProviderID,
&i.EmailAddress,
&i.AccessToken,
&i.RefreshToken,
&i.ExpiresAt,
&i.Scope,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateEmailOAuthState = `-- name: UpdateEmailOAuthState :exec
INSERT INTO email_oauth_tokens (email_provider_id, state)
VALUES ($1, $2)
ON CONFLICT (email_provider_id) DO UPDATE SET
state = EXCLUDED.state,
updated_at = now()
`
type UpdateEmailOAuthStateParams struct {
EmailProviderID pgtype.UUID `json:"email_provider_id"`
State string `json:"state"`
}
func (q *Queries) UpdateEmailOAuthState(ctx context.Context, arg UpdateEmailOAuthStateParams) error {
_, err := q.db.Exec(ctx, updateEmailOAuthState, arg.EmailProviderID, arg.State)
return err
}
const deleteEmailOAuthToken = `-- name: DeleteEmailOAuthToken :exec
DELETE FROM email_oauth_tokens WHERE email_provider_id = $1
`
func (q *Queries) DeleteEmailOAuthToken(ctx context.Context, providerID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteEmailOAuthToken, providerID)
return err
}
+13
View File
@@ -235,6 +235,19 @@ type EmailProvider struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type EmailOAuthToken struct {
ID pgtype.UUID `json:"id"`
EmailProviderID pgtype.UUID `json:"email_provider_id"`
EmailAddress string `json:"email_address"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
Scope string `json:"scope"`
State string `json:"state"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type LifecycleEvent struct {
ID string `json:"id"`
ContainerID string `json:"container_id"`
+518
View File
@@ -0,0 +1,518 @@
package gmail
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"math"
"strings"
"sync"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
sasl "github.com/emersion/go-sasl"
mail "github.com/wneessen/go-mail"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"github.com/memohai/memoh/internal/email"
)
const ProviderName email.ProviderName = "gmail"
const gmailScope = "https://mail.google.com/"
type Adapter struct {
logger *slog.Logger
tokenStore email.OAuthTokenStore
}
func New(log *slog.Logger, tokenStore email.OAuthTokenStore) *Adapter {
return &Adapter{
logger: log.With(slog.String("adapter", "gmail")),
tokenStore: tokenStore,
}
}
func (*Adapter) Type() email.ProviderName { return ProviderName }
func (*Adapter) Meta() email.ProviderMeta {
return email.ProviderMeta{
Provider: string(ProviderName),
DisplayName: "Gmail (OAuth2)",
ConfigSchema: email.ConfigSchema{
Fields: []email.FieldSchema{
{Key: "client_id", Type: "string", Title: "Client ID", Required: true, Order: 1},
{Key: "client_secret", Type: "secret", Title: "Client Secret", Required: true, Order: 2},
{Key: "email_address", Type: "string", Title: "Gmail Address", Required: true, Example: "you@gmail.com", Order: 3},
},
},
}
}
func (*Adapter) NormalizeConfig(raw map[string]any) (map[string]any, error) {
for _, key := range []string{"client_id", "client_secret", "email_address"} {
if v, _ := raw[key].(string); strings.TrimSpace(v) == "" {
return nil, fmt.Errorf("%s is required", key)
}
}
return raw, nil
}
func (*Adapter) AuthorizeURL(clientID, redirectURI, state string) string {
cfg := &oauth2.Config{
ClientID: clientID,
Scopes: []string{gmailScope},
Endpoint: google.Endpoint,
RedirectURL: redirectURI,
}
return cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
}
func (a *Adapter) ExchangeCode(ctx context.Context, config map[string]any, providerID, code, redirectURI string) error {
clientID, _ := config["client_id"].(string)
clientSecret, _ := config["client_secret"].(string)
emailAddress, _ := config["email_address"].(string)
cfg := &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: []string{gmailScope},
Endpoint: google.Endpoint,
RedirectURL: redirectURI,
}
tok, err := cfg.Exchange(ctx, code)
if err != nil {
return fmt.Errorf("gmail token exchange: %w", err)
}
return a.tokenStore.Save(ctx, email.OAuthToken{
ProviderID: providerID,
EmailAddress: emailAddress,
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
ExpiresAt: tok.Expiry,
Scope: gmailScope,
})
}
// ---- Sender ----
func (a *Adapter) Send(ctx context.Context, config map[string]any, msg email.OutboundEmail) (string, error) {
providerID, _ := config["_provider_id"].(string)
if providerID == "" {
return "", errors.New("gmail adapter: _provider_id missing from config")
}
accessToken, emailAddr, err := a.validToken(ctx, config, providerID)
if err != nil {
return "", err
}
m := mail.NewMsg()
if err := m.From(emailAddr); err != nil {
return "", fmt.Errorf("set from: %w", err)
}
if err := m.To(msg.To...); err != nil {
return "", fmt.Errorf("set to: %w", err)
}
m.Subject(msg.Subject)
if msg.HTML {
m.SetBodyString(mail.TypeTextHTML, msg.Body)
} else {
m.SetBodyString(mail.TypeTextPlain, msg.Body)
}
m.SetMessageID()
client, err := mail.NewClient("smtp.gmail.com",
mail.WithPort(587),
mail.WithTLSPolicy(mail.TLSMandatory),
mail.WithSMTPAuth(mail.SMTPAuthXOAUTH2),
mail.WithUsername(emailAddr),
mail.WithPassword(accessToken),
)
if err != nil {
return "", fmt.Errorf("create gmail smtp client: %w", err)
}
if err := client.DialAndSendWithContext(ctx, m); err != nil {
return "", fmt.Errorf("gmail send: %w", err)
}
return m.GetMessageID(), nil
}
// ---- Receiver (IMAP IDLE + poll fallback) ----
func (a *Adapter) StartReceiving(ctx context.Context, config map[string]any, handler email.InboundHandler) (email.Stopper, error) {
providerID, _ := config["_provider_id"].(string)
rctx, cancel := context.WithCancel(ctx)
conn := &gmailImapConn{
adapter: a,
config: config,
providerID: providerID,
handler: handler,
cancel: cancel,
logger: a.logger,
}
go conn.run(rctx)
return conn, nil
}
type gmailImapConn struct {
adapter *Adapter
config map[string]any
providerID string
handler email.InboundHandler
cancel context.CancelFunc
once sync.Once
lastUID imap.UID
logger *slog.Logger
}
func (c *gmailImapConn) Stop(_ context.Context) error {
c.once.Do(func() { c.cancel() })
return nil
}
func (c *gmailImapConn) run(ctx context.Context) {
for {
if err := c.connectAndReceive(ctx); err != nil {
if ctx.Err() != nil {
return
}
c.logger.Error("gmail imap error, retrying in 60s", slog.Any("error", err))
select {
case <-ctx.Done():
return
case <-time.After(60 * time.Second):
}
}
}
}
func (c *gmailImapConn) connectAndReceive(ctx context.Context) error {
client, err := c.adapter.dialIMAP(ctx, c.config)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
newMailCh := make(chan struct{}, 1)
notifyNewMail := func() {
select {
case newMailCh <- struct{}{}:
default:
}
}
_ = notifyNewMail
c.logger.Info("gmail imap connected, fetching initial messages")
c.fetchNewMessages(ctx, client)
idleCmd, idleErr := client.Idle()
if idleErr != nil {
c.logger.Warn("gmail IDLE not available, falling back to polling", slog.Any("error", idleErr))
return c.pollLoop(ctx, client)
}
c.logger.Info("gmail IDLE mode active")
checkInterval := 2 * time.Minute
for {
select {
case <-ctx.Done():
_ = idleCmd.Close()
return nil
case <-newMailCh:
_ = idleCmd.Close()
c.fetchNewMessages(ctx, client)
idleCmd, idleErr = client.Idle()
if idleErr != nil {
return c.pollLoop(ctx, client)
}
case <-time.After(checkInterval):
_ = idleCmd.Close()
c.fetchNewMessages(ctx, client)
idleCmd, idleErr = client.Idle()
if idleErr != nil {
return c.pollLoop(ctx, client)
}
}
}
}
func (c *gmailImapConn) pollLoop(ctx context.Context, client *imapclient.Client) error {
for {
c.fetchNewMessages(ctx, client)
select {
case <-ctx.Done():
return nil
case <-time.After(5 * time.Minute):
}
}
}
func (c *gmailImapConn) fetchNewMessages(ctx context.Context, client *imapclient.Client) {
var uidSet imap.UIDSet
if c.lastUID > 0 {
uidSet.AddRange(c.lastUID+1, 0)
} else {
uidSet.AddRange(1, 0)
}
fetchOpts := &imap.FetchOptions{
Envelope: true,
UID: true,
BodySection: []*imap.FetchItemBodySection{{}},
}
fetchCmd := client.Fetch(uidSet, fetchOpts)
defer func() { _ = fetchCmd.Close() }()
isFirstRun := c.lastUID == 0
processed := 0
for {
msgData := fetchCmd.Next()
if msgData == nil {
break
}
buf, err := msgData.Collect()
if err != nil || buf.Envelope == nil {
continue
}
if buf.UID > c.lastUID {
c.lastUID = buf.UID
}
if isFirstRun {
continue
}
inbound := bufToInbound(buf)
if inbound == nil {
continue
}
processed++
if err := c.handler(ctx, c.providerID, *inbound); err != nil {
c.logger.Error("inbound handler failed", slog.Any("error", err))
}
}
c.logger.Info("gmail imap fetch completed", slog.Int("processed", processed), slog.Uint64("last_uid", uint64(c.lastUID)))
}
// ---- MailboxReader ----
func (a *Adapter) ListMailbox(ctx context.Context, config map[string]any, page, pageSize int) ([]email.InboundEmail, int, error) {
client, err := a.dialIMAP(ctx, config)
if err != nil {
return nil, 0, fmt.Errorf("gmail imap connect: %w", err)
}
defer func() { _ = client.Close() }()
statusData, err := client.Status("INBOX", &imap.StatusOptions{NumMessages: true}).Wait()
if err != nil {
return nil, 0, fmt.Errorf("imap status: %w", err)
}
var total int
if statusData.NumMessages != nil {
total = int(*statusData.NumMessages)
}
if total == 0 {
return nil, 0, nil
}
end := total - (page * pageSize)
start := end - pageSize + 1
if start < 1 {
start = 1
}
if end < 1 {
return nil, total, nil
}
seqSet := imap.SeqSet{}
if start > math.MaxUint32 || end > math.MaxUint32 {
return nil, 0, fmt.Errorf("mail sequence range out of bounds: start=%d end=%d", start, end)
}
seqSet.AddRange(uint32(start), uint32(end))
fetchOpts := &imap.FetchOptions{Envelope: true, UID: true}
fetchCmd := client.Fetch(seqSet, fetchOpts)
defer func() { _ = fetchCmd.Close() }()
var results []email.InboundEmail
for {
msgData := fetchCmd.Next()
if msgData == nil {
break
}
buf, err := msgData.Collect()
if err != nil || buf.Envelope == nil {
continue
}
env := buf.Envelope
from := ""
if len(env.From) > 0 {
from = env.From[0].Addr()
}
results = append(results, email.InboundEmail{
MessageID: fmt.Sprintf("%d", buf.UID),
From: from,
Subject: env.Subject,
ReceivedAt: env.Date,
})
}
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
results[i], results[j] = results[j], results[i]
}
return results, total, nil
}
func (a *Adapter) ReadMailbox(ctx context.Context, config map[string]any, uid uint32) (*email.InboundEmail, error) {
client, err := a.dialIMAP(ctx, config)
if err != nil {
return nil, fmt.Errorf("gmail imap connect: %w", err)
}
defer func() { _ = client.Close() }()
uidSet := imap.UIDSet{}
uidSet.AddNum(imap.UID(uid))
fetchOpts := &imap.FetchOptions{
Envelope: true,
UID: true,
BodySection: []*imap.FetchItemBodySection{{}},
}
fetchCmd := client.Fetch(uidSet, fetchOpts)
defer func() { _ = fetchCmd.Close() }()
msgData := fetchCmd.Next()
if msgData == nil {
return nil, fmt.Errorf("email not found: UID %d", uid)
}
buf, err := msgData.Collect()
if err != nil || buf.Envelope == nil {
return nil, fmt.Errorf("failed to parse email UID %d", uid)
}
return bufToInbound(buf), nil
}
// ---- helpers ----
func (a *Adapter) dialIMAP(ctx context.Context, config map[string]any) (*imapclient.Client, error) {
providerID, _ := config["_provider_id"].(string)
if providerID == "" {
return nil, errors.New("gmail adapter: _provider_id missing from config")
}
accessToken, emailAddr, err := a.validToken(ctx, config, providerID)
if err != nil {
return nil, err
}
opts := &imapclient.Options{
TLSConfig: &tls.Config{ServerName: "imap.gmail.com"},
}
client, err := imapclient.DialTLS("imap.gmail.com:993", opts)
if err != nil {
return nil, fmt.Errorf("dial imap.gmail.com: %w", err)
}
saslClient := sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{
Username: emailAddr,
Token: accessToken,
})
if err := client.Authenticate(saslClient); err != nil {
_ = client.Close()
return nil, fmt.Errorf("gmail imap xoauth2: %w", err)
}
if _, err := client.Select("INBOX", nil).Wait(); err != nil {
_ = client.Close()
return nil, fmt.Errorf("select inbox: %w", err)
}
return client, nil
}
func (a *Adapter) validToken(ctx context.Context, config map[string]any, providerID string) (accessToken, emailAddr string, err error) {
stored, err := a.tokenStore.Get(ctx, providerID)
if err != nil {
return "", "", fmt.Errorf("gmail: no oauth token found (run OAuth authorization first): %w", err)
}
emailAddr = stored.EmailAddress
if emailAddr == "" {
emailAddr, _ = config["email_address"].(string)
}
if stored.AccessToken == "" || (!stored.ExpiresAt.IsZero() && time.Until(stored.ExpiresAt) < 2*time.Minute) {
newTok, refreshErr := a.refresh(ctx, config, stored.RefreshToken)
if refreshErr != nil {
return "", "", fmt.Errorf("gmail token refresh: %w", refreshErr)
}
_ = a.tokenStore.Save(ctx, email.OAuthToken{
ProviderID: providerID,
EmailAddress: emailAddr,
AccessToken: newTok.AccessToken,
RefreshToken: newTok.RefreshToken,
ExpiresAt: newTok.Expiry,
Scope: gmailScope,
})
return newTok.AccessToken, emailAddr, nil
}
return stored.AccessToken, emailAddr, nil
}
func (*Adapter) refresh(ctx context.Context, config map[string]any, refreshToken string) (*oauth2.Token, error) {
clientID, _ := config["client_id"].(string)
clientSecret, _ := config["client_secret"].(string)
cfg := &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: []string{gmailScope},
Endpoint: google.Endpoint,
}
src := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
return src.Token()
}
func bufToInbound(buf *imapclient.FetchMessageBuffer) *email.InboundEmail {
env := buf.Envelope
if env == nil {
return nil
}
var bodyText string
if len(buf.BodySection) > 0 {
bodyText = string(buf.BodySection[0].Bytes)
}
from := ""
if len(env.From) > 0 {
from = env.From[0].Addr()
}
var to []string
for _, addr := range env.To {
to = append(to, addr.Addr())
}
return &email.InboundEmail{
MessageID: fmt.Sprintf("%d", buf.UID),
From: from,
To: to,
Subject: env.Subject,
BodyText: bodyText,
ReceivedAt: env.Date,
}
}
var (
_ email.Adapter = (*Adapter)(nil)
_ email.Sender = (*Adapter)(nil)
_ email.Receiver = (*Adapter)(nil)
_ email.MailboxReader = (*Adapter)(nil)
)
+4
View File
@@ -156,6 +156,10 @@ func (m *Manager) SendEmail(ctx context.Context, botID string, providerID string
if err != nil {
return "", err
}
if config == nil {
config = make(map[string]any)
}
config["_provider_id"] = providerID
sender, err := m.service.registry.GetSender(providerName)
if err != nil {
+114
View File
@@ -0,0 +1,114 @@
package email
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/memohai/memoh/internal/db"
"github.com/memohai/memoh/internal/db/sqlc"
)
// OAuthToken holds a stored OAuth2 token for an email provider.
type OAuthToken struct {
ProviderID string `json:"provider_id"`
EmailAddress string `json:"email_address"`
AccessToken string `json:"access_token"` //nolint:gosec // encrypted at rest, needed for token refresh.
RefreshToken string `json:"refresh_token"` //nolint:gosec // encrypted at rest, needed for token refresh.
ExpiresAt time.Time `json:"expires_at"`
Scope string `json:"scope"`
}
// OAuthTokenStore persists and retrieves OAuth tokens for email providers.
type OAuthTokenStore interface {
Get(ctx context.Context, providerID string) (*OAuthToken, error)
Save(ctx context.Context, t OAuthToken) error
SetPendingState(ctx context.Context, providerID, state string) error
GetByState(ctx context.Context, state string) (*OAuthToken, error)
Delete(ctx context.Context, providerID string) error
}
// DBOAuthTokenStore is the DB-backed implementation of OAuthTokenStore.
type DBOAuthTokenStore struct {
queries *sqlc.Queries
}
func NewDBOAuthTokenStore(queries *sqlc.Queries) *DBOAuthTokenStore {
return &DBOAuthTokenStore{queries: queries}
}
func (s *DBOAuthTokenStore) Get(ctx context.Context, providerID string) (*OAuthToken, error) {
pgID, err := db.ParseUUID(providerID)
if err != nil {
return nil, err
}
row, err := s.queries.GetEmailOAuthTokenByProvider(ctx, pgID)
if err != nil {
return nil, fmt.Errorf("get oauth token: %w", err)
}
return s.toOAuthToken(row), nil
}
func (s *DBOAuthTokenStore) Save(ctx context.Context, t OAuthToken) error {
pgID, err := db.ParseUUID(t.ProviderID)
if err != nil {
return err
}
var expiresAt pgtype.Timestamptz
if !t.ExpiresAt.IsZero() {
expiresAt = pgtype.Timestamptz{Time: t.ExpiresAt, Valid: true}
}
_, err = s.queries.UpsertEmailOAuthToken(ctx, sqlc.UpsertEmailOAuthTokenParams{
EmailProviderID: pgID,
EmailAddress: t.EmailAddress,
AccessToken: t.AccessToken,
RefreshToken: t.RefreshToken,
ExpiresAt: expiresAt,
Scope: t.Scope,
State: "",
})
return err
}
func (s *DBOAuthTokenStore) SetPendingState(ctx context.Context, providerID, state string) error {
pgID, err := db.ParseUUID(providerID)
if err != nil {
return err
}
return s.queries.UpdateEmailOAuthState(ctx, sqlc.UpdateEmailOAuthStateParams{
EmailProviderID: pgID,
State: state,
})
}
func (s *DBOAuthTokenStore) GetByState(ctx context.Context, state string) (*OAuthToken, error) {
row, err := s.queries.GetEmailOAuthTokenByState(ctx, state)
if err != nil {
return nil, fmt.Errorf("get oauth token by state: %w", err)
}
return s.toOAuthToken(row), nil
}
func (s *DBOAuthTokenStore) Delete(ctx context.Context, providerID string) error {
pgID, err := db.ParseUUID(providerID)
if err != nil {
return err
}
return s.queries.DeleteEmailOAuthToken(ctx, pgID)
}
func (*DBOAuthTokenStore) toOAuthToken(row sqlc.EmailOAuthToken) *OAuthToken {
t := &OAuthToken{
ProviderID: row.EmailProviderID.String(),
EmailAddress: row.EmailAddress,
AccessToken: row.AccessToken,
RefreshToken: row.RefreshToken,
Scope: row.Scope,
}
if row.ExpiresAt.Valid {
t.ExpiresAt = row.ExpiresAt.Time
}
return t
}
+245
View File
@@ -0,0 +1,245 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/labstack/echo/v4"
"github.com/memohai/memoh/internal/email"
emailgmail "github.com/memohai/memoh/internal/email/adapters/gmail"
)
// EmailOAuthHandler handles the OAuth2 authorization flow for Gmail providers.
type EmailOAuthHandler struct {
service *email.Service
tokenStore email.OAuthTokenStore
callbackURL string
logger *slog.Logger
}
type emailOAuthStatusResponse struct {
Provider string `json:"provider"`
Configured bool `json:"configured"`
HasToken bool `json:"has_token"`
Expired bool `json:"expired"`
EmailAddress string `json:"email_address,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
func NewEmailOAuthHandler(log *slog.Logger, service *email.Service, tokenStore email.OAuthTokenStore, callbackURL string) *EmailOAuthHandler {
return &EmailOAuthHandler{
service: service,
tokenStore: tokenStore,
callbackURL: callbackURL,
logger: log.With(slog.String("handler", "email_oauth")),
}
}
func (h *EmailOAuthHandler) Register(e *echo.Echo) {
e.GET("/email-providers/:id/oauth/authorize", h.Authorize)
e.GET("/email-providers/:id/oauth/status", h.Status)
e.DELETE("/email-providers/:id/oauth/token", h.Revoke)
e.GET("/email/oauth/callback", h.Callback)
}
// Authorize godoc
// @Summary Start OAuth2 authorization for an email provider
// @Description Returns the authorization URL to redirect the user to
// @Tags email-oauth
// @Param id path string true "Email provider ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Router /email-providers/{id}/oauth/authorize [get].
func (h *EmailOAuthHandler) Authorize(c echo.Context) error {
providerID := strings.TrimSpace(c.Param("id"))
if providerID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "id is required")
}
provider, err := h.service.GetProvider(c.Request().Context(), providerID)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "provider not found")
}
state, err := generateState()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate state")
}
if err := h.tokenStore.SetPendingState(c.Request().Context(), providerID, state); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to store state")
}
var authURL string
if email.ProviderName(provider.Provider) == emailgmail.ProviderName {
clientID, _ := provider.Config["client_id"].(string)
if strings.TrimSpace(clientID) == "" {
return echo.NewHTTPError(http.StatusBadRequest, "client_id is not configured for this provider")
}
adapter := emailgmail.New(h.logger, h.tokenStore)
authURL = adapter.AuthorizeURL(clientID, h.callbackURL, state)
}
if authURL == "" {
return echo.NewHTTPError(http.StatusBadRequest, "provider does not support OAuth2")
}
return c.JSON(http.StatusOK, map[string]string{"auth_url": authURL})
}
// Callback godoc
// @Summary OAuth2 callback for email providers
// @Description Handles the OAuth2 callback, exchanges the code for tokens
// @Tags email-oauth
// @Param code query string true "Authorization code"
// @Param state query string true "State parameter"
// @Success 200 {object} map[string]string
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /email/oauth/callback [get].
func (h *EmailOAuthHandler) Callback(c echo.Context) error {
code := strings.TrimSpace(c.QueryParam("code"))
state := strings.TrimSpace(c.QueryParam("state"))
if code == "" {
return echo.NewHTTPError(http.StatusBadRequest, "code is required")
}
if state == "" {
return echo.NewHTTPError(http.StatusBadRequest, "state is required")
}
ctx := c.Request().Context()
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")
}
provider, err := h.service.GetProvider(ctx, stored.ProviderID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "provider not found")
}
if email.ProviderName(provider.Provider) != emailgmail.ProviderName {
return echo.NewHTTPError(http.StatusBadRequest, "provider does not support OAuth2")
}
adapter := emailgmail.New(h.logger, h.tokenStore)
if err := adapter.ExchangeCode(ctx, provider.Config, stored.ProviderID, code, h.callbackURL); err != nil {
h.logger.Error("gmail code exchange failed", slog.Any("error", err))
return echo.NewHTTPError(http.StatusInternalServerError, "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"})
}
// Status godoc
// @Summary Get OAuth2 status for an email provider
// @Tags email-oauth
// @Param id path string true "Email provider ID"
// @Success 200 {object} emailOAuthStatusResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Router /email-providers/{id}/oauth/status [get].
func (h *EmailOAuthHandler) Status(c echo.Context) error {
providerID := strings.TrimSpace(c.Param("id"))
if providerID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "id is required")
}
ctx := c.Request().Context()
provider, err := h.service.GetProvider(ctx, providerID)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "provider not found")
}
if !supportsEmailOAuth(email.ProviderName(provider.Provider)) {
return echo.NewHTTPError(http.StatusBadRequest, "provider does not support OAuth2")
}
resp := emailOAuthStatusResponse{
Provider: provider.Provider,
Configured: isProviderConfigured(provider),
}
token, err := h.tokenStore.Get(ctx, providerID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, resp)
}
h.logger.Error("email oauth status failed", slog.Any("error", err))
return echo.NewHTTPError(http.StatusInternalServerError, "failed to load oauth status")
}
resp.HasToken = token.AccessToken != ""
resp.EmailAddress = token.EmailAddress
if !token.ExpiresAt.IsZero() {
expiresAt := token.ExpiresAt
resp.ExpiresAt = &expiresAt
resp.Expired = time.Now().After(token.ExpiresAt)
}
return c.JSON(http.StatusOK, resp)
}
// Revoke godoc
// @Summary Revoke stored OAuth2 tokens for an email provider
// @Tags email-oauth
// @Param id path string true "Email provider ID"
// @Success 204 "No Content"
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Router /email-providers/{id}/oauth/token [delete].
func (h *EmailOAuthHandler) Revoke(c echo.Context) error {
providerID := strings.TrimSpace(c.Param("id"))
if providerID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "id is required")
}
ctx := c.Request().Context()
provider, err := h.service.GetProvider(ctx, providerID)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "provider not found")
}
if !supportsEmailOAuth(email.ProviderName(provider.Provider)) {
return echo.NewHTTPError(http.StatusBadRequest, "provider does not support OAuth2")
}
if err := h.tokenStore.Delete(ctx, providerID); err != nil {
h.logger.Error("email oauth revoke failed", slog.Any("error", err))
return echo.NewHTTPError(http.StatusInternalServerError, "failed to revoke oauth token")
}
return c.NoContent(http.StatusNoContent)
}
func supportsEmailOAuth(name email.ProviderName) bool {
return name == emailgmail.ProviderName
}
func isProviderConfigured(provider email.ProviderResponse) bool {
config := provider.Config
if config == nil {
config = map[string]any{}
}
if email.ProviderName(provider.Provider) != emailgmail.ProviderName {
return false
}
clientID, _ := config["client_id"].(string)
return strings.TrimSpace(clientID) != ""
}
func generateState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+23 -1
View File
@@ -205,7 +205,11 @@ func (e *Executor) callList(ctx context.Context, providerID string, args map[str
if err != nil {
return mcpgw.BuildToolErrorResult(err.Error()), nil
}
config = ensureProviderID(config, providerID)
return e.callListForProvider(ctx, providerName, config, args)
}
func (e *Executor) callListForProvider(ctx context.Context, providerName email.ProviderName, config map[string]any, args map[string]any) (map[string]any, error) {
reader, err := e.service.Registry().GetMailboxReader(providerName)
if err != nil {
return mcpgw.BuildToolErrorResult("mailbox listing not supported for this provider"), nil
@@ -259,13 +263,17 @@ func (e *Executor) callRead(ctx context.Context, providerID string, args map[str
if err != nil {
return mcpgw.BuildToolErrorResult(err.Error()), nil
}
config = ensureProviderID(config, providerID)
return e.callReadForProvider(ctx, providerName, config, uint32(uidRaw))
}
func (e *Executor) callReadForProvider(ctx context.Context, providerName email.ProviderName, config map[string]any, uid uint32) (map[string]any, error) {
reader, err := e.service.Registry().GetMailboxReader(providerName)
if err != nil {
return mcpgw.BuildToolErrorResult("mailbox reading not supported for this provider"), nil
}
item, err := reader.ReadMailbox(ctx, config, uint32(uidRaw))
item, err := reader.ReadMailbox(ctx, config, uid)
if err != nil {
return mcpgw.BuildToolErrorResult(err.Error()), nil
}
@@ -279,3 +287,17 @@ func (e *Executor) callRead(ctx context.Context, providerID string, args map[str
"received_at": item.ReceivedAt,
}), nil
}
func ensureProviderID(config map[string]any, providerID string) map[string]any {
if config == nil {
config = make(map[string]any)
} else {
copied := make(map[string]any, len(config)+1)
for k, v := range config {
copied[k] = v
}
config = copied
}
config["_provider_id"] = providerID
return config
}
+3
View File
@@ -87,5 +87,8 @@ func shouldSkipJWT(path string) bool {
if strings.HasPrefix(path, "/email/mailgun/webhook/") {
return true
}
if strings.HasPrefix(path, "/email/oauth/callback") {
return true
}
return false
}