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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ jobs:
- name: Version check
run: pnpm version:check

- name: Migration numbering check
run: node scripts/check-migration-numbering.mjs

- name: API interface catalog check
run: pnpm api-catalog:check

- name: Test
run: pnpm test

Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.10] - 2026-09-05

### Fixed

- Scheduled the hourly retention cleanup on staging and production (it previously had no Cloudflare cron trigger, so expired messages and R2 attachments accumulated forever) and made the sweep index-backed, batched, and parallel for attachment deletes (D1 migration 0020).
- Stopped the cleanup and batch-delete paths from exceeding Cloudflare D1's 100 bound-parameter limit on large id lists, which previously made the hourly cleanup fail outright on any backlog over 100 messages.
- Cut every authenticated request from four D1 session round-trips to two by throttling last-seen updates to once per minute.
- Superseded inbox refreshes no longer surface "request aborted" errors: stale requests are cancelled (also on unmount), and callers sharing a deduplicated GET no longer inherit each other's aborts.
- Announcement keyword search in the D1 backend now matches the JS source of truth exactly — multi-word keywords across title/summary/tags, tags containing JSON punctuation, and literal `%` / `_` characters — and empty-string start/end dates are normalized so the board list and detail view agree (D1 migration 0021).

### Changed

- Announcement list filtering, pagination, and status summaries now run in D1 SQL instead of scanning the whole table into JS on every board render.
- Split the 2,341-line D1 persistence module, the 1,699-line form primitives module, and the 1,449-line webhook console into per-aggregate / per-widget modules with unchanged public entry points, verified byte-identical against the originals.
- CI now enforces migration numbering uniqueness and API interface catalog freshness on every pull request.

### Security

- Local development CORS origins are only honored when `ENVIRONMENT=local`; staging and production no longer unconditionally allow them.
- OAuth start/callback/finalize endpoints are now behind the shared rate limiter, like login and registration.

## [0.2.9] - 2026-08-08

### Changed
Expand Down
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,13 @@ Dependency direction: `app → pages → features → shared`. Pages must not de

| Layer | Purpose |
|---|---|
| `app/` | Route registration, request/response mapping, use case orchestration (`routes/`, `use-cases/`, `services/`, `mappers/`) |
| `app/` | Request middleware (auth/CORS/rate limit in `create-app.ts`), use case orchestration (`use-cases/`, `services/`, `mappers/`), runtime entry (`runtime.ts`); legacy `routes/` holds only shared DTO/request parsers |
| `modules/` | HTTP route registration per menu domain (`modules/<domain>/routes.ts`) — wired by `modules/register-modules.ts` |
| `core/` | Type contracts, Cloudflare bindings, context definitions — no implementations |
| `infrastructure/` | D1 database, R2 storage, external service integrations |
| `shared/` | Email parsing (postal-mime), security utilities, pure helpers |

Dependency direction: `app → core/infrastructure/shared`. Infrastructure must not depend on `app/`.
Dependency direction: `app → modules → core/infrastructure/shared`. Infrastructure must not depend on `app/`.

### Shared package (`packages/shared/`)

Expand Down Expand Up @@ -130,9 +131,9 @@ Not allowed: DOM operations, Cloudflare bindings, database logic, runtime-specif
- Backend runs on **Cloudflare Workers** (not Node.js) — no Node built-ins, use Web APIs. `nodejs_compat` flag is on, but prefer Web APIs
- Database: **D1** (SQLite-compatible), Object storage: **R2**
- Email inbound processing via Cloudflare Email Routing
- Scheduled cleanup tasks via Cloudflare Cron Triggers
- Scheduled cleanup tasks via Cloudflare Cron Triggers (`[env.*.triggers] crons = ["0 * * * *"]` in wrangler.toml, hourly)
- Deploy environments are split in `apps/worker/wrangler.toml`: `default` (local), `env.staging`, `env.production` — each points at its own D1 instance and vars
- Feature flags live in wrangler vars (`ENABLE_AI`, `ENABLE_TELEGRAM`, `ENABLE_OUTBOUND`, `ENABLE_MAILBOX_CREATION`) and gate code paths; honor them when adding new behavior
- Feature flags (`ENABLE_AI`, `ENABLE_TELEGRAM`, `ENABLE_OUTBOUND`, `ENABLE_MAILBOX_CREATION`) are code-level *defaults* parsed from wrangler vars in `core/config.ts`; the live values come from D1 `system_settings` (edited in the admin UI, cached in KV). Gate new behavior behind the resolved feature toggles, not the raw env vars

**Database migrations:**
- Migration files live in `apps/worker/src/infrastructure/db/migrations/`
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.2.9",
"version": "0.2.10",
"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.2.9",
"version": "0.2.10",
"private": true,
"type": "module",
"scripts": {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/features/inbox/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function normalizeMessageListQuery(query?: MessageListQueryInput | string | null
return query ?? {};
}

export function fetchMessages(query?: MessageListQueryInput | string | null) {
export function fetchMessages(query?: MessageListQueryInput | string | null, options?: { signal?: AbortSignal }) {
const normalizedQuery = normalizeMessageListQuery(query);
const params = new URLSearchParams();
const search = normalizedQuery.search?.trim();
Expand All @@ -102,7 +102,7 @@ export function fetchMessages(query?: MessageListQueryInput | string | null) {
if (typeof normalizedQuery.hasAttachment === "boolean") params.set("hasAttachment", String(normalizedQuery.hasAttachment));
if (normalizedQuery.extractionType) params.set("extractionType", normalizedQuery.extractionType);

return apiFetch<MessageListResponse>(`/api/mail/messages?${params.toString()}`);
return apiFetch<MessageListResponse>(`/api/mail/messages?${params.toString()}`, { signal: options?.signal });
}

export function fetchMessageDetail(messageId: string) {
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/features/inbox/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ export async function queryMailboxOptions(query: MailboxListQueryInput): Promise
};
}

export async function queryMessages(query?: MessageListQueryInput | string | null): Promise<MessageListResult> {
const payload = await fetchMessages(query);
export async function queryMessages(
query?: MessageListQueryInput | string | null,
options?: { signal?: AbortSignal }
): Promise<MessageListResult> {
const payload = await fetchMessages(query, options);
const messages = (payload.messages ?? []) as MessageSummary[];
const summary = payload.summary ?? (messages.length > 0 ? summarizeMessages(messages) : emptyMessageSummary);

Expand Down
21 changes: 20 additions & 1 deletion apps/web/src/features/inbox/useInboxWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export function useInboxWorkspace({
const [isLoadingOutbound, setIsLoadingOutbound] = useState(false);
const [outboundError, setOutboundError] = useState<string | null>(null);
const messagesRequestIdRef = useRef(0);
const messagesAbortControllerRef = useRef<AbortController | null>(null);
const messageDetailRequestIdRef = useRef(0);
const selectedMessageIdRef = useRef<string | null>(selectedMessageId);

Expand All @@ -114,6 +115,14 @@ export function useInboxWorkspace({
selectedMessageIdRef.current = selectedMessageId;
}, [selectedMessageId]);

// Unmount cancels the in-flight message request; the requestId guard already
// ignores late state updates, this also stops the network work itself.
useEffect(() => {
return () => {
messagesAbortControllerRef.current?.abort();
};
}, []);

const refreshMailboxes = useCallback(
async (nextSelectedMailboxId?: string | null) => {
if (!enabled) return;
Expand All @@ -127,10 +136,17 @@ export function useInboxWorkspace({
async (query?: MessageListQueryInput | string | null) => {
const requestId = messagesRequestIdRef.current + 1;
messagesRequestIdRef.current = requestId;
// Cancel the superseded request so query changes and polling ticks do not
// leave stale responses racing the newest one.
messagesAbortControllerRef.current?.abort();
const abortController = new AbortController();
messagesAbortControllerRef.current = abortController;
setIsLoadingMessages(true);

try {
const result = await queryMessages(normalizeMessageQuery(query, selectedMailboxId));
const result = await queryMessages(normalizeMessageQuery(query, selectedMailboxId), {
signal: abortController.signal
});
if (messagesRequestIdRef.current !== requestId) return;
const previousSelectedMessageId = selectedMessageIdRef.current;
const nextSelectedMessageId = result.messages.some((message) => message.id === previousSelectedMessageId)
Expand All @@ -147,6 +163,9 @@ export function useInboxWorkspace({
setMessageListError(null);
} catch (error) {
if (messagesRequestIdRef.current !== requestId) return;
// Aborts are never user-facing errors: either a newer refresh
// superseded this one, or the shared request was cancelled elsewhere.
if (abortController.signal.aborted || (error instanceof Error && error.name === "AbortError")) return;
setMessageListError(error instanceof Error ? error.message : "邮件列表加载失败");
} finally {
if (messagesRequestIdRef.current === requestId) setIsLoadingMessages(false);
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/features/settings/WebhookCodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Copy } from "lucide-react";

import { Button } from "../../shared/button";

export type WebhookCodeBlockProps = {
copied: boolean;
copyLabel: string;
label: string;
onCopy: () => void;
value: string;
};


export function WebhookCodeBlock({ copied, copyLabel, label, onCopy, value }: WebhookCodeBlockProps) {
return (
<article className="webhook-code-card">
<div className="webhook-code-header">
<span>{label}</span>
<Button
aria-label={copyLabel}
leadingIcon={<Copy size={14} strokeWidth={1.9} />}
onClick={onCopy}
size="sm"
variant="secondary"
>
{copied ? "已复制" : "复制"}
</Button>
</div>
<pre>
<code>{value}</code>
</pre>
</article>
);
}
91 changes: 91 additions & 0 deletions apps/web/src/features/settings/WebhookDeliveryDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { RotateCw } from "lucide-react";

import { Button } from "../../shared/button";
import { WebhookCodeBlock } from "./WebhookCodeBlock";
import { OverlayDialog } from "../../shared/overlay";
import {
formatDate,
formatJson,
getEventLabel,
isSuccessfulDelivery,
normalizeStatus,
type WebhookDeliveryDialogProps
} from "./webhook-content";

export function WebhookDeliveryDialog({
copiedToken,
delivery,
isDeliveryLoading,
onClose,
onCopy,
onRetry
}: WebhookDeliveryDialogProps) {
return (
<OverlayDialog
className="webhook-create-dialog webhook-delivery-dialog"
closeOnBackdrop
description="查看本次投递的事件、状态、Payload 和目标服务响应。"
eyebrow="投递详情"
footer={
<div className="workspace-dialog-actions integration-inline-actions webhook-dialog-actions">
{!isSuccessfulDelivery(delivery) ? (
<Button
disabled={isDeliveryLoading}
leadingIcon={<RotateCw size={15} strokeWidth={1.9} />}
onClick={() => void onRetry(delivery)}
variant="secondary"
>
重试投递
</Button>
) : null}
<Button onClick={onClose} variant="primary">
关闭
</Button>
</div>
}
onClose={onClose}
size="lg"
title={getEventLabel(delivery.eventType)}
>
<div className="webhook-delivery-detail-grid">
<article className="integration-stat-row">
<strong>投递状态</strong>
<span>{normalizeStatus(delivery.status)}</span>
</article>
<article className="integration-stat-row">
<strong>状态码</strong>
<span>{delivery.statusCode ?? "无状态码"}</span>
</article>
<article className="integration-stat-row">
<strong>耗时</strong>
<span>{delivery.durationMs === null ? "未记录耗时" : `${delivery.durationMs} ms`}</span>
</article>
<article className="integration-stat-row">
<strong>创建时间</strong>
<span>{formatDate(delivery.createdAt)}</span>
</article>
</div>
{delivery.errorText ? (
<p className="error-banner webhook-error-banner" role="alert">
{delivery.errorText}
</p>
) : null}
<div className="webhook-reference-grid">
<WebhookCodeBlock
copied={copiedToken === "delivery-payload"}
copyLabel="复制投递 Payload"
label="Payload"
onCopy={() => void onCopy("delivery-payload", formatJson(delivery.payload))}
value={formatJson(delivery.payload)}
/>
<WebhookCodeBlock
copied={copiedToken === "delivery-response"}
copyLabel="复制目标响应"
label="Response"
onCopy={() => void onCopy("delivery-response", delivery.responseText ?? "")}
value={delivery.responseText || "目标服务没有返回响应体。"}
/>
</div>
</OverlayDialog>
);
}
Loading
Loading