发送 Email
# 发送 Email
我们通常不会直接使用网络层的 TCP 和 UDP 协议,而是使用应用层的协议,例如邮件协议 SMTP,以及 HTTP 协议等等。本文演示下如何使用 SMTP 协议发送邮件。
本文主要参考廖雪峰老师的教程 发送 Email - 廖雪峰的官方网站 (opens new window),并结合自己的实践。
# 什么是 Email
Email 就是电子邮件。电子邮件的应用已经有几十年的历史了,我们熟悉的邮箱地址比如 abc@qq.com
,邮件软件比如 Outlook,foxmail 都是用来收发邮件的。
使用 Java 程序也可以收发电子邮件。我们先来看一下传统的邮件是如何发送的。
传统的邮件是通过邮局投递,然后从一个邮局到另一个邮局,最终到达用户的邮箱:
┌──────────┐ ┌──────────┐
│PostOffice│ │PostOffice│ .───.
┌─────┐ ├──────────┤ ├──────────┤ ( ( )
│═══ ░│───→│ ┌─┐ ┌┐┌┐ │───→│ ┌─┐ ┌┐┌┐ │───→ `─┬─'
└─────┘ │ │░│ └┘└┘ │ │ │░│ └┘└┘ │ │
└─┴─┴──────┘ └─┴─┴──────┘ │
2
3
4
5
6
电子邮件的发送过程也是类似的,只不过是电子邮件是从用户电脑的邮件软件,例如 Outlook,发送到邮件服务器上,可能经过若干个邮件服务器的中转,最终到达对方邮件服务器上,收件方就可以用软件接收邮件:
┌─────────┐ ┌─────────┐ ┌─────────┐
│░░░░░░░░░│ │░░░░░░░░░│ │░░░░░░░░░│
┌───────┐ ├─────────┤ ├─────────┤ ├─────────┤ ┌───────┐
│░░░░░░░│ │░░░░░░░░░│ │░░░░░░░░░│ │░░░░░░░░░│ │░░░░░░░│
├───────┤ ├─────────┤ ├─────────┤ ├─────────┤ ├───────┤
│ │───→│O ░░░░░░░│───→│O ░░░░░░░│───→│O ░░░░░░░│←───│ │
└───────┘ └─────────┘ └─────────┘ └─────────┘ └───────┘
MUA MTA MTA MDA MUA
2
3
4
5
6
7
8
几个术语解释:
- 我们把类似 Outlook 这样的邮件软件称为 MUA:Mail User Agent,意思是给用户服务的邮件代理;
- 邮件服务器则称为 MTA:Mail Transfer Agent,意思是邮件中转的代理;
- 最终到达的邮件服务器称为 MDA:Mail Delivery Agent,意思是邮件到达的代理。电子邮件一旦到达 MDA,就不再动了。实际上,电子邮件通常就存储在 MDA 服务器的硬盘上,然后等收件人通过软件或者登陆浏览器查看邮件。
- MTA 和 MDA 这样的服务器软件通常是现成的,我们不关心这些服务器内部是如何运行的。要发送邮件,我们关心的是如何编写一个 MUA 的软件,把邮件发送到 MTA 上。
- MUA 到 MTA 发送邮件的协议就是 SMTP 协议,它是 Simple Mail Transport Protocol 的缩写,使用标准端口 25,也可以使用加密端口 465 或 587。
- SMTP 协议是一个建立在 TCP 之上的协议,任何程序发送邮件都必须遵守 SMTP 协议。使用 Java 程序发送邮件时,我们无需关心 SMTP 协议的底层原理,只需要使用 JavaMail 这个标准 API 就可以直接发送邮件。
# 准备 SMTP 登录信息
假设我们准备使用自己的邮件地址 me@example.com
给小明发送邮件,已知小明的邮件地址是 xm@somewhere.com
,发送邮件前,我们首先要确定作为 MTA 的邮件服务器地址和端口号。邮件服务器地址通常是 smtp.example.com
,端口号由邮件服务商确定使用 25、465 还是 587。以下是一些常用邮件服务商的 SMTP 信息:
- QQ 邮箱:SMTP 服务器是 smtp.qq.com,端口是 465/587;
- 163 邮箱:SMTP 服务器是 smtp.163.com,端口是 465;
- Gmail 邮箱:SMTP 服务器是 smtp.gmail.com,端口是 465/587。
有了 SMTP 服务器的域名和端口号,我们还需要 SMTP 服务器的登录信息,通常是使用自己的邮件地址作为用户名,登录口令是用户口令或者一个独立设置的 SMTP 口令。
例如在配置 QQ 邮箱的时候,需要设置收信和发信服务器,并设置账号和密码。
注意 QQ 邮箱使用的是授权码,而不是账号密码。可以通过网页端 mail.qq.com (opens new window) 后,在设置--账户里获取授权码
# 使用 JavaMail
我们来看看如何使用 JavaMail 发送邮件。
# 准备依赖
首先,我们需要创建一个 Maven 工程,并把 JavaMail 相关的两个依赖加入进来:
jakarta.mail-2.0.1.jar
jakarta.mail-api-2.1.0.jar
jakarta.activation-2.0.1.jar
2
3
这两个包一个是接口定义,一个是具体实现。
如果你会 Maven,可以这样设置 pom.xml
<dependency>
<groupId>jakarta.mail</groupId>
<artifactId>jakarta.mail-api</artifactId>
<version>2.0.1</version>
</dependency>
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.1</version>
</dependency>
2
3
4
5
6
7
8
9
10
11
# 配置服务器信息
然后,我们通过 JavaMail API 连接到 SMTP 服务器上。我们创建一个 properties 文件,设置相关参数(密码已脱敏,请自行更换为自己的授权码):
mail.smtp.host=smtp.qq.com
mail.smtp.port=587
mail.smtp.auth=true
mail.smtp.starttls.enable=true
mail.smtp.username=peterjxl@qq.com
mail.smtp.password=****
2
3
4
5
6
然后就是加载配置文件,并创建一个 Session 对象:
Properties properties = new Properties();
properties.load(new FileInputStream("src/chapter20/mail.properties"));
String username = properties.getProperty("mail.smtp.username");
String password = properties.getProperty("mail.smtp.password");
Session session = Session.getInstance(properties, new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(username, password);
}
});
session.setDebug(true);
2
3
4
5
6
7
8
9
10
11
12
13
14
获取 Session
实例时,如果服务器需要认证,还需传入一个 Authenticator
对象,并返回指定的用户名和密码
当我们获取到 Session
实例后,设置调试模式,这样发送邮件时可以看到 SMTP 通信的详细内容,便于调试。
# 发送邮件
发送邮件时,我们需要构造一个 Message
对象,然后调用 Transport.send(Message)
即可完成发送,这里我设置自己给自己发邮件:
MimeMessage message = new MimeMessage(session);
//设置发送方地址:
message.setFrom(new InternetAddress("peterjxl@qq.com"));
//设置接收方地址
message.setRecipient(Message.RecipientType.TO, new InternetAddress("peterjxl@qq.com"));
//设置邮件主题
message.setSubject("Hello", "UTF-8");
//设置邮件正文
message.setText("Hi PeterJXL", "UTF-8");
Transport.send(message);
2
3
4
5
6
7
8
9
10
注意:绝大多数邮件服务器要求发送方地址和登录用户名必须一致,否则发送将失败。
运行上述代码,我们可以在控制台看到 JavaMail 打印的调试信息:
DEBUG: setDebug: Jakarta Mail version 2.0.1
DEBUG: getProvider() returning jakarta.mail.Provider[TRANSPORT,smtp,com.sun.mail.smtp.SMTPTransport,Oracle]
DEBUG SMTP: need username and password for authentication
DEBUG SMTP: protocolConnect returning false, host=smtp.qq.com, user=peterjxl, password=<null>
DEBUG SMTP: useEhlo true, useAuth true
DEBUG SMTP: trying to connect to host "smtp.qq.com", port 587, isSSL false
220 newxmesmtplogicsvrszb9-0.qq.com XMail Esmtp QQ Mail Server.
DEBUG SMTP: connected to host "smtp.qq.com", port: 587
EHLO peter
250-newxmesmtplogicsvrszb9-0.qq.com
250-PIPELINING
250-SIZE 73400320
250-STARTTLS
250-AUTH LOGIN PLAIN XOAUTH XOAUTH2
250-AUTH=LOGIN
250-MAILCOMPRESS
250 8BITMIME
DEBUG SMTP: Found extension "PIPELINING", arg ""
DEBUG SMTP: Found extension "SIZE", arg "73400320"
DEBUG SMTP: Found extension "STARTTLS", arg ""
DEBUG SMTP: Found extension "AUTH", arg "LOGIN PLAIN XOAUTH XOAUTH2"
DEBUG SMTP: Found extension "AUTH=LOGIN", arg ""
DEBUG SMTP: Found extension "MAILCOMPRESS", arg ""
DEBUG SMTP: Found extension "8BITMIME", arg ""
STARTTLS
220 Ready to start TLS from 116.21.30.25 to newxmesmtplogicsvrszb9-0.qq.com.
EHLO peter
250-newxmesmtplogicsvrszb9-0.qq.com
250-PIPELINING
250-SIZE 73400320
250-AUTH LOGIN PLAIN XOAUTH XOAUTH2
250-AUTH=LOGIN
250-MAILCOMPRESS
250 8BITMIME
DEBUG SMTP: Found extension "PIPELINING", arg ""
DEBUG SMTP: Found extension "SIZE", arg "73400320"
DEBUG SMTP: Found extension "AUTH", arg "LOGIN PLAIN XOAUTH XOAUTH2"
DEBUG SMTP: Found extension "AUTH=LOGIN", arg ""
DEBUG SMTP: Found extension "MAILCOMPRESS", arg ""
DEBUG SMTP: Found extension "8BITMIME", arg ""
DEBUG SMTP: protocolConnect login, host=smtp.qq.com, user=peterjxl@qq.com, password=<non-null>
DEBUG SMTP: Attempt to authenticate using mechanisms: LOGIN PLAIN DIGEST-MD5 NTLM XOAUTH2
DEBUG SMTP: Using mechanism LOGIN
DEBUG SMTP: AUTH LOGIN command trace suppressed
DEBUG SMTP: AUTH LOGIN succeeded
DEBUG SMTP: use8bit false
MAIL FROM:<peterjxl@qq.com>
250 OK
RCPT TO:<peterjxl@qq.com>
250 OK
DEBUG SMTP: Verified Addresses
DEBUG SMTP: peterjxl@qq.com
DATA
354 End data with <CR><LF>.<CR><LF>.
Date: Sat, 15 Apr 2023 16:12:24 +0800 (CST)
From: peterjxl@qq.com
To: peterjxl@qq.com
Message-ID: <22069592.0.1681546344477@peter>
Subject: Hello
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
Hi PeterJXL
.
250 OK: queued as.
DEBUG SMTP: message successfully delivered to mail server
QUIT
221 Bye.
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
从上面的调试信息可以看出,SMTP 协议是一个请求-响应协议,客户端总是发送命令,然后等待服务器响应。服务器响应总是以数字开头,后面的信息才是用于调试的文本。这些响应码已经被定义在 SMTP 协议 (opens new window)中了,查看具体的响应码就可以知道出错原因。
如果一切顺利,对方将收到一封文本格式的电子邮件:
# 发送 HTML 邮件
发送 HTML 邮件和文本邮件是类似的,只需要把:
message.setText(body, "UTF-8");
改为:
message.setText(body, "UTF-8", "html");
传入的 body
是类似 <h1>Hello</h1><p>Hi, PeterJXL</p>
这样的 HTML 字符串即可。
HTML 邮件可以在邮件客户端直接显示为网页格式:
# 发送附件
要在电子邮件中携带附件,我们就不能直接调用 message.setText()
方法,而是要构造一个 Multipart
对象:
//准备邮件内容
Multipart multipart = new MimeMultipart();
// 添加text,也就是邮件正文
BodyPart textPart = new MimeBodyPart();
textPart.setContent("<h1>Hello</h1> <p>Hi, PeterJXL</p>", "text/html;charset=utf-8");
multipart.addBodyPart(textPart);
// 添加图片,也就是附件
BodyPart imagePart = new MimeBodyPart();
imagePart.setFileName("fuk.jpg");
InputStream input = new FileInputStream("src/chapter20/fuk.jpg");
imagePart.setDataHandler(new DataHandler(new ByteArrayDataSource(input, "application/octet-stream")));
multipart.addBodyPart(imagePart);
// 设置邮件内容为multipart:
message.setContent(multipart);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
一个 Multipart
对象可以添加若干个 BodyPart
,其中第一个 BodyPart
是文本,即邮件正文,后面的 BodyPart 是附件。
BodyPart
依靠 setContent()
决定添加的内容,如果添加文本,用 setContent("...", "text/plain;charset=utf-8")
添加纯文本,或者用 setContent("...", "text/html;charset=utf-8")
添加 HTML 文本。
如果添加附件,需要设置文件名(不一定和真实文件名一致),并且添加一个 DataHandler()
,传入文件的 MIME 类型。二进制文件可以用 application/octet-stream
,Word 文档则是 application/msword
。
最后,通过 setContent()
把 Multipart
添加到 Message
中,即可发送。
观察控制台,可以看到一大堆字符串,这是因为 SMTP 是文本协议,二进制数据(图片)需要通过 Base64 编码为纯文本。忘记的同学可以复习下 Base64 编码 | 从 01 开始 (opens new window)
带附件的邮件在客户端会被提示下载:
# 发送内嵌图片的 HTML 邮件
有些童鞋可能注意到,HTML 邮件中可以内嵌图片,这是怎么做到的?
如果给一个 <img src="http://example.com/test.jpg">
,这样的外部图片链接通常会被邮件客户端过滤,并提示用户显示图片并不安全。只有内嵌的图片才能正常在邮件中显示。
内嵌图片实际上也是一个附件,即邮件本身也是 Multipart
,但需要做一点额外的处理:
Multipart multipart = new MimeMultipart();
// 添加text:
BodyPart textpart = new MimeBodyPart();
textpart.setContent("<h1>Hello</h1><p><img src=\"cid:img01\"></p>", "text/html;charset=utf-8");
multipart.addBodyPart(textpart);
// 添加image:
BodyPart imagepart = new MimeBodyPart();
imagepart.setFileName(fileName);
imagepart.setDataHandler(new DataHandler(new ByteArrayDataSource(input, "image/jpeg")));
// 与HTML的<img src="cid:img01">关联:
imagepart.setHeader("Content-ID", "<img01>");
multipart.addBodyPart(imagepart);
2
3
4
5
6
7
8
9
10
11
12
在 HTML 邮件中引用图片时,需要设定一个 ID,用类似 <img src=\"cid:img01\">
引用,然后,在添加图片作为 BodyPart 时,除了要正确设置 MIME 类型(根据图片类型使用 image/jpeg
或 image/png
),还需要设置一个 Header:
imagepart.setHeader("Content-ID", "<img01>");
这个 ID 和 HTML 中引用的 ID 对应起来,邮件客户端就可以正常显示内嵌图片:
# 常见问题
如果用户名或口令错误,会导致 535
登录失败:
DEBUG SMTP: AUTH LOGIN failed
Exception in thread "main" javax.mail.AuthenticationFailedException: 535 5.7.3 Authentication unsuccessful [HK0PR03CA0105.apcprd03.prod.outlook.com]
2
如果登录用户和发件人不一致,会导致 554
拒绝发送错误:
DEBUG SMTP: MessagingException while sending, THROW:
com.sun.mail.smtp.SMTPSendFailedException: 554 5.2.0 STOREDRV.Submission.Exception:SendAsDeniedException.MapiExceptionSendAsDenied;
2
有些时候,如果邮件主题和正文过于简单,会导致 554
被识别为垃圾邮件的错误:
DEBUG SMTP: MessagingException while sending, THROW:
com.sun.mail.smtp.SMTPSendFailedException: 554 DT:SPM
2
# 小结
使用 JavaMail API 发送邮件本质上是一个 MUA 软件通过 SMTP 协议发送邮件至 MTA 服务器;
打开调试模式可以看到详细的 SMTP 交互信息;
某些邮件服务商需要开启 SMTP,并需要独立的 SMTP 登录密码。