Skip to content

任务1:实现POP3系统 ​

POP3 协议详解 (Post Office Protocol version 3) ​

POP3 是一种应用层协议,其全称为“邮局协议第3版”。顾名思义,它的工作模式非常像现实生活中的邮局信箱。

核心思想: 去邮局的信箱取信,把所有信件都拿出来带回家,然后的信箱就空了。在家阅读、整理、删除这些信件,这些操作都与邮局无关了。

POP3 就是这样一个“下载并删除”的协议。它允许邮件客户端连接到邮件服务器,将收件箱(INBOX)中的所有邮件下载到本地计算机,然后通常会从服务器上删除这些邮件。


主要特点 ​

  1. 简单性 (Simplicity): POP3 协议非常简单,命令数量少,交互逻辑直接。这使得客户端和服务器的实现都相对容易。

  2. 离线工作模式 (Offline Model): 它是为离线邮件阅读而设计的。用户连接网络,一次性收取所有新邮件,然后可以断开网络,在本地慢慢阅读、回复和管理邮件。

  3. 状态化会话 (Stateful Session): 一个 POP3 会话包含三个明确定义的状态。服务器会根据客户端当前所处的状态来决定哪些命令是合法的。

  4. 服务器资源占用少 (Low Server Resource Usage): 由于邮件默认被下载到本地后就从服务器删除,服务器不需要长期存储大量邮件,节省了存储空间。

  5. 不适合多设备同步 (Poor for Multi-Device Sync): 这是它最大的缺点。如果在电脑上用 POP3 收取了邮件,这些邮件就会从服务器上消失。此时再用手机登录,将看不到任何旧邮件,因为它们已经“被从邮局带回家了”。(虽然现在很多客户端提供了“在服务器上保留副本”的选项,但这并非 POP3 的原生设计,且管理起来依然不便)。


POP3 会话的三个阶段 ​

一个典型的 POP3 连接过程分为三个阶段:

1. 授权 (AUTHORIZATION) 阶段 ​

这是会话的开始阶段,客户端需要向服务器验明身份。

  • 目标:登录邮箱。
  • 过程:客户端连接到服务器的 110 端口后,服务器会发送一个 +OK 的欢迎语。然后客户端必须通过 USER 和 PASS 命令提供用户名和密码。
  • 可用命令:USER, PASS, QUIT。
  • 状态转换:一旦用户名和密码验证成功,会话就从 授权 阶段进入 事务 阶段。如果验证失败,会话仍停留在 授权 阶段。如果客户端发送 QUIT,则直接进入 更新 阶段。

2. 事务 (TRANSACTION) 阶段 ​

这是会话的核心阶段,客户端在此阶段进行邮件的查询、获取和标记删除。

  • 目标:管理邮件。
  • 过程:客户端可以查看邮箱统计信息、获取邮件列表、下载邮件内容,并标记要删除的邮件。
  • 重要提示:在这一阶段,DELE 命令只是“标记”邮件为待删除,并不会立即从服务器上真正删除。
  • 可用命令:
    • STAT: 获取邮箱状态(邮件总数和总大小)。
    • LIST: 列出每封邮件的编号和大小。
    • RETR [msg_id]: 获取指定编号的邮件全文。
    • DELE [msg_id]: 标记指定编号的邮件为待删除。
    • NOOP: 无操作,用于保持连接活动,服务器会回复 +OK。
    • RSET: 重置,清除所有在本阶段做的 DELE 标记。
    • QUIT: 结束事务,准备退出。
  • 状态转换:当客户端发送 QUIT 命令后,会话进入 更新 阶段。

3. 更新 (UPDATE) 阶段 ​

这是会话的结束阶段,服务器执行清理工作。

  • 目标:应用更改并关闭连接。
  • 过程:服务器会永久删除所有在 事务 阶段被 DELE 命令标记的邮件。完成删除后,服务器向客户端发送一个 +OK 的告别语,然后关闭 TCP 连接。
  • 可用命令:无(此阶段由服务器自动执行,不接受客户端新命令)。

常用 POP3 命令详解 ​

命令格式作用示例可能的响应
USERUSER <username>发送用户名USER user1@tio.com+OK (用户存在) / -ERR (用户不存在)
PASSPASS <password>发送密码PASS mysecret+OK (密码正确) / -ERR (密码错误)
STATSTAT获取邮箱统计信息STAT+OK 2 320 (2封邮件, 共320字节)
LISTLIST [msg_id]列出邮件(不带参数列出所有)LIST+OK 2 messages\r\n1 120\r\n2 200\r\n.
RETRRETR <msg_id>获取指定邮件的完整内容RETR 1+OK 120 octets\r\n(邮件内容)\r\n.
DELEDELE <msg_id>标记邮件为待删除DELE 1+OK message 1 deleted
RSETRSET取消所有删除标记RSET+OK maildrop has 2 messages
NOOPNOOP无操作(心跳)NOOP+OK
QUITQUIT结束会话QUIT+OK dewey POP3 server signing off

响应格式说明:

  • +OK: 表示命令成功执行。后面通常跟着一些说明文字。
  • -ERR: 表示命令执行失败。后面跟着失败原因。
  • 多行响应: 对于 LIST 和 RETR 等可能返回多行数据的命令,响应以 +OK 开始,然后是数据行,最后以一个单独的句点 . 结束。

一个完整的 Telnet 交互示例 ​

下面是一个手动使用 telnet 与 POP3 服务器交互的完整流程。S: 代表服务器响应,C: 代表客户端输入。

# 1. 连接到服务器的 110 端口
$ telnet mail.example.com 110

# 2. 服务器返回欢迎信息,进入【授权】阶段
S: +OK POP3 server ready <1896.697170952@mail.example.com>

# 3. 客户端发送用户名
C: USER alice

# 4. 服务器确认用户存在
S: +OK

# 5. 客户端发送密码
C: PASS mypassword

# 6. 服务器确认密码正确,进入【事务】阶段
S: +OK alice's maildrop has 2 messages (320 octets)

# 7. 客户端查看邮箱状态
C: STAT
S: +OK 2 320

# 8. 客户端列出所有邮件
C: LIST
S: +OK 2 messages (320 octets)
S: 1 120
S: 2 200
S: .

# 9. 客户端获取第一封邮件
C: RETR 1
S: +OK 120 octets
S: From: bob@example.com
S: To: alice@example.com
S: Subject: Hello
S: 
S: This is a test message.
S: .

# 10. 客户端决定删除第一封邮件
C: DELE 1
S: +OK message 1 deleted

# 11. 客户端决定不删除第二封邮件,准备退出
C: QUIT

# 12. 服务器进入【更新】阶段,永久删除被标记的邮件,并发送告别语,然后关闭连接
S: +OK dewey POP3 server signing off (maildrop empty)

# 连接已由外部主机关闭。

官方文档和资源链接 ​

  1. RFC 1939 - Post Office Protocol - Version 3

    • 链接: https://www.rfc-editor.org/rfc/rfc1939
    • 说明: 这是 POP3 协议的最终官方规范,也被称为“协议圣经”。所有关于 POP3 的实现细节、命令格式、状态转换的权威定义都在这里。如果要从头实现一个 POP3 服务器,这是必读文档。
  2. RFC 2449 - POP3 Extension Mechanism

  3. RFC 2595 - Using TLS with IMAP, POP3 and ACAP

    • 链接: https://www.rfc-editor.org/rfc/rfc2595
    • 说明: 定义了如何通过 STARTTLS 命令将一个不安全的 POP3 连接升级为安全的 TLS 加密连接。这是实现现代邮件服务安全性的关键。

实现POP3服务 ​


第一步:Pop3Packet 和创建会话上下文 ​

POP3 协议是基于字符串命令的,所以我们的 Packet 直接存储字符串会更方便。同时,我们需要一个会话上下文来跟踪每个客户端连接的状态(例如,是否已登录)。

1. Pop3Packet.java ​

将 byte[] 改为 String,方便处理命令。

java
// src/main/java/com/tio/mail/wing/packet/Pop3Packet.java
package com.tio.mail.wing.packet;

import nexus.io.aio.Packet;

/**
 * POP3 消息包,直接存储解码后的命令或响应字符串
 */
@SuppressWarnings("serial")
public class Pop3Packet extends Packet {
  private String line;

  public Pop3Packet(String line) {
    this.line = line;
  }

  public String getLine() {
    return line;
  }

  public void setLine(String line) {
    this.line = line;
  }
}

2. 创建 Pop3SessionContext.java ​

这是至关重要的一步,用于保存每个连接的会话状态。

java
// src/main/java/com/tio/mail/wing/handler/Pop3SessionContext.java
package com.tio.mail.wing.handler;

import nexus.io.tio.core.ChannelContext;
import nexus.io.tio.core.Tio;
import com.tio.mail.wing.packet.Pop3Packet;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class Pop3SessionContext {
  // 会话状态枚举
  public enum State {
    /**
     * 未认证
     */
    AUTHORIZATION,
    /**
     * 已认证,可以进行邮件操作
     */
    TRANSACTION,
    /**
     * 准备关闭
     */
    UPDATE
  }

  private State state = State.AUTHORIZATION;
  private String username;
  // 可以添加更多字段,如待删除邮件列表等

  /**
   * 发送 OK 响应
   * @param context ChannelContext
   * @param message 消息内容
   */
  public static void sendOk(ChannelContext context, String message) {
    String response = "+OK " + message + "\r\n";
    Tio.send(context, new Pop3Packet(response));
  }

  /**
   * 发送 ERR 响应
   * @param context ChannelContext
   * @param message 消息内容
   */
  public static void sendErr(ChannelContext context, String message) {
    String response = "-ERR " + message + "\r\n";
    Tio.send(context, new Pop3Packet(response));
  }

  /**
   * 发送多行数据
   * @param context ChannelContext
   * @param data 多行数据
   */
  public static void sendData(ChannelContext context, String data) {
    // 多行数据以 "." 结尾
    String response = data + "\r\n.\r\n";
    Tio.send(context, new Pop3Packet(response));
  }
}

第二步:创建模拟的业务服务 ​

为了让 Handler 能跑起来,我们先创建两个简单的内存版服务。

1. Email.java ​

java
// src/main/java/com/tio/mail/wing/model/Email.java
package com.tio.mail.wing.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.UUID;

/**
 * 邮件实体类
 * 代表一封存储在服务器上的邮件
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Email {

  /**
   * 唯一标识符 (UIDL)
   * 这是邮件的“身份证”,在邮件的整个生命周期中保持不变。
   * 使用 UUID 或者 数据库自增ID + 时间戳等方式确保唯一。
   */
  private String uid;

  /**
   * 邮件大小 (单位:字节)
   * 用于 LIST 命令的响应。
   */
  private int size;

  /**
   * 邮件的完整原始内容 (MIME 格式)
   * 包括邮件头和邮件体,用于 RETR 命令。
   */
  private String content;

  /**
   * 标记为待删除
   * 用于 DELE 命令。当会话结束时,标记为 true 的邮件将被真正删除。
   */
  private boolean deleted = false;

  /**
   * 一个便捷的构造函数,用于快速创建邮件对象。
   * 它会自动根据内容计算大小并生成 UID。
   * @param content 邮件的原始内容
   */
  public Email(String content) {
    this.content = content;
    // 在真实场景中,字符集转换可能更复杂,这里用 getBytes() 做简单估算
    this.size = content.getBytes().length;
    // 使用 UUID 生成一个唯一的、随机的 ID
    this.uid = UUID.randomUUID().toString().replace("-", "");
  }
}

2. UserService.java ​

java
// src/main/java/com/tio/mail/wing/service/UserService.java
package com.tio.mail.wing.service;

import nexus.io.annotation.AService;
import java.util.HashMap;
import java.util.Map;

public class UserService {
  // 模拟用户数据库
  private static final Map<String, String> users = new HashMap<>();

  static {
    users.put("user1@tio.com", "pass1");
    users.put("user2@tio.com", "pass2");
  }

  /**
   * 认证用户
   * @param username 用户名
   * @param password 密码
   * @return 是否成功
   */
  public boolean authenticate(String username, String password) {
    String storedPassword = users.get(username);
    return storedPassword != null && storedPassword.equals(password);
  }
}

3. MailService.java ​

java
// src/main/java/com/tio/mail/wing/service/MailService.java
package com.tio.mail.wing.service;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

import nexus.io.annotation.AService;
import com.tio.mail.wing.model.Email;

@AService
public class MailService {

  /**
   * 模拟邮件存储系统
   * Key: 用户名 (e.g., "user1@tio.com")
   * Value: 该用户的邮件列表 (List<Email>)
   */
  private static final Map<String, List<Email>> mailboxes = new ConcurrentHashMap<>();

  // 使用静态代码块初始化一些模拟数据
  static {
    // 为 user1@tio.com 创建邮箱并添加两封邮件
    List<Email> user1Emails = new ArrayList<>();
    user1Emails.add(new Email("From: sender@example.com\r\n" + "To: user1@tio.com\r\n" + "Subject: Test Mail 1\r\n" + "\r\n" + "This is the body of the first email."));
    user1Emails.add(new Email("From: another@example.com\r\n" + "To: user1@tio.com\r\n" + "Subject: Hello World\r\n" + "\r\n" + "This is the second message."));
    mailboxes.put("user1@tio.com", user1Emails);

    // 为 user2@tio.com 创建一个空邮箱
    mailboxes.put("user2@tio.com", new ArrayList<>());
  }

  /**
   * 获取用户邮箱中所有未被标记为删除的邮件。
   * @param username 用户名
   * @return 邮件列表
   */
  private List<Email> getActiveMessages(String username) {
    List<Email> userEmails = mailboxes.getOrDefault(username, new ArrayList<>());
    return userEmails.stream().filter(email -> !email.isDeleted()).collect(Collectors.toList());
  }

  /**
   * 获取邮箱状态(邮件数,总大小)
   * @param username 用户名
   * @return int[]{邮件数, 总大小}
   */
  public int[] getStat(String username) {
    List<Email> activeMessages = getActiveMessages(username);
    int count = activeMessages.size();
    int totalSize = activeMessages.stream().mapToInt(Email::getSize).sum();
    return new int[] { count, totalSize };
  }

  /**
   * 获取邮件大小列表,用于 LIST 命令。
   * @param username 用户名
   * @return 邮件大小列表
   */
  public List<Integer> listMessages(String username) {
    return getActiveMessages(username).stream().map(Email::getSize).collect(Collectors.toList());
  }

  /**
   * 获取指定邮件内容
   * @param username 用户名
   * @param msgNumber 邮件序号 (从 1 开始)
   * @return 邮件原始内容
   */
  public String getMessageContent(String username, int msgNumber) {
    List<Email> activeMessages = getActiveMessages(username);
    // 序号从 1 开始,列表索引从 0 开始,需要转换
    if (msgNumber > 0 && msgNumber <= activeMessages.size()) {
      return activeMessages.get(msgNumber - 1).getContent();
    }
    return null;
  }

  /**
   * 获取邮件的唯一ID列表,用于 UIDL 命令。
   * @param username 用户名
   * @return 邮件的唯一ID列表
   */
  public List<String> listUids(String username) {
    return getActiveMessages(username).stream().map(Email::getUid).collect(Collectors.toList());
  }
}

Pop3ServerAioListener ​

java
// src/main/java/com/tio/mail/wing/listener/Pop3ServerAioListener.java
package com.tio.mail.wing.listener;

import nexus.io.aio.Packet;
import nexus.io.tio.core.ChannelContext;
import nexus.io.tio.server.intf.ServerAioListener;
import com.tio.mail.wing.handler.Pop3SessionContext;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class Pop3ServerAioListener implements ServerAioListener {

  @Override
  public void onAfterConnected(ChannelContext channelContext, boolean isConnected, boolean isReconnect) throws Exception {
    if (isConnected) {
      log.info("POP3 client connected: {}", channelContext.getClientNode());
      // 1. 创建会话上下文
      Pop3SessionContext sessionContext = new Pop3SessionContext();
      channelContext.set("sessionContext", sessionContext);

      // 2. 立即发送欢迎消息
      Pop3SessionContext.sendOk(channelContext, "tio-mail-wing POP3 server ready.");
      log.info("POP3 >>> +OK welcome message sent to {}", channelContext.getClientNode());
    }
  }
  @Override
  public void onBeforeClose(ChannelContext channelContext, Throwable throwable, String remark, boolean isRemove) throws Exception {
    log.info("POP3 client disconnected: {}", channelContext.getClientNode());
  }

  @Override
  public void onAfterDecoded(ChannelContext channelContext, Packet packet, int packetSize) throws Exception {
    // Do nothing
  }

  @Override
  public void onAfterReceivedBytes(ChannelContext channelContext, int receivedBytes) throws Exception {
    // Do nothing
  }

  @Override
  public void onAfterSent(ChannelContext channelContext, Packet packet, boolean isSentSuccess) throws Exception {
    // Do nothing
  }

  @Override
  public void onAfterHandled(ChannelContext channelContext, Packet packet, long cost) throws Exception {
    // Do nothing
  }

  @Override
  public boolean onHeartbeatTimeout(ChannelContext channelContext, Long interval, int heartbeatTimeoutCount) {
    return false;
  }
}

Pop3ServerAioHandler ​

这是最核心的修改,我们将实现真正的 POP3 协议逻辑。

java
// src/main/java/com/tio/mail/wing/handler/Pop3ServerAioHandler.java
package com.tio.mail.wing.handler;

import java.nio.ByteBuffer;
import java.util.List;

import nexus.io.aio.Packet;
import nexus.io.jfinal.aop.Aop;
import nexus.io.tio.core.ChannelContext;
import nexus.io.tio.core.Tio;
import nexus.io.tio.core.TioConfig;
import nexus.io.tio.core.exception.LengthOverflowException;
import nexus.io.tio.core.exception.TioDecodeException;
import nexus.io.tio.core.utils.ByteBufferUtils;
import nexus.io.tio.server.intf.ServerAioHandler;
import com.tio.mail.wing.packet.Pop3Packet;
import com.tio.mail.wing.service.MailService;
import com.tio.mail.wing.service.UserService;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class Pop3ServerAioHandler implements ServerAioHandler {

  private UserService userService = Aop.get(UserService.class);

  private MailService MailService = Aop.get(MailService.class);

  private static final String CHARSET = "UTF-8";

  /**
   * 解码:从 ByteBuffer 中解析出以 \r\n 结尾的一行命令
   */
  @Override
  public Packet decode(ByteBuffer buffer, int limit, int position, int readableLength, ChannelContext channelContext) throws TioDecodeException {
    // Tio内置了行解码器,非常方便
    String line = null;
    try {
      line = ByteBufferUtils.readLine(buffer, CHARSET);
    } catch (LengthOverflowException e) {
      e.printStackTrace();
    }

    // 如果 line 为 null,表示数据不完整,不是一个完整的行,需要等待更多数据
    if (line == null) {
      return null;
    }

    // 返回一个包含该行命令的 Packet
    return new Pop3Packet(line);
  }

  /**
   * 编码:将响应字符串转换为 ByteBuffer
   */
  @Override
  public ByteBuffer encode(Packet packet, TioConfig tioConfig, ChannelContext channelContext) {
    Pop3Packet pop3Packet = (Pop3Packet) packet;
    String line = pop3Packet.getLine();
    try {
      byte[] bytes = line.getBytes(CHARSET);
      ByteBuffer buffer = ByteBuffer.allocate(bytes.length);
      buffer.put(bytes);
      return buffer;
    } catch (Exception e) {
      log.error("Encoding error", e);
      return null;
    }
  }

  /**
   * 消息处理:根据收到的命令和当前会话状态进行响应
   */
  @Override
  public void handler(Packet packet, ChannelContext channelContext) throws Exception {
    Pop3Packet pop3Packet = (Pop3Packet) packet;
    String commandLine = pop3Packet.getLine().trim();
    log.info("POP3 <<< {}", commandLine);

    // 获取或创建会日志上下文
    Pop3SessionContext sessionContext = (Pop3SessionContext) channelContext.get("sessionContext");

    String[] parts = commandLine.split("\\s+", 2);
    String command = parts[0].toUpperCase();

    // 根据会话状态处理命令
    switch (sessionContext.getState()) {
    case AUTHORIZATION:
      handleAuthorizationState(command, parts, channelContext, sessionContext);
      break;
    case TRANSACTION:
      handleTransactionState(command, parts, channelContext, sessionContext);
      break;
    case UPDATE:
      // 在 UPDATE 状态,通常只响应 QUIT
      if ("QUIT".equals(command)) {
        handleQuit(channelContext, sessionContext);
      } else {
        Pop3SessionContext.sendErr(channelContext, "Command not allowed in UPDATE state.");
      }
      break;
    }
  }

  private void handleAuthorizationState(String command, String[] parts, ChannelContext channelContext, Pop3SessionContext sessionContext) {
    switch (command) {
    case "CAPA":
      // 1. 先发送 +OK 响应头
      Pop3SessionContext.sendOk(channelContext, "Capability list follows");

      // 2. 构造多行响应体
      StringBuilder capaBuilder = new StringBuilder();
      capaBuilder.append("TOP\r\n"); // 声明支持 TOP 命令
      capaBuilder.append("USER\r\n"); // 声明支持 USER/PASS 认证
      capaBuilder.append("UIDL\r\n"); // 声明支持 UIDL 命令
      capaBuilder.append("PIPELINING\r\n"); // 声明支持管道,可以提高效率
      // capaBuilder.append("STLS\r\n");  // 如果未来支持 STARTTLS,可以取消这行注释
      capaBuilder.append("."); // 以点结束多行响应

      // 3. 发送响应体
      Tio.send(channelContext, new Pop3Packet(capaBuilder.toString() + "\r\n"));
      // 注意:因为我们自己拼接了多行响应,所以不需要用 sendData 方法,
      // 并且日志也应该在发送时手动打印,以保持一致性。
      log.info("POP3 >>>\n{}", capaBuilder.toString().trim());
      break;
    case "USER":
      if (parts.length < 2) {
        Pop3SessionContext.sendErr(channelContext, "Username required.");
        return;
      }
      sessionContext.setUsername(parts[1]);
      Pop3SessionContext.sendOk(channelContext, "Password required for " + parts[1]);
      break;
    case "PASS":
      if (sessionContext.getUsername() == null) {
        Pop3SessionContext.sendErr(channelContext, "USER command first.");
        return;
      }
      if (parts.length < 2) {
        Pop3SessionContext.sendErr(channelContext, "Password required.");
        return;
      }
      if (userService.authenticate(sessionContext.getUsername(), parts[1])) {
        sessionContext.setState(Pop3SessionContext.State.TRANSACTION);
        Pop3SessionContext.sendOk(channelContext, "Mailbox open.");
      } else {
        Pop3SessionContext.sendErr(channelContext, "Authentication failed.");
        sessionContext.setUsername(null); // 认证失败,清空用户名
      }
      break;
    case "QUIT":
      handleQuit(channelContext, sessionContext);
      break;
    default:
      Pop3SessionContext.sendErr(channelContext, "Unknown command or command not allowed.");
    }
  }

  private void handleTransactionState(String command, String[] parts, ChannelContext channelContext, Pop3SessionContext sessionContext) {
    String username = sessionContext.getUsername(); // 获取当前用户名
    switch (command) {
    case "STAT":
      int[] stat = MailService.getStat(sessionContext.getUsername());
      Pop3SessionContext.sendOk(channelContext, stat[0] + " " + stat[1]);
      break;
    case "TOP":
      if (parts.length < 2) {
        Pop3SessionContext.sendErr(channelContext, "Message number and number of lines required.");
        return;
      }
      String[] topArgs = parts[1].split("\\s+");
      if (topArgs.length < 2) {
        Pop3SessionContext.sendErr(channelContext, "Message number and number of lines required.");
        return;
      }

      try {
        int msgId = Integer.parseInt(topArgs[0]);
        int lines = Integer.parseInt(topArgs[1]);

        String content = MailService.getMessageContent(username, msgId);
        if (content != null) {
          Pop3SessionContext.sendOk(channelContext, "Top of message follows");

          // 简单的模拟实现:返回邮件头和正文的前几行
          String[] contentLines = content.split("\r\n");
          StringBuilder topResponse = new StringBuilder();

          boolean inBody = false;
          int bodyLinesCount = 0;

          for (String line : contentLines) {
            topResponse.append(line).append("\r\n");
            if (line.isEmpty()) { // 空行是邮件头和体的分隔符
              inBody = true;
            }
            if (inBody && !line.isEmpty()) {
              bodyLinesCount++;
            }
            if (inBody && bodyLinesCount >= lines) {
              break; // 正文行数已达到要求
            }
          }
          topResponse.append(".");

          Tio.send(channelContext, new Pop3Packet(topResponse.toString() + "\r\n"));
          log.info("POP3 >>>\n{}", topResponse.toString().trim());

        } else {
          Pop3SessionContext.sendErr(channelContext, "No such message.");
        }

      } catch (NumberFormatException e) {
        Pop3SessionContext.sendErr(channelContext, "Invalid arguments for TOP command.");
      }
      break;
    case "LIST":
      List<Integer> sizes = MailService.listMessages(username);
      Pop3SessionContext.sendOk(channelContext, sizes.size() + " messages");

      StringBuilder listResponse = new StringBuilder();
      for (int i = 0; i < sizes.size(); i++) {
        listResponse.append(i + 1).append(" ").append(sizes.get(i)).append("\r\n");
      }
      listResponse.append(".");

      Tio.send(channelContext, new Pop3Packet(listResponse.toString() + "\r\n"));
      log.info("POP3 >>>\n{}", listResponse.toString().trim());
      break;

    case "UIDL":
      List<String> uids = MailService.listUids(username);
      Pop3SessionContext.sendOk(channelContext, "Unique-ID listing follows");

      StringBuilder uidlResponse = new StringBuilder();
      for (int i = 0; i < uids.size(); i++) {
        // 序号 (i+1) 和 唯一ID
        uidlResponse.append(i + 1).append(" ").append(uids.get(i)).append("\r\n");
      }
      uidlResponse.append(".");

      Tio.send(channelContext, new Pop3Packet(uidlResponse.toString() + "\r\n"));
      log.info("POP3 >>>\n{}", uidlResponse.toString().trim());
      break;

    case "RETR":
      if (parts.length < 2) {
        Pop3SessionContext.sendErr(channelContext, "Message ID required.");
        return;
      }
      try {
        int msgId = Integer.parseInt(parts[1]);
        String content = MailService.getMessageContent(sessionContext.getUsername(), msgId);
        if (content != null) {
          Pop3SessionContext.sendOk(channelContext, "Message " + msgId + " follows");
          Pop3SessionContext.sendData(channelContext, content);
        } else {
          Pop3SessionContext.sendErr(channelContext, "No such message.");
        }
      } catch (NumberFormatException e) {
        Pop3SessionContext.sendErr(channelContext, "Invalid message ID.");
      }
      break;
    case "DELE":
      // TODO: 实现标记删除逻辑
      Pop3SessionContext.sendOk(channelContext, "Message marked for deletion.");
      break;
    case "NOOP":
      Pop3SessionContext.sendOk(channelContext, "");
      break;
    case "RSET":
      // TODO: 实现取消删除标记的逻辑
      Pop3SessionContext.sendOk(channelContext, "Deletion marks removed.");
      break;
    case "QUIT":
      handleQuit(channelContext, sessionContext);
      break;
    default:
      Pop3SessionContext.sendErr(channelContext, "Unknown command.");
    }
  }

  private void handleQuit(ChannelContext channelContext, Pop3SessionContext sessionContext) {
    sessionContext.setState(Pop3SessionContext.State.UPDATE);
    // TODO: 在这里执行真正的删除操作
    Pop3SessionContext.sendOk(channelContext, "tio-mail-wing POP3 server signing off.");
    Tio.close(channelContext, "Client requested QUIT.");
  }
}

Pop3ServerConfig ​

java
// src/main/java/com/tio/mail/wing/config/Pop3ServerConfig.java
package com.tio.mail.wing.config;

import java.io.IOException;

import nexus.io.annotation.AConfiguration;
import nexus.io.annotation.Initialization;
import nexus.io.jfinal.aop.Aop;
import nexus.io.tio.server.ServerTioConfig;
import nexus.io.tio.server.TioServer;
import nexus.io.tio.server.intf.ServerAioHandler;
import nexus.io.tio.utils.environment.EnvUtils;
import com.tio.mail.wing.handler.Pop3ServerAioHandler;
import com.tio.mail.wing.listener.Pop3ServerAioListener;

import lombok.extern.slf4j.Slf4j;

@AConfiguration
@Slf4j
public class Pop3ServerConfig {

  @Initialization
  public void startPop3Server() {

    ServerAioHandler serverHandler = Aop.get(Pop3ServerAioHandler.class);
    Pop3ServerAioListener pop3ServerAioListener = Aop.get(Pop3ServerAioListener.class);

    // 配置对象
    ServerTioConfig serverTioConfig = new ServerTioConfig("pop3-server");
    serverTioConfig.setServerAioHandler(serverHandler);
    serverTioConfig.setServerAioListener(pop3ServerAioListener);
    serverTioConfig.checkAttacks = false;

    // 设置心跳,-1 取消心跳
    serverTioConfig.setHeartbeatTimeout(-1);

    // TioServer对象
    TioServer tioServer = new TioServer(serverTioConfig);

    // 启动服务
    try {
      int port = EnvUtils.getInt("mail.server.pop3.port", 110);
      tioServer.start(null, port);
      log.info("Started POP3 server on port: {}", port);
    } catch (IOException e) {
      log.error("Failed to start POP3 server", e);
    }
  }
}

测试 ​

telnet ​

现在,的 POP3 服务器已经具备了基础的协议交互能力。可以使用 telnet 工具来手动测试它。

前提:

  1. 确保的 tio-mail-wing 项目正在运行。
  2. 打开的命令行工具(Windows 的 CMD/PowerShell,macOS/Linux 的 Terminal)。

测试步骤:

  1. 连接服务器 输入以下命令并回车。如果的端口不是 110,请替换。

    bash
    telnet localhost 110

    如果连接成功,服务器会返回欢迎信息:

    +OK tio-mail-wing POP3 server ready.
  2. 发送用户名 输入 USER 命令。

    USER user1@tio.com

    服务器响应:

    +OK Password required for user1@tio.com
  3. 发送密码 输入 PASS 命令。

    PASS 00000000

    服务器响应,表示登录成功:

    +OK Mailbox open.
  4. 查看邮箱状态 输入 STAT 命令。

    STAT

    服务器返回邮件数量和总大小(来自我们模拟的 MailService):

    +OK 2 320
  5. 列出邮件列表 输入 LIST 命令。

    LIST

    服务器返回多行响应:

    +OK 2 messages
    1 120
    2 200
    .
  6. 读取第一封邮件 输入 RETR 1 命令。

    RETR 1

    服务器返回邮件的完整内容:

    +OK Message 1 follows
    From: sender@example.com
    To: user1@tio.com
    Subject: Test Mail 1
    
    This is the body of the first email.
    .
  7. 退出 输入 QUIT 命令。

    QUIT

    服务器响应并关闭连接:

    +OK tio-mail-wing POP3 server signing off.

    telnet 会话将在此处终止。

如果能顺利完成以上所有步骤,并看到预期的响应,那么恭喜,第一阶段的 POP3 服务核心协议实现已经成功完成!

下一步就是将 UserService 和 MailService 对接到真实的数据库或文件系统(如 Maildir 格式)。

curl ​

C:\Users\Administrator>curl -v --url "pop3://localhost:110" --user "user1@tio.com:00000000"
* Host localhost:110 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:110...
* Connected to localhost (::1) port 110
< +OK tio-mail-wing POP3 server ready.
> CAPA
< +OK Capability list follows
< TOP
< USER
< UIDL
< PIPELINING
< .
> USER user1@tio.com
< +OK Password required for user1@tio.com
> PASS pass1
< +OK Mailbox open.
> LIST
< +OK 2 messages
1 105
2 97
* Connection #0 to host localhost left intact

C:\Users\Administrator>curl -v --url "pop3://localhost:110/1" --user "user1@tio.com:00000000"
* Host localhost:110 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:110...
* Connected to localhost (::1) port 110
< +OK tio-mail-wing POP3 server ready.
> CAPA
< +OK Capability list follows
< TOP
< USER
< UIDL
< PIPELINING
< .
> USER user1@tio.com
< +OK Password required for user1@tio.com
> PASS pass1
< +OK Mailbox open.
> RETR 1
< +OK Message 1 follows
From: sender@example.com
To: user1@tio.com
Subject: Test Mail 1

This is the body of the first email.
* Connection #0 to host localhost left intact

MIT Licensed