diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index c5f7815b..0d2e4f19 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -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": { diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index e2ae97d4..3efc59cd 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -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": { diff --git a/apps/web/src/pages/email-providers/components/provider-setting.vue b/apps/web/src/pages/email-providers/components/provider-setting.vue index 29ae9b03..89e852bb 100644 --- a/apps/web/src/pages/email-providers/components/provider-setting.vue +++ b/apps/web/src/pages/email-providers/components/provider-setting.vue @@ -128,6 +128,67 @@ + +
+
+
+

+ {{ $t('emailProvider.oauth.title') }} +

+

+ {{ $t('emailProvider.oauth.description') }} +

+

+ + + + + +

+
+
+ + + {{ $t('emailProvider.oauth.authorize') }} + + + {{ $t('emailProvider.oauth.logout') }} + +
+
+
+
()) const curProviderId = computed(() => curProvider.value?.id) @@ -205,6 +278,14 @@ const orderedFields = computed(() => { return [...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) }) +const isOAuthProvider = computed(() => + OAUTH_PROVIDERS.includes(curProvider.value?.provider ?? ''), +) + +const oauthStatus = ref(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 }) => { 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({ + 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 + } +} diff --git a/cmd/agent/main.go b/cmd/agent/main.go index b234de27..dbabbcdb 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -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) } diff --git a/cmd/memoh/serve.go b/cmd/memoh/serve.go index fd351c9b..28e3e08b 100644 --- a/cmd/memoh/serve.go +++ b/cmd/memoh/serve.go @@ -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) } diff --git a/db/migrations/0028_email_oauth_tokens.down.sql b/db/migrations/0028_email_oauth_tokens.down.sql new file mode 100644 index 00000000..fa7c7fe0 --- /dev/null +++ b/db/migrations/0028_email_oauth_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS email_oauth_tokens; diff --git a/db/migrations/0028_email_oauth_tokens.up.sql b/db/migrations/0028_email_oauth_tokens.up.sql new file mode 100644 index 00000000..3b41f46e --- /dev/null +++ b/db/migrations/0028_email_oauth_tokens.up.sql @@ -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 != ''; diff --git a/db/queries/email_oauth_tokens.sql b/db/queries/email_oauth_tokens.sql new file mode 100644 index 00000000..e68dffd4 --- /dev/null +++ b/db/queries/email_oauth_tokens.sql @@ -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); diff --git a/go.mod b/go.mod index 6fbf6b72..2a745b11 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index ab9176a8..5d726cce 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/db/sqlc/email_oauth_tokens.sql.go b/internal/db/sqlc/email_oauth_tokens.sql.go new file mode 100644 index 00000000..a919cc92 --- /dev/null +++ b/internal/db/sqlc/email_oauth_tokens.sql.go @@ -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 +} diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index ae90cae5..29c84d69 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -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"` diff --git a/internal/email/adapters/gmail/adapter.go b/internal/email/adapters/gmail/adapter.go new file mode 100644 index 00000000..e58f94bb --- /dev/null +++ b/internal/email/adapters/gmail/adapter.go @@ -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) +) diff --git a/internal/email/manager.go b/internal/email/manager.go index 731844f8..2947d302 100644 --- a/internal/email/manager.go +++ b/internal/email/manager.go @@ -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 { diff --git a/internal/email/oauth_token_store.go b/internal/email/oauth_token_store.go new file mode 100644 index 00000000..7cf7aa2e --- /dev/null +++ b/internal/email/oauth_token_store.go @@ -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 +} diff --git a/internal/handlers/email_oauth.go b/internal/handlers/email_oauth.go new file mode 100644 index 00000000..d4598f7f --- /dev/null +++ b/internal/handlers/email_oauth.go @@ -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 +} diff --git a/internal/mcp/providers/email/provider.go b/internal/mcp/providers/email/provider.go index a217809d..875a1522 100644 --- a/internal/mcp/providers/email/provider.go +++ b/internal/mcp/providers/email/provider.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 041aa22b..3ad22601 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 }