mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-06-01 21:49:45 +08:00
feat: add AI email extraction with Cloudflare Workers AI
Add AI-powered email content extraction feature using Cloudflare Workers AI to automatically identify and extract important information from emails including verification codes, authentication links, service links, and subscription links. Features: - AI extraction with priority-based logic (auth_code > auth_link > service_link > subscription_link > other_link) - Admin allowlist configuration with wildcard support (*@example.com) - Frontend display in both email list (compact) and detail view (full mode) - Bilingual documentation (Chinese/English) - Database migration: add metadata field to raw_mails (v0.0.3 -> v0.0.4) Technical highlights: - Proper regex escaping for wildcard pattern matching - Content truncation to avoid AI token limits - Error handling that won't affect email receiving - JSON schema validation for AI responses - Type-safe TypeScript implementation - Vue I18n support with special character escaping References: - Inspired by Alle Project: https://github.com/bestruirui/Alle - Uses Cloudflare Workers AI JSON Mode 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
145
frontend/src/components/AiExtractInfo.vue
Normal file
145
frontend/src/components/AiExtractInfo.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ContentCopyOutlined, LinkRound, CodeRound } from '@vicons/material';
|
||||
import { useMessage } from 'naive-ui';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
authCode: 'Verification Code',
|
||||
authLink: 'Authentication Link',
|
||||
serviceLink: 'Service Link',
|
||||
subscriptionLink: 'Subscription Link',
|
||||
otherLink: 'Other Link',
|
||||
copySuccess: 'Copied successfully',
|
||||
copyFailed: 'Copy failed',
|
||||
open: 'Open',
|
||||
},
|
||||
zh: {
|
||||
authCode: '验证码',
|
||||
authLink: '认证链接',
|
||||
serviceLink: '服务链接',
|
||||
subscriptionLink: '订阅链接',
|
||||
otherLink: '其他链接',
|
||||
copySuccess: '复制成功',
|
||||
copyFailed: '复制失败',
|
||||
open: '打开',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
metadata: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const aiExtract = computed(() => {
|
||||
if (!props.metadata) return null;
|
||||
try {
|
||||
const data = JSON.parse(props.metadata);
|
||||
return data.ai_extract || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
if (!aiExtract.value) return '';
|
||||
const typeMap = {
|
||||
auth_code: t('authCode'),
|
||||
auth_link: t('authLink'),
|
||||
service_link: t('serviceLink'),
|
||||
subscription_link: t('subscriptionLink'),
|
||||
other_link: t('otherLink'),
|
||||
};
|
||||
return typeMap[aiExtract.value.type] || '';
|
||||
});
|
||||
|
||||
const typeIcon = computed(() => {
|
||||
if (!aiExtract.value) return null;
|
||||
const iconMap = {
|
||||
auth_code: CodeRound,
|
||||
auth_link: LinkRound,
|
||||
service_link: LinkRound,
|
||||
subscription_link: LinkRound,
|
||||
other_link: LinkRound,
|
||||
};
|
||||
return iconMap[aiExtract.value.type] || null;
|
||||
});
|
||||
|
||||
const isLink = computed(() => {
|
||||
return aiExtract.value && aiExtract.value.type !== 'auth_code';
|
||||
});
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (!aiExtract.value) return '';
|
||||
return aiExtract.value.result_text || aiExtract.value.result;
|
||||
});
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(aiExtract.value.result);
|
||||
message.success(t('copySuccess'));
|
||||
} catch (e) {
|
||||
message.error(t('copyFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const openLink = () => {
|
||||
if (isLink.value && aiExtract.value.result) {
|
||||
window.open(aiExtract.value.result, '_blank');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="aiExtract && aiExtract.result" class="ai-extract-info">
|
||||
<n-alert v-if="!compact" type="success" closable>
|
||||
<template #icon>
|
||||
<n-icon :component="typeIcon" />
|
||||
</template>
|
||||
<template #header>
|
||||
{{ typeLabel }}
|
||||
</template>
|
||||
<n-space align="center">
|
||||
<n-text v-if="aiExtract.type === 'auth_code'" strong style="font-size: 18px; font-family: monospace;">
|
||||
{{ aiExtract.result }}
|
||||
</n-text>
|
||||
<n-ellipsis v-else style="max-width: 400px;">
|
||||
{{ displayText }}
|
||||
</n-ellipsis>
|
||||
<n-button size="small" @click="copyToClipboard" tertiary>
|
||||
<template #icon>
|
||||
<n-icon :component="ContentCopyOutlined" />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button v-if="isLink" size="small" @click="openLink" tertiary type="primary">
|
||||
{{ t('open') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-alert>
|
||||
<n-tag v-else type="success" @click="copyToClipboard" style="cursor: pointer;" size="small">
|
||||
<template #icon>
|
||||
<n-icon :component="typeIcon" />
|
||||
</template>
|
||||
<n-ellipsis style="max-width: 150px;">
|
||||
{{ typeLabel }}: {{ displayText }}
|
||||
</n-ellipsis>
|
||||
</n-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-extract-info {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@ import { useIsMobile } from '../utils/composables'
|
||||
import { processItem } from '../utils/email-parser'
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import MailContentRenderer from "./MailContentRenderer.vue";
|
||||
import AiExtractInfo from "./AiExtractInfo.vue";
|
||||
|
||||
const message = useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
@@ -439,6 +440,7 @@ onBeforeUnmount(() => {
|
||||
TO: {{ row.address }}
|
||||
</n-ellipsis>
|
||||
</n-tag>
|
||||
<AiExtractInfo :metadata="row.metadata" compact />
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
@@ -513,6 +515,7 @@ onBeforeUnmount(() => {
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ row.address }}
|
||||
</n-tag>
|
||||
<AiExtractInfo :metadata="row.metadata" compact />
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from "vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from '@vicons/material'
|
||||
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
|
||||
import AiExtractInfo from "./AiExtractInfo.vue";
|
||||
import { getDownloadEmlUrl } from '../utils/email-parser';
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import { useGlobalState } from '../store';
|
||||
@@ -179,6 +180,9 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
</n-button>
|
||||
</n-space>
|
||||
|
||||
<!-- AI 提取信息 -->
|
||||
<AiExtractInfo :metadata="mail.metadata" />
|
||||
|
||||
<!-- 邮件内容 -->
|
||||
<div class="mail-content">
|
||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||
|
||||
@@ -26,6 +26,7 @@ import Webhook from './admin/Webhook.vue';
|
||||
import MailWebhook from './admin/MailWebhook.vue';
|
||||
import WorkerConfig from './admin/WorkerConfig.vue';
|
||||
import IpBlacklistSettings from './admin/IpBlacklistSettings.vue';
|
||||
import AiExtractSettings from './admin/AiExtractSettings.vue';
|
||||
|
||||
const {
|
||||
adminAuth, showAdminAuth, adminTab, loading,
|
||||
@@ -74,6 +75,7 @@ const { t } = useI18n({
|
||||
database: 'Database',
|
||||
workerconfig: 'Worker Config',
|
||||
ipBlacklistSettings: 'IP Blacklist',
|
||||
aiExtractSettings: 'AI Extract Settings',
|
||||
appearance: 'Appearance',
|
||||
about: 'About',
|
||||
ok: 'OK',
|
||||
@@ -103,6 +105,7 @@ const { t } = useI18n({
|
||||
database: '数据库',
|
||||
workerconfig: 'Worker 配置',
|
||||
ipBlacklistSettings: 'IP 黑名单',
|
||||
aiExtractSettings: 'AI 提取设置',
|
||||
appearance: '外观',
|
||||
about: '关于',
|
||||
ok: '确定',
|
||||
@@ -166,6 +169,9 @@ onMounted(async () => {
|
||||
<n-tab-pane name="ipBlacklistSettings" :tab="t('ipBlacklistSettings')">
|
||||
<IpBlacklistSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="aiExtractSettings" :tab="t('aiExtractSettings')">
|
||||
<AiExtractSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="webhook" :tab="t('webhookSettings')">
|
||||
<Webhook />
|
||||
</n-tab-pane>
|
||||
|
||||
125
frontend/src/views/admin/AiExtractSettings.vue
Normal file
125
frontend/src/views/admin/AiExtractSettings.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage } from 'naive-ui'
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
title: 'AI Email Extraction Settings',
|
||||
successTip: 'Success',
|
||||
save: 'Save',
|
||||
enableAllowList: 'Enable Address Allowlist',
|
||||
enableAllowListTip: 'When enabled, AI extraction will only process emails sent to addresses in the allowlist',
|
||||
allowList: 'Address Allowlist (Enter address and press Enter, wildcards supported)',
|
||||
allowListTip: "Wildcard * matches any characters, e.g. *{'@'}example.com matches all addresses under example.com domain",
|
||||
manualInputPrompt: 'Type and press Enter to add',
|
||||
disabledTip: 'When disabled, AI extraction will process all email addresses',
|
||||
},
|
||||
zh: {
|
||||
title: 'AI 邮件提取设置',
|
||||
successTip: '成功',
|
||||
save: '保存',
|
||||
enableAllowList: '启用地址白名单',
|
||||
enableAllowListTip: '启用后,AI 提取功能仅对白名单中的邮箱地址生效',
|
||||
allowList: '地址白名单 (请输入地址并回车,支持通配符)',
|
||||
allowListTip: "通配符 * 可匹配任意字符,如 *{'@'}example.com 可匹配 example.com 域名下的所有地址",
|
||||
manualInputPrompt: '输入后按回车键添加',
|
||||
disabledTip: '未启用时,所有邮箱地址都可使用 AI 提取功能',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type AiExtractSettings = {
|
||||
enableAllowList: boolean
|
||||
allowList: string[]
|
||||
}
|
||||
|
||||
const settings = ref<AiExtractSettings>({
|
||||
enableAllowList: false,
|
||||
allowList: []
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/ai_extract/settings`) as AiExtractSettings
|
||||
Object.assign(settings.value, res)
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/ai_extract/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(settings.value),
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :title="t('title')" :bordered="false" embedded style="max-width: 800px; overflow: auto;">
|
||||
<n-flex justify="end">
|
||||
<n-button @click="saveSettings" type="primary">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-form-item-row :label="t('enableAllowList')">
|
||||
<n-switch v-model:value="settings.enableAllowList" :round="false" />
|
||||
</n-form-item-row>
|
||||
|
||||
<n-alert v-if="!settings.enableAllowList" type="info" style="margin-bottom: 16px;">
|
||||
{{ t('disabledTip') }}
|
||||
</n-alert>
|
||||
|
||||
<div v-if="settings.enableAllowList">
|
||||
<n-alert type="warning" style="margin-bottom: 16px;">
|
||||
{{ t('enableAllowListTip') }}
|
||||
</n-alert>
|
||||
|
||||
<n-form-item-row :label="t('allowList')">
|
||||
<n-select v-model:value="settings.allowList" filterable multiple tag
|
||||
:placeholder="t('allowListTip')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-text depth="3" style="font-size: 12px;">
|
||||
{{ t('allowListTip') }}
|
||||
</n-text>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user