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:
Dream Hunter
2025-12-06 16:28:19 +08:00
committed by GitHub
parent a2a9f9e25f
commit dbb55d948f
27 changed files with 2473 additions and 1637 deletions
+145
View 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>
+3
View File
@@ -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>