PAYATHON 2026

Spring JavaMailSender 无法连接 Testcontainers Mailpit,如何解决?

排障赵师傅

结论

两个容器已经加入同一个 TESTCONTAINERS_NETWORK,被测服务应通过 Mailpit 的网络别名和容器内部的 SMTP 端口连接:

mailpit:1025

为 Mailpit 设置 .withNetworkAliases("mailpit"),再将 JavaMailSender 的主机名改为 "mailpit"。这条连接不需要 Testcontainers.exposeHostPorts(1025) 或 withAccessToHost(true)。

[EOF] 表示客户端读取 SMTP 欢迎消息时,连接已经结束。当前配置应先修正连接地址。仅凭这条异常,还无法确定是哪个服务或转发环节关闭了连接。

为什么当前配置会失败

host.testcontainers.internal 用于让容器访问运行测试进程的宿主机,不会自动指向另一个 Testcontainers 容器。

这段代码暴露的是宿主机上的 1025 端口:

Testcontainers.exposeHostPorts(1025);

它不会把 Mailpit 容器内部的 1025 端口绑定到宿主机的同名端口。Testcontainers 通常会将容器端口映射到宿主机的随机可用端口,因此,即使 Mailpit 已暴露 SMTP 端口,也不能认为宿主机的 1025 就是 Mailpit。

当前配置的连接路径相当于:

被测服务容器 → 宿主机:1025

这条路径没有明确连接到 Mailpit。如果宿主机的目标端口没有正确的 SMTP 服务,或者转发链路在建立连接后关闭,客户端就可能读到 [EOF]。

SMTP 服务正常接受连接后,会先发送以 220 开头的欢迎消息。Got bad greeting 出现在读取这条消息时,此时尚未进行 SMTP 身份认证,也未发送 STARTTLS 命令。调整用户名、密码或延长超时,都无法修复连接目标错误的问题。

推荐配置:通过共享网络直接连接

1. 为 Mailpit 设置网络别名

在现有的 MailpitContainer 配置中增加网络别名:

@Container
static MailpitContainer mailpitContainer = new MailpitContainer()
        .withNetwork(TESTCONTAINERS_NETWORK)
        .withNetworkAliases("mailpit");

被测服务保留共享网络配置:

this.withNetwork(TESTCONTAINERS_NETWORK);

这条 SMTP 连接不再需要暴露宿主机端口,可以删除:

Testcontainers.exposeHostPorts(1025);

也可以删除允许访问宿主机的配置:

this.withAccessToHost(true);

如果被测服务还需要访问宿主机上的其他服务,则按那些连接的需要保留相关配置。

2. 修改 JavaMailSender

如果 Mailpit 仍使用容器内的 1025 端口,并允许无认证、无 TLS 的 SMTP 连接,可以这样配置:

@Bean
public JavaMailSenderImpl createJavaMailSenderBean() {
    JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
    mailSender.setHost("mailpit");
    mailSender.setPort(1025);
    mailSender.setProtocol("smtp");
    mailSender.setJavaMailProperties(createJavaMailProperties());
    return mailSender;
}

private Properties createJavaMailProperties() {
    Properties properties = new Properties();
    properties.setProperty("mail.smtp.auth", "false");
    properties.setProperty("mail.smtp.starttls.enable", "false");
    properties.setProperty("mail.smtp.ssl.enable", "false");
    properties.setProperty("mail.smtp.connectiontimeout", "180000");
    properties.setProperty("mail.smtp.timeout", "180000");
    return properties;
}

未启用认证时,可以省略用户名和密码设置。如果 Mailpit 配置了认证或 TLS,则按实际配置调整。

使用 setProperty() 代替 Properties.put(),以字符串形式设置属性值,可以避免属性读取方式带来的差异。本例仍应先修正连接地址。

3. 保证 Mailpit 已经就绪

发送邮件时,Mailpit 的 SMTP 服务必须已经开始监听。

如果被测服务在启动阶段就会发送邮件,应在启动服务容器前为它设置 dependsOn(mailpitContainer),并确认 Mailpit 容器的等待策略包含 SMTP 就绪条件。不能仅凭字段声明顺序,就认为两个容器已按业务需要完成启动。

什么时候使用 getHost() 和 getMappedPort()

连接地址取决于发送邮件的代码运行在哪里:

发送邮件的位置主机名端口
与 Mailpit 共享网络的容器内网络别名,例如 mailpit容器内部 SMTP 端口,例如 1025
宿主机上的测试 JVM 内mailpitContainer.getHost()mailpitContainer.getMappedPort(1025)

如果 JavaMailSender 运行在测试 JVM 中,可以在 Mailpit 启动后使用:

mailSender.setHost(mailpitContainer.getHost());
mailSender.setPort(mailpitContainer.getMappedPort(1025));

前提是所用的 MailpitContainer 已暴露 1025 端口。

本例的发送代码运行在被测服务容器内,应优先使用网络别名,不要直接套用宿主机侧的地址和映射端口。

修改后仍出现 EOF,如何排查

从被测服务容器内部检查 mailpit:1025,以验证应用实际使用的网络路径。如果镜像中安装了 nc,可以执行:

nc -v mailpit 1025

正常情况下,应能看到以 220 开头的 SMTP 欢迎消息,具体文本取决于 Mailpit 配置。

如果仍收不到欢迎消息,继续检查:

  • 两个容器是否确实连接到同一个 Docker 网络,且已设置 mailpit 别名。
  • Mailpit 的 SMTP 监听端口是否仍为 1025,监听接口是否允许通过容器网络访问。
  • 连接的是否为 SMTP 端口,有没有误用 Web 界面的端口。
  • Mailpit 是否启用了隐式 TLS,导致服务端与客户端预期的连接协议不同。
  • Mailpit 容器日志中是否有启动失败、退出或连接异常。

必要时,可以临时开启 JavaMail 调试日志:

properties.setProperty("mail.debug", "true");

延长 mail.smtp.connectiontimeout 或 mail.smtp.timeout 只能改变等待时间,无法让错误的端点返回有效的 SMTP 欢迎消息。

备注:内容仅供参考。