Skip to content
ChangYo edited this page Oct 25, 2026 · 2 revisions

交大邮箱

项 值
平台 mail.sjtu.edu.cn
认证 邮箱地址 + 密码
数据方向 读 + 写(发信、标记已读)
难度 中
最后验证 2026-09-15
证据等级 A(官方参数 + Dela 实测)

交大邮箱支持标准 IMAP/SMTP,不需要 scope 或 OAuth。协议成熟,客户端库多,行为相对稳定,适合作为个人工具的数据源。

要点

  • 可获取:文件夹、邮件头、正文、附件、已读与旗标、草稿、已发送。
  • 前置:jAccount 账号@sjtu.edu.cn 和邮箱密码。
  • 服务器:IMAP mail.sjtu.edu.cn:993(SSL),SMTP mail.sjtu.edu.cn:465(SSL)。
  • 注意:UIDVALIDITY 变化后必须清缓存;UID 与 sequence number 不同;读取正文时不要顺便标为已读。

1. 数据清单

对象 内容 说明
文件夹 path、名称、特殊用途(\Inbox/\Sent/\Drafts/\Trash)、总数、未读数 LIST + STATUS
邮件头 UID、Message-ID、主题、发件人、收件人、抄送、日期、大小、旗标 FETCH ENVELOPE
正文 text/plain 或 text/html BODYSTRUCTURE + BODY.PEEK
附件 文件名、大小、part ID、MIME 流式下载,不要整封读进内存
状态 \Seen、\Flagged、\Answered 等 STORE
发信 SMTP 发送、保存到已发送 SMTP + APPEND
草稿 本地草稿;服务器草稿箱 Dela 用本地草稿;服务器 \Drafts 会被一次性清空(真删、不可恢复,需用户确认)

无法获取:

  • 别人邮箱的邮件。
  • 服务器端「已读回执」的可靠状态(取决于对方客户端)。
  • 企业微信/短信等其他渠道的通知。

2. 认证与服务器

项 值
邮箱地址 <jAccount>@sjtu.edu.cn
密码 邮箱密码。修改过邮箱密码时使用新密码;不要假设它始终与 jAccount 登录密码相同。[官方/实测]
IMAP mail.sjtu.edu.cn,端口 993,SSL/TLS
SMTP mail.sjtu.edu.cn,端口 465,SSL/TLS
第三方邮箱 自填 IMAP/SMTP host/port;同一套代码可复用

Note

交大邮箱是学校托管的企业邮箱。部分账号可能开启二次验证/客户端专用密码;如果密码登录失败,先看邮箱网页端的「设置 → 客户端/安全」是否有独立密码。以你账号的实际设置为准。


3. 协议与命令

连接

import { ImapFlow } from 'imapflow';

const client = new ImapFlow({
  host: 'mail.sjtu.edu.cn',
  port: 993,
  secure: true,
  auth: { user: 'someone@sjtu.edu.cn', pass: process.env.SJTU_MAIL_PASSWORD! },
  logger: false,
});

await client.connect();

列文件夹

const folders = await client.list();
// folder.path、folder.name、folder.specialUse、folder.flags
  • 跳过 flags 含 \Noselect 的文件夹。
  • specialUse 可能是 \Inbox、\Sent、\Drafts、\Trash、\Junk。
  • 文件夹分隔符不一定是 /;用库返回的 delimiter,不要自己拼。

读状态

const status = await client.status('INBOX', {
  messages: true,
  uidValidity: true,
  uidNext: true,
});
  • uidValidity 是 UID 的世代号。它变了,之前缓存的 UID 全部失效,必须清空该文件夹的缓存重新同步。
  • uidNext 是下一个 UID;用它算最近窗口。
  • messages 是邮件总数。

拉邮件头

const lock = await client.getMailboxLock('INBOX');
try {
  const uidNext = Number(status.uidNext ?? 1);
  const highest = Math.max(1, uidNext - 1);
  const start = Math.max(1, highest - 300 + 1); // 最近 300 封

  const messages = await client.fetchAll(
    { uid: `${start}:*` },
    {
      uid: true,
      flags: true,
      envelope: true,
      internalDate: true,
      size: true,
      bodyStructure: true,
    },
    { uid: true },
  );
  // message.uid、message.envelope、message.flags、message.bodyStructure
} finally {
  lock.release();
}
  • 始终用 { uid: true }。IMAP 的 sequence number 会随删除/移动变化,UID 才是稳定主键。
  • envelope 给主题、发件人、收件人、日期;internalDate 是服务器收到时间。
  • bodyStructure 给 MIME 树,后面用它挑正文 part 和附件 part。

读正文

async function readBody(client: ImapFlow, uid: number, part: string) {
  const download = await client.download(uid, part, {
    uid: true,
    maxBytes: 2_000_000,
  });
  const chunks: Buffer[] = [];
  for await (const chunk of download.content) chunks.push(chunk as Buffer);
  return Buffer.concat(chunks).toString('utf8');
}

// 调用:const html = await readBody(client, message.uid, textPart.part);
  • 用 BODY.PEEK 语义读取,避免读取正文时自动标记已读。不同库的 download 默认行为可能不同,应确认库不会设置 \Seen。
  • 正文设上限(Dela 用 2 MB);超长邮件截断并提示。
  • HTML 邮件要清洗;远程图片默认不加载(隐私/跟踪像素)。

标记已读

await client.messageFlagsAdd({ uid }, ['\\Seen'], { uid: true });
  • 只在你确认用户真的读了这封信时调用。
  • 批量「全部已读」也要按文件夹分批,不要一次 STORE 1:* 把整个邮箱扫一遍。

字段与语义

字段/结构 含义 注意
envelope.subject 主题 可能为空
envelope.from / to / cc 地址列表 name 可能为空;地址是主键
envelope.messageId Message-ID 去重/已发送判重
internalDate 服务器收到时间 与 Date 头可能不同
flags \Seen/\Flagged/\Answered 等 状态以服务器为准
bodyStructure MIME 树 正文/附件的 part 从这里来
uid 邮件 UID 只在同一 UIDVALIDITY 内稳定
uidValidity UID 世代号 变了必须清缓存

分页

IMAP 没有 HTTP 那种 page 分页。用 UID 窗口:取最近 300 个 UID,更早的按需拉。不要用 sequence number,也不要把「没在窗口里」当成已删除。


4. 同步设计

Dela 的做法(src/main/mail/sync.ts)可以直接参考:

  1. LIST 所有文件夹,跳过 \Noselect、\Drafts、\Trash。
  2. 对每个文件夹 STATUS 拿 uidValidity、uidNext、messages。
  3. uidValidity 变了:清空该文件夹缓存。
  4. 只同步最近 300 个 UID(窗口可调);更早的邮件按需拉取。
  5. 用 FETCH 拉 envelope/flags/internalDate/size/bodyStructure,写入本地库。
  6. 预取最近 30 封正文,保证打开就能读;其余按需拉。
  7. 未读数从本地表统计,不盲信服务器计数。
  8. 同步失败保留旧缓存,只标错误。

Warning

Dela 在首次同步时会调用 messageDelete({ all: true }) 清空服务器草稿箱(\Drafts),然后改用本地草稿。这是真删、不可恢复的操作;接入前必须让用户明确确认。不要把它描述成「清理缓存」。

原则:不要每次全量拉整个邮箱。一个用了十年的邮箱可能有几万封,全量会把服务器和本地都拖垮。


5. MIME 与附件

挑正文 part

遍历 bodyStructure:

  • 跳过 disposition === 'attachment' 和 type === 'message/rfc822'。
  • 优先 text/html,没有就 text/plain。
  • 记录 part(如 1.2),下载时用它。

附件

  • 附件 part 的 disposition 是 attachment,或 type 是 message/rfc822。
  • 文件名可能在 dispositionParameters.filename、parameters.name,也可能是 RFC 2231 编码;用库的解析,不要自己 decodeURIComponent。
  • 下载用流式,写临时文件再 rename;不要 Buffer.concat 一个大附件。
  • 中文文件名在 Windows 上要过滤非法字符;保存前做一次 path.basename,防止路径穿越。

HTML 邮件

  • 清洗脚本、样式、事件属性。
  • 远程图片默认不加载;用户点「显示图片」再加载。
  • cid: 内联图片可以转 data URL,但设总大小上限。
  • 不要执行邮件里的 HTML/JS。

6. 发信

import nodemailer from 'nodemailer';

const transporter = nodemailer.createTransport({
  host: 'mail.sjtu.edu.cn',
  port: 465,
  secure: true,
  auth: { user: 'someone@sjtu.edu.cn', pass: process.env.SJTU_MAIL_PASSWORD! },
});

await transporter.sendMail({
  from: 'someone@sjtu.edu.cn',
  to: 'teacher@sjtu.edu.cn',
  subject: '关于课程作业的问题',
  text: '...',
  html: '<p>...</p>',
});

发信后的「保存到已发送」:

const sent = folders.find((folder) => folder.specialUse === '\\Sent');
if (sent) await client.append(sent.path, rawMessage, ['\\Seen']);
  • SMTP 成功、APPEND 失败时,邮件已经发出去了,只能给用户一个 warning,不能报「发送失败」。
  • 有些服务器会自动保存已发送;再 APPEND 可能重复。先检查 \Sent 里有没有相同 Message-ID。
  • 附件用 MIME 编码;文件名用 RFC 2231。不要手写 MIME 边界。

7. 常见问题

  1. UIDVALIDITY 变了不清缓存:UID 会指向错误的邮件。必须清空该文件夹重同步。
  2. 用 sequence number 当主键:删除/移动后全部错位。始终用 UID。
  3. 读取正文时误标已读:确认库使用 BODY.PEEK;需要标记已读时显式执行 STORE \Seen。
  4. 盲信服务器未读数:服务器计数可能包含其他客户端的状态,或延迟更新。以本地 \Seen 为准,定期校正。
  5. 全量同步整个邮箱:几万封邮件会拖垮。用最近窗口 + 按需拉取。
  6. 中文附件名乱码:RFC 2231/2047 编码,用库解析。
  7. 大附件整包读入内存:流式下载,临时文件 + rename。
  8. HTML 邮件直接渲染:XSS/跟踪像素。清洗 + 默认不加载远程图片。
  9. SMTP 成功但 APPEND 失败:邮件已发出,不能报失败;给 warning。
  10. 重复保存已发送:先按 Message-ID 去重。
  11. 草稿箱/已删除文件夹:不同服务器行为不同;如果做本地草稿,就不要同时同步服务器草稿箱,避免两套状态打架。
  12. 邮箱密码与 jAccount 密码可能不同:修改过邮箱密码后使用新密码;不要假设两者始终同步。

8. 最小示例

import { ImapFlow } from 'imapflow';

const client = new ImapFlow({
  host: 'mail.sjtu.edu.cn',
  port: 993,
  secure: true,
  auth: { user: process.env.SJTU_MAIL_USER!, pass: process.env.SJTU_MAIL_PASSWORD! },
  logger: false,
});

await client.connect();
const folders = await client.list();
const inbox = folders.find((folder) => folder.specialUse === '\\Inbox') ?? folders[0];
if (!inbox) throw new Error('没有可同步的文件夹');

const lock = await client.getMailboxLock(inbox.path);
try {
  const status = await client.status(inbox.path, { uidValidity: true, uidNext: true });
  const start = Math.max(1, Number(status.uidNext ?? 1) - 300);
  const messages = await client.fetchAll(
    { uid: `${start}:*` },
    { uid: true, envelope: true, flags: true, internalDate: true, size: true },
    { uid: true },
  );
  for (const message of messages.slice(-5)) {
    console.log(message.uid, message.envelope?.subject);
  }
} finally {
  lock.release();
  await client.logout();
}

9. 已过时 / 不要用

  • 不要用 POP3 做多设备同步:POP3 没有文件夹/旗标/UID 语义,容易重复下载和丢状态。
  • 不要用明文 25 端口发信:用 465 SSL。
  • 不要用管理员/共享邮箱凭据:只用本人邮箱。
  • 不要自动群发/批量订阅:容易被判垃圾邮件,也违反邮箱使用规范。

10. 证据与来源

  • 服务器参数:[官方 2026-10-25] https://net.sjtu.edu.cn/info/1191/2699.htm
  • IMAP/SMTP、UIDVALIDITY、同步窗口、MIME、发信、已发送:[实测 2026-09,Dela src/main/mail/*]
  • 邮箱服务说明:[官方 2026-10-25] https://net.sjtu.edu.cn/xxfw/dzyj.htm

变更记录

  • 2026-10-25:首版。基于 Dela 邮件模块重写,补充 UIDVALIDITY、同步窗口、MIME、发信和常见问题。

Clone this wiki locally