内部说明文档 · 正文统一使用中文

邮箱、日历与会议联动开发说明

本文面向产品、前端、服务端与邮件集成开发,整理自「日历邮箱联动设计」对话及补充的联动时序说明。目标是说明 teleMail 多邮箱账号、统一日历与独立会议模块应该如何协作,而不是复刻某一家邮箱产品的界面。

本文的数据模型和服务接口是开发设计建议,不代表已经上线或已经批准建设后端。当前仓库仍为静态、无持久化的原型;实际已实现范围见第九节。未确定的产品规则集中列在第十一节,开发不应自行补齐这些决策。

一、先回答:两个邮箱收到同一邀请,日历显示几条?

建议采用「统一日历视图 + 多邮箱身份 + 邀请关联去重」:同一用户的工作邮箱 A、个人邮箱 B 收到同一个日程邀请,统一日历显示一个日程块,详情保留两份来源和两个参与身份的状态。

查看流程图源码与文本描述
flowchart LR
mailA["工作邮箱 A 的邀请"] -->|"同一可信 UID"| event["统一事件 E1001"]
mailB["个人邮箱 B 的邀请"] -->|"同一可信 UID"| event
event --> entry["用户日历:一个日程块"]
entry --> identityA["身份 A:已接受"]
entry --> identityB["身份 B:未回应"]

图示为两个不同参与地址都属于当前用户的例子;如果只是同一个别名的两份投递,则仍只有一个参与身份。

关键区别:邮件是传输记录,事件是日程事实,参与者是回应身份,用户日历是展示关系。四者不能合成一张「邮件事件表」。

二、领域边界:事件、会议、邮件分别负责什么?

查看流程图源码与文本描述
flowchart LR
message["邮件:邀请和变更记录"] -->|"解析与来源验证"| event["日历事件:时间与参与关系"]
event -->|"供用户查看"| view["统一日历视图"]
event -.->|"可选 meetingId"| meeting["内部会议能力"]
event -.->|"可选外部链接"| external["外部会议能力"]

实线表示信息处理/展示,虚线表示可选关联。这是逻辑职责图,不代表已经建设了这些生产服务。

日历事件:时间安排的主体

事件负责标题、开始/结束时间、时区、组织者、参与者、重复规则、修订版本和取消状态。普通提醒、线下活动、会议都可以对应事件;一个事件不一定有会议能力。

会议:可选的业务能力

会议模块负责会议室、加入会议、内部聊天、会议纪要等能力,通过 meetingId 与事件关联。事件是安排,会议是能力,两者不要互相复制时间与参与状态作为双主数据。

外部邀请中出现 Teams 等加入链接,不意味着必须创建 teleMail 内部会议。可保存外部会议提供方及加入链接;内部会议 ID 仍为空。外链需要协议和安全校验,不能执行邀请中的脚本或任意 URI。

邮件:邀请与回应的传输通道

邮件模块负责多个邮箱账号授权、收件、解析日历 MIME 内容、发出邀请或回应。日历调度层识别新邀请、更新、回应和取消,再把结果提供给统一日历。普通 .ics 附件可能仅供导入,不能仅凭文件扩展名就认定它是要求回复的会议邀请。

内部创建的活动先生成事件,再通过邮件邀请外部参与者;外部发来的邀请直接进入收件处理流程,不要求先经过内部会议模块。

三、身份与去重规则

四种 ID,不要混用

重复系列由 UID 识别,单次实例还需要 RECURRENCE-ID。某次会议从周五改到周四时,实例标识仍指向原定周五,而不是改后的开始时间。SEQUENCE 属于版本,不属于恒定的事件身份。协议依据:RFC 5545。

先验证来源,再建立关联

查看流程图源码与文本描述
flowchart TD
received["收到日历邮件"] --> parse["解析方法、UID、实例与版本"]
parse --> trusted{"来源可信且有访问权限?"}
trusted -->|"否或无法确认"| conflict["隔离记录,不覆盖已有事件"]
trusted -->|"是"| matched{"隔离范围内匹配同一事件?"}
matched -->|"组织者冲突"| conflict
matched -->|"没有"| create["新建规范事件"]
matched -->|"已有可信关联"| revision["按方法检查实例与修订"]
create --> link["幂等保存邮件、账号、参与身份关系"]
revision --> link
link --> entry["唯一用户视图关系,按实例展示"]

建议在隔离范围内寻找同 UID 的事件,并校验组织者与可信来源的连续性。已可信关联的重复系列,再按实例标识定位具体一次事件;不能简单把「租户 + UID + 组织者」字符串拼接后就认为安全。

不同用户或租户的私人邮箱数据不能因 UID 相同而直接全局合并。推荐先在用户可访问的数据范围内建立规范事件,跨用户共享必须通过明确的访问关系。对于同 UID 但组织者冲突、来源不可信的邮件,隔离为冲突记录,不覆盖原事件。组织者和参与者伪造是邮件日历协议需要处理的安全风险。协议依据:RFC 6047。

收件账号不一定是被邀请身份

A 邮箱可能收到发给别名的邀请,B 邮箱也可能收到转发副本。因此需要区分:

转发副本不自动授予代他人接受的权限。两个账号收到的都是同一个别名时,也不应生成两个不同参与者。地址规范化需要保留原值,不擅自去掉 +tag、点号或推断两个地址属于同一身份。

四、建议数据结构与唯一约束

以下为逻辑模型,可由真实业务服务落地为关系表或等价存储。隔离字段必须参与实际查询与唯一约束;不能只写在文档里。

核心关系图:事件、用户视图与参与回应

查看流程图源码与文本描述
erDiagram
direction LR
User ||..o{ UserCalendarEntry : 拥有视图
CalendarEvent ||..o{ UserCalendarEntry : 被用户查看
CalendarEvent ||..o{ EventOccurrence : 展开实例
CalendarEvent ||..o{ EventAttendee : 邀请身份
EventAttendee ||..o{ AttendeeResponse : 按范围回应
Meeting o|..o{ CalendarEvent : 可选关联
User { string id PK }
CalendarEvent { string id PK
  string scopeId
  string icalUid
  string meetingId FK
}
UserCalendarEntry { string userId FK
  string eventId FK
  string visibility
}
EventOccurrence { string id PK
  string eventId FK
  string recurrenceKey
}
EventAttendee { string id PK
  string eventId FK
  string identityKey
}
AttendeeResponse { string attendeeId FK
  string responseScope
  string status
}
Meeting { string id PK }

读图:圆圈表示可选,单线表示一,分叉表示多;图中虚线为非标识关联,子记录有自己的标识。用户与事件通过 UserCalendarEntry 建立多对多视图关系;参与者及其回应另存。会议只表示可选关联,不额外规定一个会议能关联几个事件的产品限制。

来源关系图:多账号、多邮件与外部映射

查看流程图源码与文本描述
erDiagram
direction LR
User ||..o{ MailAccount : 绑定账号
User ||..o{ VerifiedIdentity : 验证身份
CalendarEvent ||..o{ EventAccountLink : 关联来源
MailAccount ||..o{ EventAccountLink : 收到邀请
CalendarEvent ||..o{ EventMessageLink : 保留历史
MailAccount ||..o{ EventMessageLink : 提供邮件
CalendarEvent ||..o{ ExternalEventBinding : 关联外部事件
MailAccount ||..o{ ExternalEventBinding : 提供同步上下文
CalendarEvent ||..o{ ResponseOperation : 记录回应操作
User { string id PK }
MailAccount { string id PK
  string userId FK
}
VerifiedIdentity { string id PK
  string userId FK
  string address
}
CalendarEvent { string id PK }
EventAccountLink { string eventId FK
  string mailAccountId FK
}
EventMessageLink { string eventId FK
  string mailAccountId FK
  string providerMessageId
}
ExternalEventBinding { string eventId FK
  string mailAccountId FK
  string externalEventId
}
ResponseOperation { string id PK
  string eventId FK
  string deliveryStatus
}

为避免图过密,此图仅列关键关系;账号与已验证身份之间的授权映射、实例外键及回应操作的目标身份仍按下文约束实现。PK 是主键,FK 是外键;复合唯一约束见各实体说明,不把 icalUid 单独标为唯一。

1. MailAccount 与 VerifiedIdentity

MailAccount 保存账号 ID、所属用户、隔离范围、提供方、授权状态和连接状态。凭据由安全的账号服务管理,不下发给日历前端。

VerifiedIdentity 保存用户拥有且已验证的邮箱地址或内部协同身份。邮箱别名与账号之间为显式授权映射,不凭地址相似性自动认领。

2. CalendarEvent:事件/系列主记录

interface CalendarEvent {
  id: string;
  scopeId: string;
  icalUid: string;
  source: "internal" | "ics" | "provider";
  organizerIdentity: string;
  title: string;
  description: string;
  start: CalendarTime;
  end: CalendarTime;
  location?: string;
  recurrenceRule?: string;
  sequence: number;
  dtstamp: string;
  revision: string; // 服务端并发控制版本,不等同于 SEQUENCE
  status: "active" | "cancelled";
  meetingId?: string;
  externalMeeting?: { provider: string; joinUrl: string };
}

type CalendarTime =
  | { kind: "date"; value: string } // 全天日期,不强制换算为 UTC 午夜
  | { kind: "instant"; value: string; timeZone: string }
  | { kind: "floating"; value: string }; // 无时区时间,需明确解释策略

记录经过验证的来源绑定。icalUid 不应设置为跨租户、跨私人邮箱范围的裸全局唯一键。单次普通事件和重复系列都使用内部 ID。

3. EventOccurrence:重复事件实例与例外

保存 id、eventId、原始 recurrenceId、实例版本、覆写的时间/标题以及实例取消状态。保留实例标识的值类型、时区语义与原始值。

建议唯一约束:(eventId, normalizedRecurrenceKey)。普通非重复事件使用显式的单次键;不要依赖数据库对空值唯一约束的不同处理。重复系列可按查询时间窗口展开,不必无限预生成实例。仅取消某一次、取消整个系列和修改后续实例必须分开处理。

4. UserCalendarEntry:用户视图关系

保存 userId、eventId、个人可见性、个人提醒设置和展示偏好。唯一约束为隔离范围内的 (userId, eventId)。系列在某一时间窗口内按实例展开,所以「去重」不是把整个重复系列压成一个时间块。

同一用户只建立一条事件视图关系,是多账号不重复展示的关键。这个关系不是参与者状态:隐藏日程不等于拒绝邀请,删除个人视图不等于组织者取消活动。

5. EventAttendee 与 AttendeeResponse:身份及回应

EventAttendee 保存 eventId、参与身份类型、邀请原始地址、规范身份键、可选 userId。唯一约束为 (eventId, attendeeIdentityKey),不能用 (eventId, userId),否则一个用户的两个邮箱身份会被错误合并。

AttendeeResponse 保存参与者、回应范围(整个系列/指定实例)、NEEDS_ACTION、ACCEPTED、TENTATIVE 或 DECLINED,并保存回应所针对的事件版本、回应时间、传输结果。每个参与身份、每个回应范围的当前状态唯一,历史另存。

事件版本与参与者回应版本要分开。组织者修改重要内容后,是否要求重新回应,应由明确规则驱动;不要把过去的接受标记无条件当作接受了新时间。

6. EventAccountLink:账号来源关联

保存 eventId、mailAccountId、可选的参与身份关联以及来源连接状态。一个事件可以关联多个收件账号;其中某个账号断开,不影响另一个账号的关联,也不抹掉邀请历史。

账号与参与身份可能多对多,必要时使用独立的映射表,不假设收件账号就是回应地址。账号筛选应按来源或明确映射匹配,合并视图仍只显示一份事件。

7. EventMessageLink:邮件历史关联

保存 eventId、可选实例 ID、账号 ID、提供方邮件 ID、方法、SEQUENCE、DTSTAMP、内容摘要、解析和处理结果。邀请、更新、取消、回应均保留原邮件关系。

收件幂等键建议为 (mailAccountId, providerMessageId);只有 MIME Message-ID 时,还需连接器给出稳定的本地邮件标识,不能假定 Message-ID 永远唯一可信。重复投递的相同有效载荷也需要业务去重。取消是状态变化,不是硬删除这些关联。

8. Meeting、ExternalEventBinding 与 ResponseOperation

这三个实体用于生产集成。本次仅编写设计说明,不在静态原型中添加数据库、凭据、发信或运行时 API。

五、完整业务流程

1. 创建与发送邀请

查看流程图源码与文本描述
sequenceDiagram
participant 组织者
participant 日历调度
participant 邮件连接器
participant 受邀邮箱
组织者->>日历调度: 创建事件并选择授权发件身份
日历调度->>日历调度: 保存事件、UID、参与者与可选会议关联
日历调度->>邮件连接器: 投递 METHOD REQUEST
邮件连接器->>受邀邮箱: 发送邀请
邮件连接器-->>日历调度: 提交或失败结果
日历调度-->>组织者: 展示真实投递状态

需要会议时再关联会议能力。失败不能显示成已成功邀请。

2. 两个邮箱收到同一邀请

查看流程图源码与文本描述
sequenceDiagram
participant 邮箱A
participant 邮箱B
participant 日历调度
participant 统一读模型
邮箱A->>日历调度: 同步 M001
日历调度->>日历调度: 解析并验证来源和参与身份
日历调度->>统一读模型: 创建或匹配 E1001,保存 A 的关系
邮箱B->>日历调度: 同步 M002,同一可信 UID
日历调度->>日历调度: 匹配 E1001 并检查修订
日历调度->>统一读模型: 补充 B 的来源与身份关系
统一读模型-->>日历调度: 一个日程,两份来源,独立回应

事务或等价原子机制应保证 A、B 同时到达也不会创建两个展示关系。无法可靠判定是否同一邀请时保留冲突待处理,不能用模糊匹配静默合并。

3. 接受、待定与拒绝

查看流程图源码与文本描述
sequenceDiagram
participant 用户
participant 邮件或日历界面
participant 回应服务
participant 单一回应通道
用户->>邮件或日历界面: 选择身份 A 与接受、待定或拒绝
邮件或日历界面->>回应服务: 提交目标身份、版本与幂等键
回应服务->>回应服务: 校验身份、权限、取消状态与版本
回应服务-->>邮件或日历界面: 返回操作 ID,显示处理中
回应服务->>单一回应通道: 需要通知时提交回应
单一回应通道-->>回应服务: 提交或失败结果
回应服务->>回应服务: 保存 A 的选择与发送结果,B 不变
回应服务-->>邮件或日历界面: 更新邮件卡片和日历读模型

邮件入口默认定位可验证的受邀身份;日历入口多身份时明确选择。单一回应通道指提供方 API 或授权邮箱 iTIP 回应,不能两种都发送。无需通知时跳过通道,保存本地选择;失败以同一操作幂等重试。缓存按受影响用户、事件和身份失效。

发送成功仅证明连接器接受了操作,不保证组织者已经阅读或更新日历。界面应区分「本地已选择」「正在发送」「发送失败」「已提交回应」;如提供方没有回执,不显示未经证实的「组织者已确认」。

「不向组织者发送邮件」也不是一个协议层面的通用能力。若产品允许仅更新本地选择,需要明确提示组织者不会得知,并独立保存本地状态;提供方 API 是否支持静默回应由连接器定义。

4. 修改与取消

同一 UID/实例的事件修订按 SEQUENCE 处理;同版本需要结合 DTSTAMP 判断顺序。回应携带所回应的修订版本,不应作为事件内容更新。实现时按方法分别处理,不能把收到的所有 ICS 都当作最新事件覆盖。协议依据:RFC 5546。

若两个来源的同版本内容冲突,记录冲突并按可信来源策略解决;收件时间不是事件修订时间,也不能作为唯一裁决依据。

查看流程图源码与文本描述
flowchart TD
validated["已验证来源的日历消息"] --> method{"消息方法?"}
method -->|"REQUEST 或 CANCEL"| revision{"相同 UID 与实例的修订检查"}
revision -->|"旧修订或重复"| history["保留历史,不覆盖当前状态"]
revision -->|"同版本内容冲突"| conflict["记录冲突,按可信策略解决"]
revision -->|"可应用的修订"| action{"REQUEST 还是 CANCEL?"}
action -->|"REQUEST"| update["更新原事件或实例,标出重要变化"]
action -->|"CANCEL"| cancel["取消指定范围,保留取消版本"]
method -->|"REPLY"| response["校验身份与回应版本,只更新该参与者"]
method -->|"其他方法"| unsupported["交给专用处理或标记不支持"]

修订检查结合 SEQUENCE、DTSTAMP、实例范围与内容;旧邀请不能让已取消事件复活。REPLY 不覆盖事件内容。

六、日历与邮件的展示契约

统一日历的查询结果应该包含事件/实例 ID、标题、时间、来源账号列表、当前用户各参与身份的状态,以及用于展示的派生状态。派生状态不能替代原始参与状态。

建议的显示优先级为:取消状态优先;未取消时,任一身份接受则显示已接受,否则任一待定则显示待定,否则任一未回应则显示未回应,全部拒绝则显示已拒绝。此优先级是产品建议,仍需确认,且应同时标明「存在其他身份未回应」。

查看流程图源码与文本描述
flowchart TD
event{"事件或当前实例已取消?"} -->|"是"| cancelled["显示已取消"]
event -->|"否"| accepted{"任一身份已接受?"}
accepted -->|"是"| showAccepted["显示已接受"]
accepted -->|"否"| tentative{"任一身份待定?"}
tentative -->|"是"| showTentative["显示待定"]
tentative -->|"否"| pending{"任一身份未回应?"}
pending -->|"是"| showPending["显示未回应"]
pending -->|"否且全部拒绝"| showDeclined["显示已拒绝"]

此图仅描述「至少一个本人受邀身份」时的建议聚合规则,不决定日程是否隐藏;无本人受邀身份的导入事件不套用该规则。所有身份的原始状态和发送结果始终保留。

日程详情中,每个身份显示地址、来源账号、回应和发送结果。切换身份后,接受/待定/拒绝作用于所选身份。隐藏某一账号的来源筛选时,如果另一账号仍命中该事件,日程继续显示。

邮件邀请卡片显示当前邀请身份的状态,并链接到统一事件。日历反向查看邮件时,可列出多个账号的邀请、修改、取消历史;导航使用内部事件/实例 ID,不能靠标题搜索或仅传一个日期。

七、服务接口建议

以下是未来业务服务的契约草案,不是当前 Next.js 原型的 API Routes,也不要求前端自己发送 ICS。

interface ReplyInvitationCommand {
  eventId: string;
  occurrenceId?: string;
  responseScope: "series" | "occurrence";
  attendeeIdentityId: string;
  mailAccountId?: string;
  expectedRevision: string;
  action: "ACCEPTED" | "TENTATIVE" | "DECLINED";
  notifyOrganizer: boolean;
  note?: string;
  idempotencyKey: string;
}

interface ReplyInvitationResult {
  operationId: string;
  localStatus: "ACCEPTED" | "TENTATIVE" | "DECLINED";
  deliveryStatus: "not-requested" | "pending" | "submitted" | "failed";
  eventRevision: string;
}

操作者与隔离范围从认证上下文获得,不信任客户端提交的 userId。回应身份、账号、事件之间的权限关系由服务校验。版本冲突时返回新版本供用户确认,不静默接受已经改期的日程。幂等键限定到操作者与操作范围,重试返回原操作结果。

查询能力至少包括:按时间窗口/账号筛选日程;获取日程及本人身份状态;获取来源邮件;查询回应操作结果。组织者创建、修改和取消属于独立的有权限命令,不复用参与者拒绝接口。

八、邮件 ICS 与外部日历同步如何选择?

路径 A:邮件驱动

收件连接器解析邀请、修改、取消,回应通过邮件传输。优点是与多邮箱收件统一;限制是只有收到新消息才知道外部变化,不能承诺实时反映用户在外部日历的操作。

路径 B:提供方日历同步

通过提供方日历 API 同步事件与状态,保存外部事件映射、同步游标和授权范围。若同一事件也从邮件到达,应进入同一规范化事件,而不是再显示一份。

一个事件可有多个来源,但需要明确各字段的来源权威、同步冲突策略和回应通道。不能开启双向同步后又把自己发送的更新当作新的邀请无限循环。内部来源 ID、幂等标记和外部映射应参与循环检测。

查看流程图源码与文本描述
flowchart LR
mail["路径 A:邮件 ICS"] --> mailNormalize["解析、来源验证与邮件幂等"]
provider["路径 B:提供方日历 API"] --> providerNormalize["授权、外部映射与同步版本"]
mailNormalize --> match["匹配同一规范事件"]
providerNormalize --> match
match --> readModel["统一事件与参与状态读模型"]
readModel --> calendar["日历展示一次"]
readModel --> message["邮件邀请卡片"]

两条路径是方案对照,不表示一期同时启用;多来源汇入同一事件,也不代表对外回应要发两次。

建议先明确一期选择哪条路径,再设计连接器;数据模型保留扩展点。不能把「支持登录邮箱」等同于「已经有读取和修改提供方日历的授权」。

九、当前原型已实现与尚未实现的边界

当前已有 Web 邮件 ICS 邀请卡片、详情、接受/待定/拒绝,以及周/月日历联动。接受和待定按 UID 写入浏览器内存日历,拒绝移除;勾选通知后生成本地模拟回应邮件。刷新页面会重置,不向外部组织者发信。

当前模型只有单一模拟参与身份。src/features/calendar/repository.ts 中的回应和日历条目以 UID 为键;这可以演示单身份流程,不能直接扩展成本文的多账号生产模型。

尚未实现:多邮箱授权与身份映射、真实收发、完整 iCalendar 解析、重复事件、版本排序、取消消息处理、外部日历连接器、服务端权限、事务、持久化及失败重试。

后续扩展原型时,先把回应键拆为事件/实例/参与身份,再通过 repository 暴露统一读模型。邮件与日历通过各功能域的 index.ts 公开入口协作,不由 UI 直接读取 fixtures 或操作另一域内部存储。新页面仍走生成器,交互状态由 TanStack Query 管理。

十、开发拆解与验收用例

推荐实施顺序

查看流程图源码与文本描述
flowchart TD
decisions["1 产品规则与一期范围"] --> model["2 隔离、身份、事件模型与唯一约束"]
model --> inbound["3 来源验证、收件去重与修订排序"]
inbound --> reply["4 回应权限、幂等、发送结果与重试"]
reply --> ui["5 邮件与日历共用契约,关联会议能力"]
ui -.-> future["6 按批准范围扩展重复事件与外部同步"]

必须覆盖的用例

本仓库继续使用既定 npm run check 和 root//telemail 静态构建验证;以上生产服务用例是未来开发验收要求,不代表本次新增测试框架或后端。

十一、上线前需逐项确认的产品决策

  1. 未回应邀请是否直接出现在日历?当前原型是接受/待定才进入日历。
  2. 多身份全部拒绝后是否隐藏?当前单身份原型会移除,但生产系统应保留历史。
  3. 是否采用第六节的聚合状态优先级?如何提示其他身份仍未回应?
  4. 是否允许只保存本地回应而不通知组织者?各提供方是否具备该能力?
  5. 日历入口多身份时是否每次选择,或允许显式记忆默认身份?不能自动代所有身份回复。
  6. 一期仅处理单次事件,还是支持重复系列与单次例外?不支持时如何提示?
  7. 一期选择邮件驱动还是提供方日历同步?授权和同步失败的用户提示是什么?
  8. 解绑最后一个来源后,保留、隐藏还是移除个人视图?历史与隐私删除如何处理?
  9. 邀请改期等重要修改是否要求重新回应?旧接受状态如何展示?

这些决策需逐项确认,不应从参考截图或对话建议中推断已批准。无论最终展示策略如何,底层始终保持:同一邀请可关联多封邮件、同一用户可有多个参与身份、每个身份独立回应、取消与删除历史分离。