使用Kindle的同学应该知道Amazon官方有个Send To Kindle的插件可以方便的把文档推送到你的设备上,可惜的是,这个插件只能用美亚的账号登陆,何不自己做一个?
我从昨天睡觉之前萌生了这个想法,到现在做出来原型整整用了一天,惭愧不已,看来还需多加练习。
简单说下原理
完整的源码我会放在最后,具体的细节实现可以看源码。这里我就简单的说下我的思路以及过程中遇到的值得记录一下的”难点“。
过程其中很简单,就是用SMTP协议把文档以邮件的形式发送给Kindle邮箱。那么,我们需要做的其实只有:
- 一个UI用来收集Kindle邮箱、推送邮箱、推送邮箱密码和要推送的文档
- 使用SMTP协议
邮件服务商
SMTP协议是应用层的网络协议,由TCP协议支持,换句话说,它是在保证了可靠传输的基础上通过一定的”暗号“交接来传递邮件,过程大概是:打招呼(hello),确认交流方式(加密吗?用什么加密协议?),身份认证(采用base64编码的用户名和密码),传递内容,结束。具体的指令可能会因为邮件服务商所用的加密协议有所不同,但过程基本就是这样。这样的文章网上一搜一大堆:SMTP协议--在cmd下利用命令行发送邮件,有兴趣的可以自己打一打指令,对理解协议有好处。
但有一点我想说的是这些文章中使用的都是端口25(SMTP的默认端口,传输不加密),而经过我的测试,QQ、163甚至Gmail都已经不再开放25号端口了,用的最多的是TLS和SSL的端口进行加密传输。在SMTP交互过程中,对应的指令变化就是在auth login之前要先发送starttls指令,同时在连接服务器的时候使用端口587。
这里我要吐槽一下QQ和163邮箱所谓的授权码,我不知道是出于什么原因要让第三方邮件客户端使用这个授权码。它真的让你的邮箱更安全了吗?在使用SMTP协议时,QQ和163在验证身份时都要求提供采用base64编码的授权码。Hmm...
汉字转拼音库
在使用smtplib库时,我发现当附件名是中文的时候,收件方收到的邮件中附件会变成一个bin文件。在尝试调整编码无果之后,我想到了一个把中文都转化为拼音的方案。接着去搜了一下还真有一个第三方库:Pinyin。
安装这个库的方法是,将整个github库下载到本地,解压缩,用cmd切换到有setup.py那个目录,然后执行:python setup.py install
。
其他
除了上述两点,本项目中还有几个值得一提的问题或者说我学到的新知识:
- Python中将键盘鼠标的操作和函数绑定:Events and Bindings,它可以用来实现一个带有超链接效果的Label。
- 使用文件对话框选取一个文件
- 怎样清空一个Entry插件
都在代码里了。
目前使用的一些约束
因为只是一个初版,难免有些考虑不到的地方或者说bug,考虑到这只是个练手的项目,我应该也不会继续完善它了,目前我能想到的一些约束包括:
- 只支持三个邮件服务商:QQ、163和Gmail。目前会对用户输入的推送邮箱和密码做一些简单的校验,但是仅限于判断其是不是以qq.com, 163.com或gmail.com结尾,用例如[email protected]就是校验不出来的。kindle邮箱也有类似的问题。同时如果邮箱或密码错误,也不能返回相应的错误消息。
- 推送的文件大小不能超过50MB,这个其实不是bug,超过50MB的文件即使推送了也会被Amazon退回。同样文件的类型我也不能控制,如果用户选择了kindle不支持的文件类型,软件仍然会推送,只不过同样会被Amazon退回。
- 在正常流程下,点击发送按钮之后程序会”停滞“很长时间,多长取决于文件的大小。考虑应该添加一个类似于进度条的东西用来缓解用户等待时产生的”焦虑感“。
- 目前一次只能推送一个文件。考虑将”选择文件“那个Frame做成一个可以进行CRUD操作的LIstbox,但要校验总文件大小不能超过50MB。
- 还有一些诸如邮箱必须开启SMTP服务,推送邮箱必须处于Amazon账户信任列表里的条件不属于本软件范畴,但是确实必要的,我就不一一列出来的。总之,如果你手动发邮件能成功推送文档,那么软件也可以, 亲测有效。
完整代码
效果图
smtp.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
def SendToKindle(mail_host, mail_user, mail_pass, receiver, fullpath, bookname):
message = MIMEMultipart()
message['From'] = Header("SentToKindle", 'utf-8')
message['To'] = receiver
message['Subject'] = Header('convert')
att = MIMEText(open(fullpath, 'rb').read(), 'base64', 'utf-8')
att["Content-Type"] = 'application/octet-stream'
att["Content-Disposition"] = 'attachment; filename=%s' % bookname
message.attach(att)
smtpObj = smtplib.SMTP(mail_host, 587)
smtpObj.ehlo()
smtpObj.starttls()
smtpObj.login(mail_user, mail_pass)
smtpObj.sendmail(mail_user, [receiver], message.as_string())
smtpObj.quit()
ui.py
import tkinter as tk
import tkinter.messagebox
import tkinter.filedialog
from tkinter import END
import webbrowser
import os.path
import smtp
import pinyin
mail_host = ''
mail_user = ''
mail_pass = ''
receiver = ''
fullpath = ''
bookname = ''
class SentToKindleUI(object):
def __init__(self, object):
# 推送信息
self.lf_sendinfo = tk.LabelFrame(object, width=256, height=144, text='推送信息')
self.lf_sendinfo.grid(row=0, column=0, sticky='w',padx=10)
self.label_sendinfo_kindlemail = tk.Label(self.lf_sendinfo, width=12, text='Kindle邮箱:')
self.label_sendinfo_kindlemail.place(x=5,y=2)
self.label_sendinfo_entry1 = tk.Entry(self.lf_sendinfo, relief='solid')
self.label_sendinfo_entry1.place(x=100, y=2)
self.label_sendinfo_sendmail = tk.Label(self.lf_sendinfo, width=12, text='推送邮箱:')
self.label_sendinfo_sendmail.place(x=5,y=30)
self.label_sendinfo_entry2 = tk.Entry(self.lf_sendinfo, relief='solid')
self.label_sendinfo_entry2.place(x=100, y=30)
self.label_sendinfo_password = tk.Label(self.lf_sendinfo, width=12, text='推送邮箱密码:')
self.label_sendinfo_password.place(x=5,y=58)
self.label_sendinfo_entry3 = tk.Entry(self.lf_sendinfo, relief='solid',show='*')
self.label_sendinfo_entry3.place(x=100, y=58)
# 校验三个Entries的内容
def label_sendinfo_bt_click():
global mail_host, receiver, mail_user, mail_pass
receiver = self.label_sendinfo_entry1.get()
mail_user = self.label_sendinfo_entry2.get()
mail_pass = self.label_sendinfo_entry3.get()
# 检查kindle邮箱
if receiver.endswith('kindle.com') or receiver.endswith('kindle.cn'):
pass
else:
tk.messagebox.showinfo(title='HI', message='Kindle邮箱必须以kindle.com或kindle.cn结尾。')
self.label_sendinfo_entry1.delete(0, END)
return
# 检查推送邮箱
if mail_user.endswith('gmail.com'):
mail_host = 'smtp.gmail.com'
elif mail_user.endswith('163.com'):
mail_host = 'smtp.163.com'
elif mail_user.endswith('qq.com'):
mail_host = 'smtp.qq.com'
else:
tk.messagebox.showinfo(title='HI', message='目前仅支持QQ、163和Gmail邮箱作为推送邮箱。')
self.label_sendinfo_entry2.delete(0, END)
self.label_sendinfo_entry3.delete(0, END)
return
# 如果能进行到这,说明内容校验都没问题
tk.messagebox.showinfo(title='HI', message='输入没有问题!')
varCheck = tk.IntVar()
def label_sendinfo_checkbutton_click():
if varCheck.get() == 1:
self.label_sendinfo_entry3.config(show='')
else:
self.label_sendinfo_entry3.config(show='*')
self.label_sendinfo_checkbutton = tk.Checkbutton(self.lf_sendinfo,
text = '显示密码',
variable = varCheck,
onvalue = 1,
offvalue = 0,
command = label_sendinfo_checkbutton_click
)
self.label_sendinfo_checkbutton.place(x=90,y=86)
self.label_sendinfo_bt = tk.Button(self.lf_sendinfo,
text='校验',
width=8,
command=label_sendinfo_bt_click
)
self.label_sendinfo_bt.place(x=175,y=86)
# 文件选择
self.lf_file = tk.LabelFrame(object, width=256, height=128, text='文件选择')
self.lf_file.grid(row=1, column=0, sticky='w', padx=10)
self.lf_file_label = tk.Label(self.lf_file,
width=34,
text='已选择:(空)',
anchor='w',
justify='left',
wraplength=240
)
def lf_file_bt_click():
global bookname, fullpath
SupportedFiletypes = [('所有文件','*.*'), ('mobi文件','*.mobi'), ('文本文件','*.txt'), ('pdf文件','*.pdf')]
filename = tk.filedialog.askopenfilename(filetypes=SupportedFiletypes)
if filename != '':
filesize = os.path.getsize(filename)/float(1024*1024) # MB
if float(filesize) > 50.00:
tk.messagebox.showinfo(title='HI', message='文件大小不得超过50MB。')
self.lf_file_label.config(text = '已选择:(空)')
return
self.lf_file_label.config(text = '已选择: '+ filename)
fullpath = filename
bookname = pinyin.get(os.path.basename(fullpath), format="numerical")
self.lf_file_bt = tk.Button(self.lf_file,
text = '选择文件',
command=lf_file_bt_click
)
self.lf_file_bt.place(x=2, y=2)
self.lf_file_label.place(x=2, y=42)
# 描述信息
self.lf_desc = tk.LabelFrame(object, width=256, height=96, text='说明')
self.lf_desc.grid(row=2, column=0, sticky='w', padx=10)
def callback(event):
webbrowser.open_new(r"https://journal.ethanshub.com/post/category/gong-cheng-shi/-python-kindledian-zi-shu-tui-song#toc_4")
self.tmp = "目前一些使用的约束"
self.lf_desc_label = tk.Label(self.lf_desc,
fg='blue',
cursor='hand2',
width=34,
text=self.tmp,
anchor='w',
justify='left',
wraplength=250
)
self.lf_desc_label.place(x=2, y=2)
self.lf_desc_label.bind("", callback)
# 按钮
self.lf_button = tk.Frame(object, width=256, height=96)
self.lf_button.grid(row=3, column=0, sticky='w', padx=10)
def lf_button_bt1_click():
global mail_host, mail_user, mail_pass, receiver, bookname
smtp.SendToKindle(mail_host, mail_user, mail_pass, receiver, fullpath, bookname)
self.lf_button_bt1 = tk.Button(self.lf_button,
text='发送',
width=12,
height=2,
command=lf_button_bt1_click
)
self.lf_button_bt1.place(x=20,y=5)
self.lf_button_bt2 = tk.Button(self.lf_button,
text='取消',
width=12,
height=2,
command=self.lf_sendinfo.quit
)
self.lf_button_bt2.place(x=123,y=5)
# 初始化窗口
root = tk.Tk()
root.title('Sent to Kindle')
width = 276
height = 432
screenwidth = root.winfo_screenwidth()
screenheight = root.winfo_screenheight()
size = '%dx%d+%d+%d' % (width, height, (screenwidth - width)/2, (screenheight - height)/2)
root.geometry(size)
SentToKindleUI(root)
root.mainloop()
main.py
import ui as myUI
# 初始化窗口
root = myUI.tk.Tk()
root.title('Sent to Kindle')
width = 276
height = 432
screenwidth = root.winfo_screenwidth()
screenheight = root.winfo_screenheight()
size = '%dx%d+%d+%d' % (width, height, (screenwidth - width)/2, (screenheight - height)/2)
root.geometry(size)
myUI.SentToKindleUI(root)
root.mainloop()