Repository navigation
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),SMTPmail.sjtu.edu.cn:465(SSL)。 - 注意:UIDVALIDITY 变化后必须清缓存;UID 与 sequence number 不同;读取正文时不要顺便标为已读。
| 对象 | 内容 | 说明 |
|---|---|---|
| 文件夹 | 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 会被一次性清空(真删、不可恢复,需用户确认) |
无法获取:
- 别人邮箱的邮件。
- 服务器端「已读回执」的可靠状态(取决于对方客户端)。
- 企业微信/短信等其他渠道的通知。
| 项 | 值 |
|---|---|
| 邮箱地址 | <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
交大邮箱是学校托管的企业邮箱。部分账号可能开启二次验证/客户端专用密码;如果密码登录失败,先看邮箱网页端的「设置 → 客户端/安全」是否有独立密码。以你账号的实际设置为准。
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,也不要把「没在窗口里」当成已删除。
Dela 的做法(src/main/mail/sync.ts)可以直接参考:
-
LIST所有文件夹,跳过\Noselect、\Drafts、\Trash。 - 对每个文件夹
STATUS拿uidValidity、uidNext、messages。 -
uidValidity变了:清空该文件夹缓存。 - 只同步最近 300 个 UID(窗口可调);更早的邮件按需拉取。
- 用
FETCH拉envelope/flags/internalDate/size/bodyStructure,写入本地库。 - 预取最近 30 封正文,保证打开就能读;其余按需拉。
- 未读数从本地表统计,不盲信服务器计数。
- 同步失败保留旧缓存,只标错误。
Warning
Dela 在首次同步时会调用 messageDelete({ all: true }) 清空服务器草稿箱(\Drafts),然后改用本地草稿。这是真删、不可恢复的操作;接入前必须让用户明确确认。不要把它描述成「清理缓存」。
原则:不要每次全量拉整个邮箱。一个用了十年的邮箱可能有几万封,全量会把服务器和本地都拖垮。
遍历 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,防止路径穿越。
- 清洗脚本、样式、事件属性。
- 远程图片默认不加载;用户点「显示图片」再加载。
-
cid:内联图片可以转 data URL,但设总大小上限。 - 不要执行邮件里的 HTML/JS。
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 边界。
- UIDVALIDITY 变了不清缓存:UID 会指向错误的邮件。必须清空该文件夹重同步。
- 用 sequence number 当主键:删除/移动后全部错位。始终用 UID。
-
读取正文时误标已读:确认库使用
BODY.PEEK;需要标记已读时显式执行STORE \Seen。 -
盲信服务器未读数:服务器计数可能包含其他客户端的状态,或延迟更新。以本地
\Seen为准,定期校正。 - 全量同步整个邮箱:几万封邮件会拖垮。用最近窗口 + 按需拉取。
- 中文附件名乱码:RFC 2231/2047 编码,用库解析。
- 大附件整包读入内存:流式下载,临时文件 + rename。
- HTML 邮件直接渲染:XSS/跟踪像素。清洗 + 默认不加载远程图片。
- SMTP 成功但 APPEND 失败:邮件已发出,不能报失败;给 warning。
-
重复保存已发送:先按
Message-ID去重。 - 草稿箱/已删除文件夹:不同服务器行为不同;如果做本地草稿,就不要同时同步服务器草稿箱,避免两套状态打架。
- 邮箱密码与 jAccount 密码可能不同:修改过邮箱密码后使用新密码;不要假设两者始终同步。
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();
}- 不要用 POP3 做多设备同步:POP3 没有文件夹/旗标/UID 语义,容易重复下载和丢状态。
- 不要用明文 25 端口发信:用 465 SSL。
- 不要用管理员/共享邮箱凭据:只用本人邮箱。
- 不要自动群发/批量订阅:容易被判垃圾邮件,也违反邮箱使用规范。
- 服务器参数:
[官方 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、发信和常见问题。
本 Wiki 记录的是开发实践,不是学校官方承诺。每个结论都标注证据与日期;校园平台会调整字段、权限和登录方式,动手前请按页面里的「最后验证」复核。发现过期内容请提 Issue 或直接改。