公网IP地址在网络通信的重要性不用多说。对个人小项目来说,容易获取到的是公网的IPv6地址,而这个公网v6地址经常变化,我们常常要把地址同步到网络。
这里使用的方法是把IP地址发送到自己的邮箱 ,相比于绑定域名,脚本发送短信之类的方法,优点还是比较明显的:
- 可以自定义发送和接受的过程
- 不需要再注册新的账号,直接用现有的邮箱即可
- 不限制编程语言,拓展性很强
- 完全免费
- ......
准备工作
前置知识
- 想用代码控制收发邮件,需要使用smtp协议和pop协议。其中smtp协议是发送邮件用的,pop协议是读邮件用的。
- 想用smtp和pop协议来控制邮件的收发,需要使用授权码。授权码在这里的作用相当于邮箱账号的密码。
- 收发邮件经常要处理多种文本编码的转化,所以有现成的模块直接用就好,没有就根据编码的规则自己处理一下Byte数据即可。
- 发送邮件是跟smtp服务器通信的过程,简单来说,也就是建立连接、发送一些特定的字符串、接收字符串来查看进度。接收邮件同样是这3个过程,不同的是和pop服务器通信。
- 不同的邮箱对应的smtp服务器pop服务器不同,比如163邮箱的是smtp.163.com和pop3.163.com;qq邮箱的是smtp.qq.com和pop.qq.com。具体的自己搜索一下就出来了。
- 网络通信只有地址是不行的,还要有端口号port。不加密的话smtp服务器默认用25端口,pop使用110端口。加密的话传输过程如果被拦截,那拦截的人是不知道具体内容的,更安全,这里不展开。
这里我将演示用代码把自己的公网IPv6地址发送邮件给自己,以及用代码把邮件里的内容读取出来。这两类代码只做思路演示,没有考虑SSL加密。发送邮件的部分比较简单,而且大多有封装好的模块或者库,按照使用方法调用就好。我的重心放在读邮件的过程里。哪怕发送部分没有现成的模块,看懂了读邮件的方法,自己写一个发邮件也不难。
废话不多说,直接来看代码。
发送IP地址到邮箱
这里要解决的事,一是在自己的IP地址里筛选出“公网IPv6地址”,二是发送邮件到自己的邮箱。这里以Node.js为例
获取IPv6地址,调用内置的os模块,再进行筛选即可
const os = require('os');
//获取IPv6公网地址,如果有多个,只取第一个
function getIPv6Address() {
const networkInterfaces = Object.values(os.networkInterfaces());
let tmp={};
for(let i=0;i<networkInterfaces.length;i++){
for(let j=0;j<networkInterfaces[i].length;j++){
tmp=networkInterfaces[i][j];
if (tmp.family=='IPv6' && tmp.internal == false){
return {public:true,ip:tmp.address}
}
}
}
return {public:false,ip:""}
}
发送邮件虽然没有内置模块可以用,但第三方模块一大把,实在不行再自己搓,难度也不大,对照着smtp指令表发几条指令完事。这里有别人写好的当然是直接用了。
先安装模块
npm i nodemailer
引入模块,封装为方法
const nodemailer = require('nodemailer');
function sendIPToMyMail({ host, port, secureConnection, mailSubject, mail, pass }, { public, ip }) {
//连接和登录配置
const transporter = nodemailer.createTransport({
host, //smtp服务器
port, //不加密是25,
secureConnection,//是否加密,
auth: {
user: mail,//邮箱账号
pass: pass,//邮箱授权码,注意不是密码
}
});
//邮件内容配置
const mailOptions = {
from: mail, //发件方账号,当然是自己的邮箱账号
to: mail, //发给自己,所以这里还是自己
subject: mailSubject, //邮件主题
text: JSON.stringify({ //这里只发纯文本,这样报文结构会简单很多
time: new Date().getTime(),//顺便带上时间,虽然邮件头也有时间,但放在这里可以
public,//IP地址是不是公网的
ip
}),
};
transporter.sendMail(mailOptions, (err, info) => {
if (err) { console.log("发送失败 "+err); return; }
console.log("发送成功 "+info);
});//发送邮件
}
把它们组合起来,再加个定时器,定时扫描,如果IP没有变化,就不用发邮件;反之把新IP发送出去,更新IP。
const config = {
mail: 'aaabbbccc@ddd.com', //自己的邮箱账号
pass: 'eeeeffffgggghhhh', //smtp授权码
host: 'pop.iiii.com', //自己邮箱对应的smtp服务器
port: 25, //自己邮箱对应的smtp服务器端口
secureConnection: false, //是否加密通信
mailSubject: "邮件主题", //要发送的邮件主题,用于区分这些邮件和普通邮件
scanTime:10, //多久扫描一次
}
function uploadSeverStart(config) {
let nowIp = "";
let ipMessage;
setInterval(() => { //加个定时器
ipMessage = getIPv6Address(); //获取IP
if (ipMessage.ip != nowIp) { //如果变化了
sendIPToMyMail(config,ipMessage); //发送邮件
nowIp = ipMessage.ip; //更新IP
}
}, config.scanTime * 1000)
}
uploadSeverStart(config);
从邮件读出IP地址
读邮件使用的是POP协议,POP协议常用的指令有
- USER 邮箱名 \r\n
- PASS 授权码 \r\n
- STAT \r\n (作用是查看邮箱里的信件数量和总大小)
- LIST \r\n (列出所有的邮件序号和对应的大小)
- LIST 序号 \r\n (列出序号对应的邮件和大小)
- RETR 序号 \r\n (读出序号对应的邮件头部和内容)
- TOP 序号 行数 \r\n (读出对应的邮件头部和前几行)
- DELE 序号 \r\n (标记对应的邮件删除)
- RSET \r\n (清空删除选中的状态)
- NOOP \r\n (空指令)
- QUIT \r\n (结束通信)
这些指令的使用方法很简单,先建立TCP连接,也就是弄一个Socket,然后把这些字符串发送过去就可以了。这里以Java代码为例子,同样不考虑加密。
需要准备好这些参数:
- name 邮箱账号
- pass 邮箱授权码
- mailHost 邮箱POP服务器
- mailPort 邮箱POP服务器端口,这里不加密,所以是110号
- mailSubject 要读取的邮件主题
先建立TCP连接,并且准备好发送和接收字符串的工具,声明几个临时变量。
// 准备通信
Socket mySocket = new Socket(mailHost, mailPort);
InputStreamReader reader = new InputStreamReader(mySocket.getInputStream());
OutputStreamWriter writer = new OutputStreamWriter(mySocket.getOutputStream());
Scanner sc = new Scanner(reader);
String tmp;
int tmpInt;
String tmpSubject;
sc.nextLine(); //连接成功固定返回 +ok pop3 ready,这里把它读掉
想读邮件前,自然要先登录
// 登录
writer.write("USER " + name + "\r\n");
writer.flush(); //发送
sc.nextLine();//读一行,丢弃
writer.write("PASS " + pass + "\r\n");
writer.flush();
tmp = sc.nextLine();//再读一行,用变量接收
if (tmp.indexOf("-ERR") == 0){ //如果返回-ERR,说明登录失败,不用继续往下了
throw new Error("mail login fail");
}
登录完成后,难道我们能直接直接查看目标邮件了吗?明显不行,POP没有这个指令能让我们检索邮件。按照能用的指令来看,我们接下来的目标是
- 获取邮件的总数
- 遍历邮件的头部,找到主题是我们想要的那封邮件
- 读取这封邮件的内容
按照这个步骤,会发现我们接下来会使用STAT指令获取数量,使用TOP指令获取头部,使用RETR来读取内容。但TOP指令和RETR指令都会返回头部,而且TOP指令也可以读取内容。这样子功能不就重合了吗。我们可以考虑用TOP指令代替RETR指令,把第2步第3步放在一起做。这样又会多出来一个参数
- readLine 读取邮件内容报文的前几行
先获取数量
// 查看邮件数量
writer.write("STAT\r\n");
writer.flush();
//这个时候会返回类似+ok 6 6666
sc.next();//跳过+ok
tmpInt=sc.nextInt();//接收6,表明有6封邮件
sc.nextLine(); //读完剩下部分,以免影响接下来的部分
然后就是遍历读取邮件了
// 遍历读取所有邮件的头部和前 n 行
boolean isWantMail;
String dataString;
for (int i = tmpInt; i > 0; i--) { //从后往前遍历,这样会先遍历到比较新的邮件
writer.write("TOP " + i + " " + readLine + "\r\n");
writer.flush(); // 准备读 n 行
isWantMail = false;
tmp = sc.nextLine();
// 读邮件头
while (!tmp.isEmpty()) { //这层循环逐行读取邮件的头部,包含了发件人,收件人,主题,时间等等信息
if (tmp.indexOf("Subject: ") == 0) {
tmpSubject=tmp+"\n\r";
tmp=sc.nextLine();
while(!tmp.contains(":")){
tmpSubject+=tmp+"\n\r";
tmp=sc.nextLine();
}
tmpSubject=tmpSubject.substring(8);
tmpSubject=parseMailSubject(tmpSubject);
if(tmpSubject.equals(mailSubject)){ //如果找到了最新的目标邮件
isWantMail=true; //标志位置1
}
}
tmp = sc.nextLine(); //读取下一行邮件头
}
// 目标邮件,读正文
if (isWantMail) {
dataString = "";
tmp = sc.nextLine();
while (!tmp.equals(".")) {
dataString += tmp + "\n\r";
tmp = sc.nextLine();
}
String mailContent = parseMailContent(dataString);
return mailContent; //读到了目标邮件的内容,直接返回
} else
while (!tmp.equals(".")) {
tmp = sc.nextLine();
} // 普通邮件,跳过
}
//读完了全部邮件的头部,没有找到目标邮件
writer.write("QUIT\r\n");
writer.flush(); // 结束通信
return "";
遍历的过程里发现邮件主题有些是这样的
Subject: =?UTF-8?B?572R5piT6YKu566x55So5oi36LCD56CU?=
有些还有好几行,这些是base64编码,加了头和尾的标识,那我们需要掐头去尾,再解码即可。
//解析邮件主题
private static String parseMailSubject(String subject){
byte[] tmp;
if(subject.indexOf(" =?UTF-8?B?")==0){
subject=subject.replace("?=\n\r", "").replace(" =?UTF-8?B?", "");
tmp =Base64.getDecoder().decode(subject);
return new String(tmp, StandardCharsets.UTF_8);
}
return subject.substring(1,subject.length()-1);
}
邮件内容也是编码过的,用的是Quoted-Printable 编码,那我们照样解码即可
// 解析邮件正文
private static String parseMailContent(String quotedPrintableString) {
char[] str = quotedPrintableString.replaceAll("=\n\r", "").toCharArray();
ArrayList<Byte> Bytes = new ArrayList<>();
char tmp;
for (int i = 0; i < str.length; i++) {
tmp = str[i];
if (tmp == '=') {
Bytes.add((byte) Integer.parseInt("" + str[i + 1] + str[i + 2], 16));
i += 2;
} else {
Bytes.add((byte) tmp);
}
}
byte[] bytes = new byte[Bytes.size()];
for (int i = 0; i < bytes.length; i++) {
bytes[i] = Bytes.get(i);
}
return new String(bytes, StandardCharsets.UTF_8);
}
解码和读取都完成了,接下来就是从邮件的内容里读出我们想要的IP信息了。这个时候发现之前发送IP地址的时候发的是JSON字符串,而Java内置的库没有能解析JSON的库...其实不用慌,这个JSON又不是非用不可。
两边调整一下发送的邮件正文和解析的方法,再润色一下代码就完成了。
全部代码
发送邮件
const nodemailer = require('nodemailer'); //记得先npm下载
const os = require('os');
const config = {
mail: 'aaabbbccc@ddd.com', //自己的邮箱账号
pass: 'eeeeffffgggghhhh', //smtp授权码
host: 'pop.iiii.com', //自己邮箱对应的smtp服务器
port: 25, //自己邮箱对应的smtp服务器端口
secureConnection: false, //是否加密通信
mailSubject: "ip地址更新啦!", //要发送的邮件主题,用于区分这些邮件和普通邮件
scanTime:10, //多久扫描一次
message:"动动小手点个赞~",
}
//获取IPv6公网地址,如果有多个,只取第一个
function getIPv6Address() {
const networkInterfaces = Object.values(os.networkInterfaces());
let tmp = {};
for (let i = 0; i < networkInterfaces.length; i++) {
for (let j = 0; j < networkInterfaces[i].length; j++) {
tmp = networkInterfaces[i][j];
if (tmp.family == 'IPv6' && tmp.internal == false) {
return { public: true, ip: tmp.address }
}
}
}
return { public: false, ip: "::1" }
}
//发送邮件
function sendIPToMyMail({ host, port, secureConnection, mailSubject, mail, pass ,message}, { public, ip }) {
//连接和登录配置
const transporter = nodemailer.createTransport({
host, //smtp服务器
port, //不加密是25,
secureConnection,//是否加密,
auth: {
user: mail,//邮箱账号
pass: pass,//邮箱授权码,注意不是密码
}
});
//邮件内容配置
const mailOptions = {
from: mail, //发件方账号,当然是自己的邮箱账号
to: mail, //发给自己,所以这里还是自己
subject: mailSubject, //邮件主题
text: `${ getTime() }\r\n${ public }\r\n${ip}\r\n${message}\r\n` //用换行来划分有效信息
};
transporter.sendMail(mailOptions, (err, info) => {
if (err) { console.log("发送失败 "+err); return; }
console.log("发送成功 \r\n"+JSON.stringify(info));
});//发送邮件
}
function getTime(){
const date = new Date();
const formattedTime = date.toLocaleString().replaceAll("/","-");
console.log(formattedTime);
return formattedTime;
}
function uploadSeverStart(config) {
let nowIp = "";
let ipMessage;
setInterval(() => { //加个定时器
ipMessage = getIPv6Address(); //获取IP
if (ipMessage.ip != nowIp) { //如果变化了
sendIPToMyMail(config,ipMessage); //发送邮件
nowIp = ipMessage.ip; //更新IP
}
}, config.scanTime * 1000)
}
uploadSeverStart(config);
读取邮件
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.Socket;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Scanner;
public class tool {
/*
*
* 参数
* name 邮箱账号
* pass 邮箱POP3授权码
* mailHost POP3服务器
* mailPort POP3服务器端口,不加密是110端口
* mailSubject 要读取ip邮件的主题
* readLine 读取邮件正文报文内容的前几行
*/
@SuppressWarnings("resource")
public static String get(String name, String pass, String mailHost, int mailPort, String mailSubject, int readLine)
throws UnknownHostException, IOException {
// 准备通信
Socket mySocket = new Socket(mailHost, mailPort);
InputStreamReader reader = new InputStreamReader(mySocket.getInputStream());
OutputStreamWriter writer = new OutputStreamWriter(mySocket.getOutputStream());
Scanner sc = new Scanner(reader);
String tmp;
int tmpInt;
String tmpSubject;
sc.nextLine(); //连接成功固定返回 +ok pop3 ready,把它读掉,不然影响后面的判断
// 登录
writer.write("USER " + name + "\r\n");
writer.flush();
sc.nextLine();//读一行,丢弃
writer.write("PASS " + pass + "\r\n");
writer.flush();
tmp = sc.nextLine();//再读一行,用变量接收
if (tmp.indexOf("-ERR") == 0){
throw new Error("mail login fail");
}
// 查看邮件数量
writer.write("STAT\r\n");
writer.flush();
//这个时候会返回类似+ok 6 6666
sc.next();//跳过+ok
tmpInt=sc.nextInt();//接收6,表明有6封邮件
sc.nextLine(); //读完剩下部分,以免影响接下来的部分
// 遍历读取所有邮件的头部和前 n 行
boolean isWantMail;
String dataString;
for (int i = tmpInt; i > 0; i--) { //从后往前遍历,这样会先遍历到比较新的邮件
System.out.println("NO."+i);
writer.write("TOP " + i + " " + readLine + "\r\n");
writer.flush(); // 准备读 n 行
isWantMail = false;
tmp = sc.nextLine();
// 读邮件头
while (!tmp.isEmpty()) {
if (tmp.indexOf("Subject: ") == 0) {
tmpSubject=tmp+"\r\n";
tmp=sc.nextLine();
while(!tmp.contains(":")){
tmpSubject+=tmp+"\r\n";
tmp=sc.nextLine();
}
tmpSubject=tmpSubject.substring(8);
System.out.println(tmpSubject);
tmpSubject=parseMailSubject(tmpSubject);
System.out.println(tmpSubject);
if(tmpSubject.equals(mailSubject)){ //如果找到了最新的目标邮件
isWantMail=true;
}
}
tmp = sc.nextLine();
}
// 目标邮件,读正文
if (isWantMail) {
dataString = "";
tmp = sc.nextLine();
while (!tmp.equals(".")) {
dataString += tmp + "\r\n";
tmp = sc.nextLine();
}
String mailContent = parseMailContent(dataString);
return mailContent;
} else
while (!tmp.equals(".")) {
tmp = sc.nextLine();
} // 普通邮件,跳过
}
writer.write("QUIT\r\n");
writer.flush(); // 结束通信
return "";
}
// 解析邮件正文
private static String parseMailContent(String quotedPrintableString) {
char[] str = quotedPrintableString.replaceAll("=\r\n", "").toCharArray();
ArrayList<Byte> Bytes = new ArrayList<>();
char tmp;
for (int i = 0; i < str.length; i++) {
tmp = str[i];
if (tmp == '=') {
Bytes.add((byte) Integer.parseInt("" + str[i + 1] + str[i + 2], 16));
i += 2;
} else {
Bytes.add((byte) tmp);
}
}
byte[] bytes = new byte[Bytes.size()];
for (int i = 0; i < bytes.length; i++) {
bytes[i] = Bytes.get(i);
}
return new String(bytes, StandardCharsets.UTF_8);
}
//解析邮件主题
private static String parseMailSubject(String subject){
byte[] tmp;
if(subject.indexOf(" =?UTF-8?B?")==0||subject.indexOf(" =?utf-8?B?")==0){
subject=subject.replace("?=\r\n", "").replace(" =?UTF-8?B?", "").replace(" =?utf-8?B?", "");
tmp =Base64.getDecoder().decode(subject);
return new String(tmp, StandardCharsets.UTF_8);
}
return subject.substring(1,subject.length()-2);
}
public static void main(String[] args) {
String name="aaabbbccc@ddd.com";
String pass="eeeeffffgggghhhh";
String mailHost="pop.iiii.com";
int mailPort=110;
String mailSubject="ip地址更新啦!";
int readLine=10;
try {
String content=get(name, pass, mailHost, mailPort, mailSubject, readLine);
// System.out.println(content);
if(content.isEmpty()){
//如果邮箱里没有目标邮件
System.out.println("没有找到对应邮件");
}else{
Scanner sc = new Scanner(content);
String time=sc.nextLine();//正文第一行,时间
System.out.println("时间\r\n"+time+"\r\n");
boolean ispublic= sc.nextBoolean();//正文第二行,是否是公网IPv6
if(ispublic==false){
//找到了邮件,但新IP地址没有公网IPv6
System.out.println("没有获得公网IPv6");
sc.next(); //正文第三行,无效IP
}else{
String ip=sc.next();//正文第三行,有效IP
System.out.println("获取IP地址成功!\r\n"+ip+"\r\n");
}
String message=sc.next();//正文第四行,携带的消息
System.out.println("携带的消息\r\n"+message);
sc.close();
}
} catch (UnknownHostException e) {
System.out.println(e);
} catch (IOException e) {
System.out.println(e);
}
}
}
运行效果
$ java tool
时间
2024-8-30 12:13:30
获取IP地址成功!
2409:XXXX:XXXX:f090:500b:3928:8526:7311
携带的消息
动动小手点个赞~
总结
不足
- 没有考虑加密,不够安全
- 不同的POP服务器实现的细节可能不同,这里的Java代码只测试了163邮箱和QQ邮箱,不确定其他邮箱能不能用
- 本来想把Java代码封装下,做成安卓应用的,但是莫名其妙报a.grey.BulimiaTGen.f,后来想想还是算了吧
- 没有结合持久化存储,这样每次都要从POP服务器获取邮件。应该保存在文件里,能减少很多次网络请求
- 这里读邮件的代码只考虑了纯文本的正文。如果正文里带上html或者图片,附件之类的,那么可能会有一堆分隔符,这里没有做解析和分隔。所以能用实现好的库或者模块的话,就尽量别造轮子了
- 总之代码仅供学习参考,实际使用还是建议考虑云服务器和绑定域名之类的
展望
- 代码虽然到获取完IP地址就结束了,但只要多加一点代码,它的作用能大很多。比如获取完IP后把IP加到电脑里的host文件里,就约等于DDNS了~