其实这篇文章是不太想写的,因为我自己在平时貌似已经没有什么场景会用邮箱了。但是在比较正式的场合邮件还是”地位稳固”,比如公司内部还是会用企业邮箱,与客户发一些重要文件、资料等。邮件是比较正式的一个东西。
之前再服务器上做过定时发邮件的功能,不过由于是在spring框架下做的,开发的时候其实很简单,几乎没遇到什么问题,甚至感觉自己没做什么功能就写完了….而至于收邮件,我是没有做过的。这几天写了个桌面插件,(点我去看看,记得点个星),在想要加点什么功能时,我就想到邮件来件提示。好了不多废话,开始了
协议
首先要了解邮件的协议,SMTP,IMAP, POP3 是几个大家耳熟能详的的协议了。先简介一下吧,做邮件相关功能,肯定得至少了解一下的。
协议简介
SMTP
Simple Mail Transfer Protocol. 简单邮件传输协议。 这是一个相对简单的基于文本的协议。 而我们主要用这个协议来发邮件
它是一组用于从源地址到目的地址传输邮件的规范,通过它来控制邮件的中转方式。SMTP 协议属于 TCP/IP 协议簇,它帮助每台计算机在发送或中转信件时找到下一个目的地。SMTP 服务器就是遵循 SMTP 协议的发送邮件服务器。
SMTP 认证,简单地说就是要求必须在提供了账户名和密码之后才可以登录 SMTP 服务器,这就使得那些垃圾邮件的散播者无可乘之机。
增加 SMTP 认证的目的是为了使用户避免受到垃圾邮件的侵扰。
POP3
Post Office Protocol - Version 3. 邮局协议版本3。 我们用来收邮件
POP3规定怎样将个人计算机连接到Internet的邮件服务器和下载电子邮件的电子协议。它是因特网电子邮件的第一个离线协议标准,POP3允许用户从服务器上把邮件存储到本地主机(即自己的计算机)上,同时删除保存在邮件服务器上的邮件,而POP3服务器则是遵循POP3协议的接收邮件服务器,用来接收电子邮件的。
IMAP
Internet Mail Access Protocol. 交互式邮件存取协议。 我们用来收邮件
IMAP是跟POP3类似邮件访问标准协议之一。不同的是,开启了IMAP后,您在电子邮件客户端收取的邮件仍然保留在服务器上,同时在客户端上的操作都会反馈到服务器上,如:删除邮件,标记已读等,服务器上的邮件也会做相应的动作。所以无论从浏览器登录邮箱或者客户端软件登录邮箱,看到的邮件以及状态都是一致的。
区别
自己看上面3个协议的简介的话,其实已经可以看出来了,这里总结一下。
SMTP 管发 , POP3/IMAP 管收。所以在想完整的实现一个邮件客户端的时候,一般是这样组合的 :SMTP+POP3 或 SMTP+IMAP。
而IMAP 和POP3 都负责收件,具体的区别在于:
操作位置 | 操作内容 | IMAP | POP3 |
---|---|---|---|
收件箱 | 阅读、标记、移动、删除邮件等 | 客户端与邮件更新同步 | 仅客户端内 |
发件箱 | 保存到已发送 | 客户端与邮件更新同步 | 仅客户端内 |
创建文件夹 | 新建自定义的文件夹 | 客户端与邮件更新同步 | 仅客户端内 |
草稿 | 保存草稿 | 客户端与邮件更新同步 | 仅客户端内 |
垃圾文件夹 | 接受误移入垃圾文件夹的邮件 | 支持 | 不支持 |
广告邮件 | 接受被移入广告文件夹的邮件 | 支持 | 不支持 |
总之,IMAP 整体上为用户带来更为便捷和可靠的体验。POP3 更易丢失邮件或多次下载相同的邮件,但 IMAP 通过邮件客户端与webmail 之间的双向同步功能很好地避免了这些问题。
实现
目标
我要实现什么样的功能?首先作为来件提示,最最重要的就是,在收到邮件后能否即时的通知。其次,并非仅仅监听一个邮箱罢了,作为一个客户端,需要的是能够自己配置想要监听的邮箱。一句话概括:可配置监听邮箱,即时通知
问题分析
需要自定义配置,那么properties很好的可以实现。而即时这个事情其实就有点麻烦了,怎么才能即时?定点x时间去获取一下邮箱内的邮件量,如果收件箱中邮件数量变化,则判断有新邮件? 这是第一反应,但是当然不能这么做(其实也可以,但这是下下策):
- 1,定点x时间轮询,并不是真正的即时。
- 2,可以通过缩小x,提高即时感。但代价却是性能的开销,开发中需要避免无意义的轮询,能回调通知就不要轮询!!!
- 3,也就是最重要的一点:在x时间段内,我收件箱删了一封邮件,又收到了一封新的。总数没变,怎么算?有人会杠,那就算一下垃圾箱里有没有多邮件呗? x时间内,收件箱多了5封,垃圾箱多了2封,其他邮件箱少了4封 …这要考虑的情况就更复杂了
回到第二条,我想到了接受通知,在查阅了一些资料后,我了解到了IDLE。IDLE是RFC 2177中描述的一项IMAP功能,它允许客户端向服务器表明它已准备好接受实时通知。在IMAP4扩展协议中提供了这功能,于是乎,我在POP3与IMAP中选择了IMAP。但是并非所有的IMAP服务提供者都提供了这种功能,比较主流的gmail,qq邮箱,腾讯企业邮箱提供了这一服务。而sina免费邮箱,网易163邮箱没有提供…其余的邮箱自己有兴趣可以测试一下,在本文最后我会给出如何查看提供服务的方法。
代码
说了这么多,不废话了,上代码
1 | import java.util.ArrayList; |
代码解释
在写这个功能的时候,真的遇到了挺多坑。这里就分析一下吧,顺便讲讲那些坑 (代码行标 以上面的代码为基准)
为什么用抽象类
考虑到会适配多个邮箱,那么其实大部分邮箱的功能是一样的,不同的仅仅是邮箱的账号密码,服务器地址,端口等。作为客户端,账户密码肯定来源用户,那么我就从外部获取。而邮箱服务器配置写个子类实现一下抽象父类的钩子就可以了。比如实现qq邮箱就像下面,是不是很方便呢:
1 | public class QQMailPro extends Mail { |
handshake_failure 是什么问题?
我用的是mac os,jdk是1.8.0_121。 第一次运行的时候 直接就甩了这个异常给我。一脸懵,几经波折,好像是ssl上的问题,java的一个bug,需要在官方下载2个jar包替换。相对的jre运行的时候,如果jre版本较早的话,也会出这个问题。反正用最新的jre就不会有事了。
64行 获得session的实例
这个问题一开始没有遇到,当时只适配了一个qq邮箱,而在我适配多个邮箱之后,就出现了一些问题;
1 | session = Session.getDefaultInstance(props); |
这是我一开始用的方法,在获得多个session实例情况下,后获取的实例连接新邮箱时,会随着邮件服务商不同报出各种问题,于是下载了源码看了看(javax.mail.Session),好家伙,原来有2个方法 getDefaultInstance得到的始终是该方法初次创建的缺省的对象,而getInstance得到的始终是新的对象。所以之前一只获得了相同的对象,而用了不同的邮件账号密码去登录,当然会出错了…
118行 增加事件监听
一开始其实我并不知道idle这个东西,folder的方法中直接就有很多addListener,那感觉就很简单,直接加监听就完事了呗~
1 | public synchronized void addMessageCountListener(MessageCountListener l); |
第一个是监听文件夹内邮件增减的监听器,里面监听事件有2个 messagesAdded,messagesRemoved,而且这个监听是IMAPFolder类独有的,这很明显了,我们就该使用这个监听。
顺便说一下,下面3个监听都是IMAPFolder的父类 Folder有的方法。第二个是监听连接事件,比如连接通道的open,失去连接disconnect,关闭连接closed。第三个是文件夹状态的监听,比如文件夹的创建,删除,重命名。第四个是邮件变化的监听,监听的是MessageChangedEvent 这事件对象,对象中用type具体分类不同的变化。有兴趣的可以自己下载源码看看,这里不多展开。
当我乐呵呵的添加了MessageCountListener之后,赶紧发了一封邮件试试。果然没有收到…. 为什么呢,明明添加了监听,缺没有收到通知。于是开始查阅资料…原来需要用imap idle的支持 才能接收到服务器的通知…
IdleManager的使用
接上文,有了头绪就好做很多了。几经查阅了解到,在JavaMail 1.5.2. 版本时,新推出了一个IdleManager。听名字就知道是用来管理idle的,一般来说,用官方推出的manager管理总比自己写好一些,比如线程 和 线程池…下面列出IdleManager中public的方法,对于内部的一些实现有兴趣的可以看看,这边讲一下使用点到为止。
1 | public IdleManager(Session session, Executor es) throws IOException; |
第一个是IdleManager的构造方法,第一个参数是邮件会话的session实例,就是上文提到过的那个。说白点就是 你要关注的那个邮箱连接。第二个参数是一个线程池。盲猜一下,应该是用来执行监听任务分配线程的。
第二个方法和第四个一起说,太见名知意了。isRunning毫无疑问就是看看现在这个IdleManager对象有没有的运行,而stop就是停止当前对象。需要注意的一点是,在IdleManager调用stop()方法停止后,是无法重启的。
第三个watch就是关键了,接受的参数就是你需要关注(监听)的那个收件夹。而且需要注意: Watch the Folder for new messages and other events using the IMAP IDLE command.这个是源码方法上的注释,最初不知道,后来下载源码看了才知道,给我带来了惨痛的后果,之后会说。
当用IdleManager关注你的folder之后,就可以收到邮件了。然而比较有趣的是:只能收到一封,后面又收不到了。带着操蛋的心情我只能又去看看源码。
1 | /** |
接收到的folder,在13行 被强转了 IMAPFolder ifolder = (IMAPFolder)folder; 然后一路看到最后,跳过大部分的抛异常,打log。看到比较关键的地方toWatch.add(ifolder); 他把ifolder添加到了toWatch中,这toWatch又是啥?
1 | private Queue<IMAPFolder> toWatch = new ConcurrentLinkedQueue<>(); |
原来是内部私有的一个队列…这问题就很显然了,这么一个流程:一开始open了folder,然后IdleManager.watch了folder,folder被转成IMAPFolder加入队列,收到邮件任务被执行,队列里的任务出队列,队列里没有了任务…说的通俗一点就是 watch方法是个消耗品,触发过监听回调的就不在监听了。于是乎在监听事件中又继续加上watch(代码133行),发了几封测试一下,解决问题。
心跳大坑
这个问题是折腾了我很久的一个问题,其实有几个问题交织在一起,但是他们产生的结果的现象是一样的。
现在确实可以即时的收到邮件,但是!但是!并不能持续长久,过了一会就失效了。作为写过很多socket的人想都不用想,心跳嘛~ easy~。啥是心跳,自己百度。虽说心跳随便发点什么都行(一般来说发空就行),但是也不能乱发啊,万一服务器接收到错误格式的数据直接把你咔嚓了也是有可能的(以前就遇到过一个服务器心跳只接受 heart,多发几次乱七八糟的字符直接暴毙…)
有过翻车经验的我就直接去找imap协议了,网上很多一看就知…NOOP命令:NOOP命令什么也不做,用来向服务器发送自动命令,防止因长时间处于不活动状态而导致连接中断,服务器对该命令的响应始终为肯定。无参数。
于是只要定时给服务器 发noop就可以了 (关键代码155-161,为了看起来方便,下方再贴一下)
1 | ImapFolder.doCommand(new IMAPFolder.ProtocolCommand() { |
写完心跳我测试了一下,没有成功….这里开始就坑了,测起来太麻烦了,因为一般保持连接默认是30分钟,所以很无奈,只能30分钟测一次。心跳发出去了为什么还是断开了连接?于是我在代码中给所有store和folder和添加了连接监听,开着log挂机测试…结果是啥?并没有断开连接,也就是说,连接还在,但是监听失效?
为了定位问题,我把心跳发送时间间隔缩短至30秒。测试下来,30秒前可以正常收到监听,在心跳出去之后,无法收到…wtf?啥玩意啊?咋回事啊?那咋整啊?心跳本身让监听失效,开什么玩笑?我就发了个noop啊!我已经忘记我是怎么注意到这个问题的关键点了,只能说是灵光一现吧….
记得上面说的那个惨痛的后果么,就是这里了。Watch the Folder for new messages and other events using the IMAP IDLE command。在偶然看源代码的时候注意到了…这个是IdleManager的watch方法的注释。翻译一下:使用imap idle命令监视文件夹中的新邮件和其他事件。 新邮件和其他事件。其他事件!!! 所以说我tmd那个noop也是其他事件?结合上一节发现的问题,watch是消耗品。于是推测心跳的command动作让watch被消耗掉了,这一后果就是 连接 还在,但是监听断了。就是这个现象!!于是在ImapFolder.doCommand动作后补上folder的watch(175-179行)。
- 测试noop心跳问题:启动,发送noop,发测试件,收到监听,成功!
- 再测试心跳连接:启动,挂机1小时,发测试件,收到监听,成功!
以为这就完了?并没有!!!在实际使用中,发现过了很长一段时间后,收件提示失效….我已经欲哭无泪了。难道超时时间不是半小时?我的心跳其实压根没用?在几经怀疑人生的情况下,我开始了对照测试:同时启2份代码,一个有9分钟一次的心跳(gmail的超时时间是10分钟,所以心跳设置了9分钟) ;另一个没有心跳,用qq邮箱测试。在过了40分钟后,有心跳的哪一个可以收到通知,而没有心跳的收不到。这不是没问题么?心跳确实生效了啊!
于是又想到了一个问题,线程 与 os休眠的问题。想象以下场景: 我与服务器的超时时间是 10分钟,我每隔9分钟执行一次心跳任务保持连接。在5分钟的时候,电脑休眠了,这时候所有线程被快照保存下来。过了20分钟,电脑恢复使用,线程重新被唤醒,线程以为只过了5分钟继续从5分钟开始走,(这时候由于25分钟没有心跳,连接肯定是断开了。)又过了4分钟,任务线程9分钟被触发发送心跳,但是实际时间已经过了29分钟了,已经断开了。
这个问题我目前不知道怎么解决…但是我得排除一下试试。心如死灰的关闭电脑休眠功能,然后测试,过了很久很久,邮件监听失效,这反而让我有一些高兴,暂且排除os休眠产生的问题(其实我也不确定那样到底会不会出这个问题,但是等遇到了再说吧,心太累了)于是乎开启调试模式,所有监听全开,异常全部打出,挂机….终于!!!在2个小时左右 抛出了异常!
1 | javax.mail.FolderClosedException: * BYE JavaMail Exception: java.io.IOException: **Connection dropped by server?** |
嗯??啥 bye?Connection dropped by server? 最后这个问号啥意思,到底是不是by server 倒是说清楚啊….同时我的folder closed监听被触发。注意是folder 不是store,也就说 连接还在,但是folder被关闭了,为什么会这样 不知道…没办法 只能google一下。最后找到了相关资料,一样的问题,不能根本上解决,只能写一个补偿吧…捕获异常,异常关闭后再次打开即可。注意不能再监听的closed事件中重新打开,不然在正常关闭的时候有你受的… 补偿代码(162-171行)
1 | try{ |
2019年06月12日补充
后来仔细想了想,其实在close监听事件中重新打开也没有什么问题。额外设定一个flag标记,当true的时候需要重新打开文件夹,而认为手动关闭的时候将这个flag设false就好了。这样做可以应对一些未知问题引起的异常关闭。
附件 查看IMAP服务器提供的服务
主要其实可以看IMAP4 RFC3501协议,这边就说一下怎么看邮件服务器提供的服务,这里用qq邮箱为例,首先打开shell,用的是telnet
telnet连接邮件服务器
qq邮箱的服务地址是imap.qq.com,imap端口143
1 | $ telnet imap.qq.com 143 |
当你看到 * OK [CAPABILITY IMAP4 IMAP4rev1 ID AUTH=LOGIN NAMESPACE] QQMail IMAP4Server ready 这条说明成功
登录
格式:$ login 账号 密码 ,有些邮箱用的是授权码,比如这里的qq就是。所以像下面那样
1 | $ A01 login xxxxx@qq.com zxcvbasdfg |
当你看到 A01 OK Success login ok ,说明登录成功
查看提供的服务
使用 $ A02 CAPABILITY 即可查看
1 | $ A02 CAPABILITY |
上面是qq邮箱返回的信息,我们可以看到支持IDLE。
有人会问 这个a01 a02是什么东西,不打怎么就报错了,其实你看了协议就知道了。imap的命令以一个 标记tag 开头,可以是a01 a02 等,服务器的应答是对应该tag的处理结果(OK / NO)详情就作为拓展自己看协议吧。
后记
写完之后就好像把坑又复习了一遍,看起来一下子就知道了,但中间踩坑排查的过程真的是….刺激…
本文的源码在这里,不要脸的求个star,(*/ω\*)