发送QQ邮件


2025-04-14 QQ Mail

在移动互联网通信时代,想发送通知或告警信息,理想的方式是通过即时消息系统(Instant Messenger)如微信(Wechat)、企业微信(Wecom)、QQ 等发送实时消息。这些应用都有手机客户端,能够及时获得消息通知。

推送(Server Push),指的是从服务器端跟客户端的通信方式,就是应用于上述场景。推送可用借助这些应用的官方 API 来实现。为了避免垃圾信息的骚扰,微信和QQ不提供推送功能,而企业微信会要求进行身份验证(限制较多),通过官方的 API 进行推送,其推送功能比较强大。另外,为了满足上述需求,还可用考虑第三方产品如极光推送 (opens new window)Server酱 (opens new window)等。

若对实时性要求不高,还可用采用“传统”的电子邮件(email)方式。电子邮件的缺点有时延,且不可控。就像手机短消息那样,不知道对方能否接收到,也不能确定什么时间收到。优点是免费,适用范围宽。

本文描述了使用 QQ 邮箱,发送 email 的配置过程,供读者参考。它可用于程序发送告警通知邮件。手机端可以通过微信、QQ、 TIM 等 App 得到新邮件通知。其响应速度也还不错。

为了简便,这里的电子邮件,是纯文本的格式,无附件,且接收方是一个人(点对点)。这也是通常发送告警通知邮件的常见方式。

# QQ邮箱设置

  • 登录网页的QQ邮箱,点击右上角该头像账号下拉框下面的“账号与安全(Account and Security)”的菜单。

  • 在左边栏“账号与安全”的“安全设置”中,在页面中“POP3/IMAP/SMTP/Exchange/CardDAV 服务(已开启)”的功能项中。

    点选“生成授权码”按钮。生成得到的授权码,将作为登录 SMTP 服务器的密码。

客户端的配置,参考: 配置方法 (opens new window) 的说明,摘抄如下:

  • IMAP/SMTP 设置方法

    • 用户名/帐户: 你的QQ邮箱完整的地址
    • 密码: 生成的授权码
    • 电子邮件地址: 你的QQ邮箱的完整邮件地址
    • 接收邮件服务器: imap.qq.com,使用SSL,端口号993
    • 发送邮件服务器: smtp.qq.com,使用SSL,端口号465或587
  • POP3/SMTP 设置方法

    • 用户名/帐户: 你的QQ邮箱完整的地址
    • 密码: 生成的授权码
    • 电子邮件地址: 你的QQ邮箱的完整邮件地址
    • 接收邮件服务器: pop.qq.com,使用SSL,端口号995
    • 发送邮件服务器: smtp.qq.com,使用SSL,端口号465或587

# 使用 Python

参考下面这篇文章,我们可以看怎样用 Python 来发送电子邮件。

下面的代码,在 Python 3.12 上测试通过。

import smtplib, ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.header import Header
from email.utils import formataddr

def send_qq_email(email_subject, email_content, email_receiver):
    '''
    Send out a QQ email.
    :param email_subject: The subject of the email.
    :param email_content:  The plain text content of the email.
    :param email_receiver: The receiver (only 1) of the email.
    :return: None.
    '''
    email_sender = "12345678@qq.com"
    smtp_server = "smtp.qq.com"
    smtp_port = 465
    smtp_user = email_sender
    # QQ邮箱的授权码,并非邮箱密码。授权码用于登录第三方邮件客户端的专用密码
    # 注:登录QQ邮箱,在“账号与安全 | 安全设置”里面可以生成授权码。
    smtp_auth = "iskxedhhadpbmjer"
    
    # Build the email.
    msg = MIMEMultipart()
    msg["Subject"] = Header(email_subject, "utf-8")
    msg["From"] = email_sender
    msg["To"] = email_receiver
    msg.attach(MIMEText(email_content, "plain", "utf-8"))

    # Send the email.
    server = None
    try:
        context = ssl.create_default_context()
        server = smtplib.SMTP_SSL(smtp_server, smtp_port, context=context)
        server.login(smtp_user, smtp_auth)
        server.sendmail(msg["From"], msg["To"], msg.as_string())
    finally:
        if server:
            server.close()

我们可以通过上述 send_qq_email() 方法来发送 QQ 邮件:

def send_mail_example():
    email_subject = "用Python发送的电子邮件"
    email_content = """\
        This message is sent from Python.
        这是用Python发送的一封电子邮件。
        """
    email_receiver = "12345678@qq.com"
    send_qq_email(email_subject, email_content, email_receiver)
    print("邮件发送成功")

注意,上述 send_qq_email() 方法实现代码里面,server 对象并未用 with 来创建,虽然邮件能够正常发送,但它会报异常:

smtplib.SMTPResponseException: (-1, b'\x00\x00\x00')

通过网络搜索也没有查到具体原因,因此,改用 finally 来关闭 server 资源。

# 使用 Java

Java 要相对复杂一些。QQ 邮箱启用了 SSL,而 Java 有自己的证书(不同于操作系统),因此,要先从 smtp.qq.com 导入证书到 Java 的证书库里面。否则,会看到如下的异常信息:

javax.net.ssl.SSLHandshakeException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

本机是 Windows,其默认 JDK 是: OpenJDK 17。同样的过程,也在 OpenJDK-21 上测试与验证通过。

先通过 java -version 查看版本:

openjdk 17.0.14 2025-01-21
OpenJDK Runtime Environment Temurin-17.0.14+7 (build 17.0.14+7)
OpenJDK 64-Bit Server VM Temurin-17.0.14+7 (build 17.0.14+7, mixed mode, sharing)

InstallCert.java 代码如下:

/*
 * Copyright 2006 Sun Microsystems, Inc.  All Rights Reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 *   - Redistributions of source code must retain the above copyright
 *     notice, this list of conditions and the following disclaimer.
 *
 *   - Redistributions in binary form must reproduce the above copyright
 *     notice, this list of conditions and the following disclaimer in the
 *     documentation and/or other materials provided with the distribution.
 *
 *   - Neither the name of Sun Microsystems nor the names of its
 *     contributors may be used to endorse or promote products derived
 *     from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
 * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
 
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
 
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
 
public class InstallCert {
 
	public static void main(String[] args) throws Exception {
		String host;
		int port;
		char[] passphrase;
		if ((args.length == 1) || (args.length == 2)) {
			String[] c = args[0].split(":");
			host = c[0];
			port = (c.length == 1) ? 443 : Integer.parseInt(c[1]);
			String p = (args.length == 1) ? "changeit" : args[1];
			passphrase = p.toCharArray();
		} else {
			System.out
					.println("Usage: java InstallCert <host>[:port] [passphrase]");
			return;
		}
 
		File file = new File("jssecacerts");
		if (file.isFile() == false) {
			char SEP = File.separatorChar;
			File dir = new File(System.getProperty("java.home") + SEP + "lib"
					+ SEP + "security");
			file = new File(dir, "jssecacerts");
			if (file.isFile() == false) {
				file = new File(dir, "cacerts");
			}
		}
		System.out.println("Loading KeyStore " + file + "...");
		InputStream in = new FileInputStream(file);
		KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
		ks.load(in, passphrase);
		in.close();
 
		SSLContext context = SSLContext.getInstance("TLS");
		TrustManagerFactory tmf = TrustManagerFactory
				.getInstance(TrustManagerFactory.getDefaultAlgorithm());
		tmf.init(ks);
		X509TrustManager defaultTrustManager = (X509TrustManager) tmf
				.getTrustManagers()[0];
		SavingTrustManager tm = new SavingTrustManager(defaultTrustManager);
		context.init(null, new TrustManager[] { tm }, null);
		SSLSocketFactory factory = context.getSocketFactory();
 
		System.out
				.println("Opening connection to " + host + ":" + port + "...");
		SSLSocket socket = (SSLSocket) factory.createSocket(host, port);
		socket.setSoTimeout(10000);
		try {
			System.out.println("Starting SSL handshake...");
			socket.startHandshake();
			socket.close();
			System.out.println();
			System.out.println("No errors, certificate is already trusted");
		} catch (SSLException e) {
			System.out.println();
			e.printStackTrace(System.out);
		}
 
		X509Certificate[] chain = tm.chain;
		if (chain == null) {
			System.out.println("Could not obtain server certificate chain");
			return;
		}
 
		BufferedReader reader = new BufferedReader(new InputStreamReader(
				System.in));
 
		System.out.println();
		System.out.println("Server sent " + chain.length + " certificate(s):");
		System.out.println();
		MessageDigest sha1 = MessageDigest.getInstance("SHA1");
		MessageDigest md5 = MessageDigest.getInstance("MD5");
		for (int i = 0; i < chain.length; i++) {
			X509Certificate cert = chain[i];
			System.out.println(" " + (i + 1) + " Subject "
					+ cert.getSubjectDN());
			System.out.println("   Issuer  " + cert.getIssuerDN());
			sha1.update(cert.getEncoded());
			System.out.println("   sha1    " + toHexString(sha1.digest()));
			md5.update(cert.getEncoded());
			System.out.println("   md5     " + toHexString(md5.digest()));
			System.out.println();
		}
 
		System.out
				.println("Enter certificate to add to trusted keystore or 'q' to quit: [1]");
		String line = reader.readLine().trim();
		int k;
		try {
			k = (line.length() == 0) ? 0 : Integer.parseInt(line) - 1;
		} catch (NumberFormatException e) {
			System.out.println("KeyStore not changed");
			return;
		}
 
		X509Certificate cert = chain[k];
		String alias = host + "-" + (k + 1);
		ks.setCertificateEntry(alias, cert);
 
		OutputStream out = new FileOutputStream("jssecacerts");
		ks.store(out, passphrase);
		out.close();
 
		System.out.println();
		System.out.println(cert);
		System.out.println();
		System.out
				.println("Added certificate to keystore 'jssecacerts' using alias '"
						+ alias + "'");
	}
 
	private static final char[] HEXDIGITS = "0123456789abcdef".toCharArray();
 
	private static String toHexString(byte[] bytes) {
		StringBuilder sb = new StringBuilder(bytes.length * 3);
		for (int b : bytes) {
			b &= 0xff;
			sb.append(HEXDIGITS[b >> 4]);
			sb.append(HEXDIGITS[b & 15]);
			sb.append(' ');
		}
		return sb.toString();
	}
 
	private static class SavingTrustManager implements X509TrustManager {
 
		private final X509TrustManager tm;
		private X509Certificate[] chain;
 
		SavingTrustManager(X509TrustManager tm) {
			this.tm = tm;
		}
 
		public X509Certificate[] getAcceptedIssuers() {
			throw new UnsupportedOperationException();
		}
 
		public void checkClientTrusted(X509Certificate[] chain, String authType)
				throws CertificateException {
			throw new UnsupportedOperationException();
		}
 
		public void checkServerTrusted(X509Certificate[] chain, String authType)
				throws CertificateException {
			this.chain = chain;
			tm.checkServerTrusted(chain, authType);
		}
	}
 
}

使用 InstallCert.java 代码来安装证书。

# 编译。忽略提示的警告。
javac InstallCert.java

# 安装证书,在过程中对问题的回答,选择1并回车。
java InstallCert smtp.qq.com:465

屏幕输出示例如下:

Loading KeyStore C:\opt\app\JDK\Adoptium\jdk-17.0.14+7\lib\security\cacerts...
Opening connection to smtp.qq.com:465...
Starting SSL handshake...

javax.net.ssl.SSLHandshakeException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
        at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:131)
        ... 19 more

Server sent 2 certificate(s):

 1 Subject CN=*.mail.qq.com, O=Shenzhen Tencent Computer Systems Company Limited, L=Shenzhen, ST=Guangdong Province, C=CN
   Issuer  CN=Avast Web/Mail Shield Root, O=Avast Web/Mail Shield, OU=generated by Avast Antivirus for SSL/TLS scanning
   sha1    c3 d6 de c4 1b 14 e6 0d 6a 34 29 0a a7 6c a6 71 b5 ed ed 84
   md5     9f a6 5f 76 b1 1d b9 2e eb c5 9f 9c 1c cf 62 c9

 2 Subject CN=Avast Web/Mail Shield Root, O=Avast Web/Mail Shield, OU=generated by Avast Antivirus for SSL/TLS scanning
   Issuer  CN=Avast Web/Mail Shield Root, O=Avast Web/Mail Shield, OU=generated by Avast Antivirus for SSL/TLS scanning
   sha1    19 19 14 a0 4e 9b d0 68 2d dc a3 d9 ef f9 26 fb df 2d c6 52
   md5     63 b0 f1 43 09 6c 16 81 55 ad 7c 88 60 cc 01 5c

Enter certificate to add to trusted keystore or 'q' to quit: [1]
1    # 👈 输入1并回车

# 【略】...

Added certificate to keystore 'jssecacerts' using alias 'smtp.qq.com-1'

从上述提示可以看到,该程序读入了 JDK 中 lib\security\cacerts 库的证书(见第一行),添加了 smtp.qq.com-1 证书(见最后一行),生成了新的库文件 jssecacerts

我们可以检查一下:

# 查看库文件里面的证书。默认密码为: changeit
keytool.exe -list -keystore jssecacerts

在最尾部,可以看到:

smtp.qq.com-1, 2025年4月14日, trustedCertEntry,
Certificate fingerprint (SHA-256): E7:06:C4:82:56:E6:13:B1:0C:8A:1D:73:91:6E:24:4A:FC:CA:6C:28:78:BD:93:3E:AC:EC:40:64:A3:9D:72:62

这就是新添加进来的证书。

我们再检查一下 JDK 里面的证书:

# 查看JDK中可用的证书,默认密码为 changeit
keytool.exe -list -keystore %JAVA_HOME%/lib/security/cacerts

对比可以发现,它没有 jssecacerts 中的最后一条(新添加的证书)。

最后,我们把 %JAVA_HOME%/lib/security/cacerts 做一个备份,然后用 jssecacerts 来替换掉该 cacerts 文件。当前的 JDK 就可以信任 smtp.qq.com 了。至此,我们可以使用 Java 来发送 QQ 邮件。

以 Spring Boot 3 的 Java 应用程序为例。首先在项目的 pom.xml 中添加如下依赖:

<!--发送邮件-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>

创建一个 MyMailSender.java 类,它是一个 Spring 组件(Component):

package com.example.sendemail.qqmail;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;

import jakarta.annotation.Resource;

/**
 * A component to help sending out email.
 * 
 * @author bobyuan
 */
@Component
public class MyMailSender {
	@Value("${spring.mail.username}")
    private String emailSender;
	
	@Resource
    private JavaMailSender javaMailSender;

    /**
     * Send out an email.
     * 
     * @param emailSubject The subject of the email.
     * @param emailContent The plain text content of the email. 
     * @param emailReceiver The receiver email address. 
     */
    public void send(String emailSubject, String emailContent, String emailReceiver) throws MailException {
            SimpleMailMessage mailMessage = new SimpleMailMessage();
            mailMessage.setFrom(emailSender);
            mailMessage.setTo(emailReceiver);
            mailMessage.setSubject(emailSubject);
            mailMessage.setText(emailContent);
            javaMailSender.send(mailMessage);
    }

}

application.properties 中,参照如下进行配置:

#发送邮件配置
# SMTP服务器的地址
spring.mail.host=smtp.qq.com
# 发送方的邮箱
spring.mail.username=12345678@qq.com
# 创建的16位授权码
spring.mail.password=istfeahfbckpbhgr
# 设置是否需要认证,如果为true,那么用户名和密码就必须提供
spring.mail.properties.mail.smtp.auth=true
# 邮件编码
spring.mail.default-encoding=UTF-8

# SSL Config
spring.mail.port=465
spring.mail.protocol=smtp
spring.mail.properties.mail.smtp.ssl.enable=true
spring.mail.properties.mail.smtp.socketFactory.port=465
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory

最后,就可以这样使用它来发送 QQ 邮件了:

private void sendQQMailExample() {
    String emailSubject = "Test email from Java";
    String emailContent = "Test email message. 测试邮件。";
    String emailReceiver = "12345678@qq.com";

    MyMailSender sender = this.appContext.getBean(MyMailSender.class);
    sender.send(emailSubject, emailContent, emailReceiver);
}

如果在 Kettle 等基于 Java 的工具中,可以配置发送告警邮件。例如,在“Mail” 组件中的设置:

# 小结

本文给出了通过程序发送 QQ 电子邮件的方法,适用于 Python 和 Java 这两种编程语言。我们可以通过发送纯文本的电子邮件,来“推送”告警或通知信息到指定用户。这种方式免费、灵活、适用范围宽,但有一定时延,要求不高的地方可以使用。

Last Updated: 4/15/2025, 12:05:50 AM