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>