Skip to content

CONDSTORE and QRESYNC ​

核心思路是利用一个全局递增的“修改序号”(mod-seq)来标记每条消息的状态变动,然后让客户端带上自己上次同步时看到的 mod-seq,仅拉取自那以后“真正变动”过的消息(新邮件、标志改动或被删除),从而避免每次都要全量扫描、也不会把旧邮件当“新邮件”再次推送。


一、原理讲解 ​

  1. mod-seq(修改序号)

    • 服务端为每个邮箱(mailbox)维护一个 highest_modseq,初始 0。
    • 每当有新邮件到达或对已有消息做 STORE/EXPUNGE/MOVE 等会改变状态的操作时,先把 highest_modseq++,再把这次操作涉及到的消息的 modseq 字段更新为这个新值。
    • 这样,每条消息的 modseq 总是它最后一次状态变动时的序号。
  2. CONDSTORE 扩展

    • 在 CAPABILITY 里报出 CONDSTORE。

    • SELECT 时,服务器在标准的 EXISTS/RECENT/UIDVALIDITY/UIDNEXT 之后,额外返回:

      * OK [HIGHESTMODSEQ  12345]

      这里的 12345 就是当前最高的修改序号。

    • 客户端本地记录这个数字,下次只对 modseq > 12345 的消息发 FETCH,就只拉新邮件或真有变动的标志。

  3. QRESYNC 扩展

    • 在 CAPABILITY 里再报出 QRESYNC。

    • 客户端在重新 SELECT 时带上 (QRESYNC (<last-uid-validity> <last-modseq> <known-uid-set>)) 参数:

      A142 SELECT "INBOX" (QRESYNC (1750701677 12345 1,4:6))
    • 服务器先返回一个 VANISHED 列表,告知自上次以来被 EXPUNGE(删除)的那些 UID;

    • 然后以 CHANGEDSINCE 的方式只 FETCH modseq>last-modseq 的消息;

    • 最后跟上正常的 SELECT 响应(EXISTS/RECENT/UIDVALIDITY/UIDNEXT/HIGHESTMODSEQ)。

这种“先通知删除,再增量拉取变动,再给出最新状态”流程,既让客户端保持与服务器一致,又避免重复或遗漏。


二、典型交互流程示例 ​

下面给出一个精简的示例,标注客户端(C:)和服务端(S:)通信:

C: A001 CAPABILITY
S: * CAPABILITY IMAP4rev1 CONDSTORE QRESYNC …  
S: A001 OK CAPABILITY completed

C: A002 LOGIN user pass
S: A002 OK LOGIN completed

C: A003 SELECT "INBOX"
S: * FLAGS (\Answered \Flagged \Deleted \Seen \Draft)
S: * OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft \*)]
S: * 5 EXISTS
S: * 0 RECENT
S: * OK [UIDVALIDITY 1750701677]
S: * OK [UIDNEXT 10]
S: * OK [HIGHESTMODSEQ 2048]
S: A003 OK [READ-WRITE] SELECT completed

# —— 客户端初次要全量拿 FLAGS 或 HEADER ——  
C: A004 UID FETCH 1:* (FLAGS)
S: * 1 FETCH (UID 1 FLAGS (\Seen))
… 
S: * 9 FETCH (UID 9 FLAGS ())
S: A004 OK FETCH completed [HIGHESTMODSEQ 2048]

# —— 一段时间后,有邮件和标志变化 ——  
C: A005 IDLE
S: + idling
S: * 10 EXISTS
S: * 1 RECENT   ← 通知新邮件到达
# 客户端退出 IDLE
C: A005 DONE
S: A005 OK IDLE completed

# —— 客户端增量抓取新变动,只拉 UID ≥ UIDNEXT 且 modseq>2048 ——  
C: A006 UID FETCH 10:* (FLAGS) (CHANGEDSINCE 2048)
S: * 10 FETCH (UID 10 FLAGS () MODSEQ 2050)
S: A006 OK FETCH completed [HIGHESTMODSEQ 2050]

# —— 客户端再拉这条新邮件的 HEADER/BODY ——  
C: A007 UID FETCH 10 (RFC822.SIZE FLAGS BODY.PEEK[HEADER] BODY.PEEK[TEXT]<0>)  
S: * 10 FETCH (UID 10 … BODY[HEADER] {…} … BODY[TEXT] {…})
S: A007 OK FETCH completed
  1. 首次 SELECT 拿到当前 HIGHESTMODSEQ=2048。
  2. 初次 FETCH 拿完历史消息后,也把 [HIGHESTMODSEQ 2048] 送回给客户端。
  3. IDLE 时服务器推送新邮件到达 (EXISTS/RECENT)。
  4. 客户端发带 CHANGEDSINCE 2048 的 FETCH,只收到那条 modseq=2050 的 UID 10。
  5. 最后再专门对 UID 10 拉 HEADER/BODY。

这样,客户端既能实时收到新邮件与变动,又永不重复拉旧数据。

3. 邮件层面 modseq ​

虽然不会在普通的 FETCH 响应里一并返回每条消息的 modseq,但要能实现 CONDSTORE/QRESYNC 里的增量查询和 CHANGEDSINCE 过滤,必须在消息层面保存它。原因有三:

  1. 判断“哪些消息”发生了变动 客户端只给出了上次看到的 last-modseq(比如 2048)。服务器要知道哪些消息的状态序号超过了这个值,才能筛选出新邮件、改过标志或被删除的消息;如果不在 mail 表里存 modseq,就没法做这种比较。

  2. 返回给客户端的 MODSEQ 值 在 FETCH … (CHANGEDSINCE <n>) 的响应里,服务器需要把每条变动消息的 MODSEQ 一并返回给客户端:

    * 10 FETCH (UID 10 FLAGS () MODSEQ 2050)

    客户端拿到这个新的 2050,用来更新本地的 “已知最大 modseq” 值。

  3. 性能和可维护性 在数据库里给每条消息一个 modseq 字段(并对它建索引),比在每次操作时去扫描操作日志、再去拉出受影响的消息要高效得多,也更容易维护。


简要示例表结构

sql
-- 邮箱表:记录最高的 mod-seq
ALTER TABLE mw_mailbox
  ADD COLUMN highest_modseq BIGINT NOT NULL DEFAULT 0;

-- 消息表:记录每条消息最后一次变动的序号
ALTER TABLE mw_mail
  ADD COLUMN modseq BIGINT NOT NULL DEFAULT 0;

-- 对 modseq 建索引,加速 CHANGEDSINCE 查找
CREATE INDEX idx_mail_mailbox_id_modseq ON mw_mail(mailbox_id, modseq);

只维护邮箱级别的 highest_modseq 而不记录消息层面的 modseq,就没法筛出自上次同步后“哪些消息”真的变动了,也无法在 FETCH 响应中带回新的 MODSEQ。因此,消息表上的 modseq 字段是实现增量同步必不可少的一环。

HIGHESTMODSEQ 是针对邮箱(mailbox) 级别维护的,它表示该邮箱当前所产生的最大“修改序号”。 不过,也需要在每条消息上保存一个 modseq 字段,用来记录该消息最后一次状态变动时的序号。


典型的表结构示例

sql
-- 邮箱表:存最高 mod-seq
ALTER TABLE mailbox
  ADD COLUMN highest_modseq BIGINT NOT NULL DEFAULT 0;

-- 消息表:给每条消息记录它的 mod-seq
ALTER TABLE mail
  ADD COLUMN modseq BIGINT NOT NULL DEFAULT 0;

4. 修改代码 ​

新邮件入库 或 消息状态变动(STORE/EXPUNGE/MOVE 等)时更新highest_modseq和modseq ​

添加触发器 ​

  • 在 mw_mail 表上加一个 BEFORE INSERT OR UPDATE Trigger,自动把新邮件入库或 MOVE(邮箱变更)时的 modseq 写好;
  • 在 mw_mail_flag 表上加一个 AFTER INSERT OR DELETE Trigger,自动处理 STORE(打标)和 EXPUNGE(删标)时的 modseq 更新。

两条 Trigger 都调用同一个底层函数,该函数会:

  1. 在同一事务里把对应 mw_mailbox.highest_modseq 自增并拿到 新序号;
  2. 把这个序号写入受影响的 mw_mail.modseq。

1. 底层函数 ​

sql
CREATE OR REPLACE FUNCTION fn_assign_modseq()
RETURNS TRIGGER AS $$
DECLARE
  newmod BIGINT;
BEGIN
  -- 为这个邮箱分配一个全局递增的新序号
  UPDATE mw_mailbox
    SET highest_modseq = highest_modseq + 1
  WHERE id = TG_ARGV[0]  -- 触发时传入 mailbox_id 列名
  RETURNING highest_modseq INTO newmod;

  -- 插入/更新邮件时,用这个新序号覆盖 NEW.modseq
  IF TG_TABLE_NAME = 'mw_mail' THEN
    NEW.modseq := newmod;
    RETURN NEW;
  END IF;

  -- 对 mw_mail_flag 表触发时,TG_ARGV[1] 传入关联的 mail_id
  UPDATE mw_mail
     SET modseq = newmod
   WHERE id = TG_ARGV[1];

  RETURN NULL;  -- flag 表触发器不需要返回行
END;
$$ LANGUAGE plpgsql;

注意:TG_ARGV 用来传递触发器参数,下面会演示如何传。


2. 在 mw_mail 上的 Trigger ​

sql
-- 触发场景:新插入邮件(UID 分配后)或 MOVE(更新 mailbox_id)
CREATE TRIGGER trg_mail_modseq
  BEFORE INSERT OR UPDATE OF mailbox_id
  ON mw_mail
  FOR EACH ROW
  EXECUTE FUNCTION fn_assign_modseq('id', 'mailbox_id');
  • TG_ARGV[0] = 'mailbox_id',函数里会用 NEW.mailbox_id 去更新对应的邮箱。
  • 对 INSERT,NEW.modseq 被写成最新值;对 MOVE(更新 mailbox_id),同样重新分配一个序号。

3. 在 mw_mail_flag 上的 Trigger ​

sql
-- 触发场景:新增标志(STORE)或删除标志(EXPUNGE)
CREATE TRIGGER trg_flag_modseq
  AFTER INSERT OR DELETE
  ON mw_mail_flag
  FOR EACH ROW
  EXECUTE FUNCTION fn_assign_modseq('mailbox_id', 'mail_id');
  • 这里 TG_ARGV[0] = 'mailbox_id',需要在 mw_mail_flag 行里能拿到对应 mw_mail.mailbox_id。
  • TG_ARGV[1] = 'mail_id',函数会用它去更新 mw_mail 表的 modseq。

小结 ​

  • 所有涉及消息状态变动(新邮件、MOVE、STORE、EXPUNGE)的操作,都被触发器拦截并统一调用 fn_assign_modseq。
  • 触发器自动维护了 mw_mailbox.highest_modseq 和对应 mw_mail.modseq,应用层完全不用再写那串事务逻辑,也不怕遗漏。

这样,就只需关心正常的 INSERT/UPDATE/DELETE 操作,增量同步的 mod‐seq 机制就能在数据库层面自动、稳健地跑起来。

客户端查询时: ​

  • 初次 SELECT 后,服务器把当前 HIGHESTMODSEQ(即 mailbox.highest_modseq)返回给客户端;
  • 客户客后续增量 FETCH 请求带上 CHANGEDSINCE <last-modseq>,服务器只返回那些 modseq 大于它的消息。
* OK [HIGHESTMODSEQ 2048]

MIT Licensed