Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.0] - 2026-09-06

### Added

- Upgraded email extraction to a multi-value envelope: one email now yields every finding (verification code, each classified link), an expiry hint (e.g. "10 分钟内有效"), and SPF/DKIM/DMARC sender verdicts from Authentication-Results. Chinese verification keywords (验证码/校验码/动态码…), hyphen-joined codes, code-before-keyword order, and RFC 8058 List-Unsubscribe headers are recognized; bare digit runs without a nearby code keyword are rejected, killing price/date false positives. The AI fallback returns a validated JSON array and merges all findings. The API keeps the legacy `extraction` field (best finding) and adds `extractions`, `expiresHint`, and `authSummary`; stored rows from before the upgrade render unchanged. The message detail panel shows secondary findings, the expiry hint, and sender-auth badges.

## [0.3.0] - 2026-09-05

### Added
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wemail/docs",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wemail/web",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/features/inbox/MessageDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,27 @@ function getRemoteImageBlocks(bodyText: string) {
});
}

const authVerdictLabels: Record<string, string> = {
pass: "通过",
fail: "失败",
softfail: "软失败",
none: "无",
unknown: "未知"
};

type DetailViewModel = NonNullable<ReturnType<typeof toMessageDetailViewModel>>;

function buildAuthSummaryBadges(authSummary: NonNullable<DetailViewModel["authSummary"]>) {
return [
{ key: "SPF", verdict: authSummary.spf },
{ key: "DKIM", verdict: authSummary.dkim },
{ key: "DMARC", verdict: authSummary.dmarc }
].map((entry) => ({
...entry,
label: authVerdictLabels[entry.verdict] ?? entry.verdict
}));
}

function getExtractionInsight(viewModel: NonNullable<ReturnType<typeof toMessageDetailViewModel>>) {
if (viewModel.extraction.type === "auth_code" && viewModel.extraction.value.trim()) {
return {
Expand Down Expand Up @@ -248,6 +269,9 @@ export function MessageDetailPanel({ errorMessage = null, isLoading = false, onR
const hasExtractionValue = viewModel.extraction.value.trim().length > 0;
const copyLabel = viewModel.extraction.type === "auth_code" ? "复制验证码" : "复制提取值";
const extractionInsight = getExtractionInsight(viewModel);
const secondaryFindings = (viewModel.extractions ?? []).filter(
(item) => item.type !== viewModel.extraction.type || item.value !== viewModel.extraction.value
);
const ExtractionInsightIcon = extractionInsight.Icon as LucideIcon;
const retentionLabel = formatRetentionLabel(viewModel.expiresAt);
const linkRisk = extractionInsight.kind === "link" ? analyzeExtractionLink(viewModel.extraction.value) : null;
Expand Down Expand Up @@ -307,6 +331,16 @@ export function MessageDetailPanel({ errorMessage = null, isLoading = false, onR
{retentionLabel}
</span>
</div>
{viewModel.authSummary ? (
<div className="auth-summary-row" aria-label="发件人验证">
<span className="auth-summary-title">发件人验证</span>
{buildAuthSummaryBadges(viewModel.authSummary).map((badge) => (
<span className={`auth-verdict auth-verdict-${badge.verdict}`} key={badge.key}>
{badge.key} {badge.label}
</span>
))}
</div>
) : null}
<div className="extraction-card" aria-label="邮件识别结果">
<div className="extraction-card-primary">
<p>
Expand All @@ -326,6 +360,25 @@ export function MessageDetailPanel({ errorMessage = null, isLoading = false, onR
<span className="extraction-confidence-fill" style={{ width: `${extractionInsight.confidence}%` }} />
</span>
</div>
{viewModel.expiresHint ? (
<p className="extraction-card-expiry">
<Clock3 size={14} strokeWidth={1.9} aria-hidden="true" />
<span>验证码 {viewModel.expiresHint}</span>
</p>
) : null}
{secondaryFindings.length > 0 ? (
<div className="extraction-card-secondary" aria-label="其他提取结果">
<p>同时识别到</p>
<ul>
{secondaryFindings.map((item) => (
<li key={`${item.type}:${item.value}`} title={item.value}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</li>
))}
</ul>
</div>
) : null}
</div>
{linkRisk ? (
<div className={linkRisk.isRisky ? "link-risk-card warning" : "link-risk-card"} role={linkRisk.isRisky ? "alert" : undefined}>
Expand Down
98 changes: 98 additions & 0 deletions apps/web/src/shared/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -3646,6 +3646,104 @@ button:disabled {
grid-column: 1 / -1;
}

/* Extraction envelope additions: expiry hint, secondary findings, sender auth. */
.extraction-card-expiry {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
color: var(--text-muted);
font-size: 0.86rem;
}

.extraction-card-secondary {
display: grid;
gap: 6px;
border-top: 1px solid var(--border);
padding-top: 10px;
}

.extraction-card-secondary > p {
margin: 0;
color: var(--text-soft);
font-size: 0.78rem;
letter-spacing: 0.04em;
}

.extraction-card-secondary ul {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}

.extraction-card-secondary li {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
font-size: 0.86rem;
}

.extraction-card-secondary li span {
flex-shrink: 0;
color: var(--text-muted);
}

.extraction-card-secondary li strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
text-align: left;
}

.auth-summary-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 0.84rem;
}

.auth-summary-title {
color: var(--text-soft);
}

.auth-verdict {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-pill, 999px);
border: 1px solid var(--border);
color: var(--text-muted);
white-space: nowrap;
}

.auth-verdict-pass {
border-color: color-mix(in srgb, #20c997 42%, var(--border));
color: #158f6b;
background: var(--success-soft, transparent);
}

.auth-verdict-fail,
.auth-verdict-softfail {
border-color: color-mix(in srgb, #e03131 42%, var(--border));
color: #c92a2a;
background: var(--warning-soft, transparent);
}

:root[data-theme="dark"] .auth-verdict-pass {
color: #4ddbb1;
}

:root[data-theme="dark"] .auth-verdict-fail,
:root[data-theme="dark"] .auth-verdict-softfail {
color: #ff8787;
}

.inbox-detail-panel .extraction-card {
grid-template-columns: minmax(0, 1fr) minmax(132px, 0.28fr);
align-items: center;
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/test/integration/inbox-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ const mockInboxMessages: MessageSummary[] = [
previewText: "Open the login link",
bodyText: "Open the login link",
extraction: { method: "regex", type: "auth_link", value: "https://contoso.test/magic", label: "登录链接" },
extractions: [
{ method: "regex", type: "auth_link", value: "https://contoso.test/magic", label: "登录链接", source: "body" },
{ method: "regex", type: "subscription_link", value: "https://contoso.test/unsubscribe", label: "Unsubscribe (header)", source: "header" }
],
expiresHint: "10 分钟内有效",
authSummary: { spf: "pass", dkim: "pass", dmarc: "pass", raw: null },
oversizeStatus: null,
attachmentCount: 1,
attachments: [
Expand Down Expand Up @@ -723,6 +729,11 @@ describe("mail list integration", () => {
const extractedLink = within(extractionCard).getByText("https://contoso.test/magic");

expect(within(extractionCard).getByText(/^识别到链接$/i)).toBeInTheDocument();
expect(within(extractionCard).getByText(/^同时识别到$/i)).toBeInTheDocument();
expect(within(extractionCard).getByText(/contoso.test\/unsubscribe/)).toBeInTheDocument();
expect(within(extractionCard).getByText(/验证码 10 分钟内有效/)).toBeInTheDocument();
expect(within(screen.getByRole("region", { name: /^阅读与提取详情$/i })).getByText("SPF 通过")).toBeInTheDocument();
expect(within(screen.getByRole("region", { name: /^阅读与提取详情$/i })).getByText("DMARC 通过")).toBeInTheDocument();
expect(extractedLink).toHaveClass("extraction-card-value-link");
expect(extractedLink).toHaveAttribute("title", "https://contoso.test/magic");
expect(within(extractionCard).queryByText(/^登录链接$/i)).not.toBeInTheDocument();
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wemail/worker",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
39 changes: 28 additions & 11 deletions apps/worker/src/app/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { parseAccountPolicyRecord } from "@wemail/shared";

import type { AppBindings, AppStore, MailboxRecord, PersistedMessageRecord } from "../core/bindings";
import { buildExtraction, createPreview, maybeRunAiFallback, parseRawEmail } from "../shared/mail";
import { buildMessageExtraction, createPreview, maybeRunAiFallback, parseRawEmail } from "../shared/mail";
import { recordAudit } from "./services/audit-service";
import { defaultFeatureToggles } from "./services/config-service";
import { getRuntimeSettings } from "./services/runtime-settings-service";
Expand Down Expand Up @@ -117,7 +117,7 @@ async function saveInboundMessage(
text: string;
attachments: Array<{ filename: string; contentType: string; data: Uint8Array; size: number }>;
};
extraction: ReturnType<typeof buildExtraction>;
extraction: ReturnType<typeof buildMessageExtraction>;
}
) {
const duplicate = await findRecentDuplicateMessage(store, input);
Expand Down Expand Up @@ -169,13 +169,20 @@ async function processInboundForMailbox(
toAddress: string,
parsed: {
messageId?: string | null;
listUnsubscribe?: string | null;
authenticationResults?: string | null;
fromAddress: string;
subject: string;
text: string;
attachments: Array<{ filename: string; contentType: string; data: Uint8Array; size: number }>;
}
) {
let extraction = buildExtraction(parsed.subject, parsed.text);
let extraction = buildMessageExtraction({
subject: parsed.subject,
bodyText: parsed.text,
listUnsubscribe: parsed.listUnsubscribe,
authenticationResults: parsed.authenticationResults
});
const featureToggles = await getFeatureToggles(store, env);
const settings = await getRuntimeSettings(store, env);
const aiUsageToday = await store.audit.countByActorSince(
Expand All @@ -184,9 +191,9 @@ async function processInboundForMailbox(
`${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`
);

if (featureToggles.aiEnabled && extraction.type === "none" && aiUsageToday < settings.ai.fallbackLimit) {
extraction = (await maybeRunAiFallback(env, extraction, parsed.text)) as typeof extraction;
if (extraction.method === "ai") {
if (featureToggles.aiEnabled && extraction.primary.type === "none" && aiUsageToday < settings.ai.fallbackLimit) {
extraction = await maybeRunAiFallback(env, extraction, parsed.text);
if (extraction.primary.method === "ai") {
await recordAudit(store, "user", mailbox.userId, "ai-fallback", { mailboxId: mailbox.id });
}
}
Expand Down Expand Up @@ -223,21 +230,26 @@ async function processInboundForMailbox(
subject: parsed.subject
});

if (extraction.type !== "none") {
if (extraction.primary.type !== "none") {
await sendTelegramNotification(
{ store, env, featureToggles },
{
userId: mailbox.userId,
eventId: "message.extraction.detected",
text: `Extracted result for ${mailbox.address}\n${extraction.label}: ${extraction.value}\nSubject: ${parsed.subject}`,
metadata: { mailboxId: mailbox.id, messageId: message.id, extractionType: extraction.type }
text: `Extracted result for ${mailbox.address}\n${extraction.primary.label}: ${extraction.primary.value}\nSubject: ${parsed.subject}`,
metadata: { mailboxId: mailbox.id, messageId: message.id, extractionType: extraction.primary.type }
}
);
// extraction (primary) keeps the legacy single-result contract; the
// envelope fields carry every finding plus expiry and auth verdicts.
await sendWebhookEventToUser(store, mailbox.userId, "message.extracted", {
mailboxAddress: mailbox.address,
mailboxId: mailbox.id,
messageId: message.id,
extraction
extraction: extraction.primary,
extractions: extraction.items,
expiresHint: extraction.expiresHint,
authSummary: extraction.authSummary
});
}

Expand All @@ -264,7 +276,12 @@ export async function processInboundEmail(
mailboxId: buildUnmatchedMailboxId(toAddress),
toAddress,
parsed,
extraction: buildExtraction(parsed.subject, parsed.text)
extraction: buildMessageExtraction({
subject: parsed.subject,
bodyText: parsed.text,
listUnsubscribe: parsed.listUnsubscribe,
authenticationResults: parsed.authenticationResults
})
});
return unmatchedMessage;
}
Expand Down
10 changes: 8 additions & 2 deletions apps/worker/src/infrastructure/persistence/d1/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,11 +326,17 @@ export function createMailAggregate(db: D1Database): MailAggregate {
bindings.push(searchLike, searchLike, searchLike, searchLike, searchLike, searchLike);
}

// extraction_json holds the envelope ($.primary.type) or a legacy
// single result ($.type); COALESCE reads both shapes.
if (query.filter === "code") {
whereConditions.push("json_extract(extraction_json, '$.type') = 'auth_code'");
whereConditions.push(
"COALESCE(json_extract(extraction_json, '$.primary.type'), json_extract(extraction_json, '$.type')) = 'auth_code'"
);
}
if (query.filter === "link") {
whereConditions.push("json_extract(extraction_json, '$.type') NOT IN ('auth_code', 'none')");
whereConditions.push(
"COALESCE(json_extract(extraction_json, '$.primary.type'), json_extract(extraction_json, '$.type')) NOT IN ('auth_code', 'none')"
);
}
if (query.filter === "attachment") {
whereConditions.push("attachment_count > 0");
Expand Down
Loading
Loading