Telegram 邮件机器人
手上的邮箱越来越多, 管理它们变成一件困难的事. 我希望在所有设备上都能接收所有的邮件. 而我在所有设备上都高强度使用Telegram, 于是我便计划做一个邮件机器人.
项目规划
功能计划
- 支持多用户, 但限制邮箱总数, 以避免对服务器造成预期之外的压力
- 实现接收邮件功能, 发送邮件暂不实现
- 允许imap协议, 其他协议在以后按需加入
技术规划
- 使用
golang
- 使用
telebot
库构建机器人 - 使用
go-imap
库构建邮件接收系统
实现邮件接收
管理连接
使用 Connect
interface 描述一个与邮件服务器的连接
type Connection interface {
Connect() error
Disconnect()
Status() Status
GetUserID() int64
GetEmail() string
AddListener(listener Listener)
}
使用全局变量保存所有的连接
type ConnectionKey struct {
UserID int64
Email string
}
var connections = make(map[ConnectionKey]Connection)
使用一个goroutine维护所有的连接, 实现出错重试
func init() {
go func() {
time.Sleep(5 * time.Minute)
for _, c := range connections {
if c.Status() == StatusDisconnected || c.Status() == StatusError {
_ = c.Connect()
}
}
}()
}
实现imap连接
ImapConnection
类型存储了所有需要的变量
type ImapConnection struct {
userId int64
email string
config *data.ImapConfig
status Status
idleCommand *imapclient.IdleCommand
listeners []Listener
client *imapclient.Client
handlingNewMessages bool
mailIndex *uint32
}
建立连接
Connect
方法与服务器建立连接, 登录, 选择邮箱
此时需要判断服务器是否支持IDLE
对于支持IDLE的服务器, 我们可以与服务器建立长连接, 有新邮件时服务器会通知客户端.
对于不支持IDLE的服务器, 我们只能轮询, 检查是否有新邮件
func (i *ImapConnection) Connect() error {
i.status = StatusConnecting
config := i.config
var err error
i.client, err = imapclient.DialTLS(fmt.Sprintf("%s:%d", config.Host, config.Port), &imapclient.Options{
UnilateralDataHandler: &imapclient.UnilateralDataHandler{
Mailbox: i.HandleNewMails,
},
})
if err != nil {
i.status = StatusError
return fmt.Errorf("failed to connect to IMAP server: %w", err)
}
loginCommand := i.client.Login(config.Username, config.Password)
err = loginCommand.Wait()
if err != nil {
i.status = StatusError
return fmt.Errorf("failed to login: %w", err)
}
selectCommand := i.client.Select("INBOX", nil)
if _, err = selectCommand.Wait(); err != nil {
i.status = StatusError
return fmt.Errorf("failed to select mailbox: %w", err)
}
idle, err := supportIdle(i.client)
if err != nil {
i.status = StatusError
return fmt.Errorf("failed to check IDLE support: %w", err)
}
i.status = StatusConnected
if idle {
i.StartIdle()
} else {
i.StartLoop()
}
return nil
}
IDLE连接
创建 client 时需要设置新邮件回调, 该回调会通知我们有多少新邮件
func (i *ImapConnection) Connect() error
// ...
i.client, err = imapclient.DialTLS(fmt.Sprintf("%s:%d", config.Host, config.Port), &imapclient.Options{
UnilateralDataHandler: &imapclient.UnilateralDataHandler{
Mailbox: i.HandleNewMails,
},
})
// ...
}
轮询
每隔固定时间, 获取当前邮件总数, 如有变化, 获取新邮件. 服务器可能会主动关闭连接, 因此需要识别连接被关闭的情况, 并重新连接.
读取邮件
Imap协议中邮件从旧到新, 从1开始编号. 并且当邮件被删除, 其后的邮件编号都会变化. 我们可以根据邮件编号获取邮件内容.
type Message struct {
ID uint32
From string
To string
Subject string
Content string
}
func FetchMessages(c *imapclient.Client, seqNums []int) ([]Message, error) {
bodySection := &imap.FetchItemBodySection{}
s := imap.SeqSet{}
for _, seqNum := range seqNums {
s.AddNum(uint32(seqNum))
}
fetchCommand := c.Fetch(s, &imap.FetchOptions{
BodySection: []*imap.FetchItemBodySection{bodySection},
})
var messages []Message
for message := fetchCommand.Next(); message != nil; message = fetchCommand.Next() {
var bodySectionData imapclient.FetchItemDataBodySection
ok := false
for {
item := message.Next()
if item == nil {
break
}
switch v := item.(type) {
case imapclient.FetchItemDataBodySection:
bodySectionData = v
ok = true
}
if ok {
break
}
}
if !ok {
continue
}
mr, err := mail.CreateReader(bodySectionData.Literal)
if err != nil {
return nil, err
}
h := mr.Header
fromList, err := h.AddressList("From")
from := "Unknown"
if len(fromList) != 0 {
from = fromList[0].String()
}
toList, err := h.AddressList("To")
to := "Unknown"
if len(toList) != 0 {
to = toList[0].String()
}
subject, err := h.Subject()
if err != nil {
subject = "Unknown"
}
content := ""
for {
p, err := mr.NextPart()
if err == io.EOF {
break
} else if err != nil {
return nil, err
}
switch p.Header.(type) {
case *mail.InlineHeader:
d, _ := io.ReadAll(p.Body)
if len(d) > 0 {
content = string(d)
}
}
if len([]rune(content)) > 1000 {
content = string([]rune(content)[:1000])
break
}
}
messages = append(messages, Message{
ID: message.SeqNum,
From: from,
To: to,
Subject: subject,
Content: content,
})
}
_ = fetchCommand.Close()
return messages, nil
}
设置解码器
有些邮件服务的编码不是utf-8
, 例如outlook. 不配置编码会出现这样的错误
unknown charset: unknown charset: message: unhandled charset "gb2312"
解码器来自go-message
包
func (i *ImapConnection) Connect() error
// ...
i.client, err = imapclient.DialTLS(fmt.Sprintf("%s:%d", config.Host, config.Port), &imapclient.Options{
UnilateralDataHandler: &imapclient.UnilateralDataHandler{
Mailbox: i.HandleNewMails,
},
WordDecoder: &mime.WordDecoder{CharsetReader: charset.Reader},
})
//...
}
编写机器人
telebot
这个包很不错, 开发体验类似与后端开发, 配置路由, 配置中间件. 非常适合用户邮件的CRUD
func main() {
token := os.Getenv("TOKEN")
if token == "" {
panic("Please set the TOKEN environment variable.")
}
pref := tele.Settings{
Token: token,
Poller: &tele.LongPoller{Timeout: 10 * time.Second},
}
bot, err := tele.NewBot(pref)
if err != nil {
log.Fatal(err)
return
}
server.SetBot(bot)
bot.Use(server.MessageFilter)
bot.Use(server.RecordUserMiddleware)
bot.Handle("/start", func(c tele.Context) error {
return c.Send(helloMessage)
})
bot.Handle("/add_email", server.HandleAddEmail)
bot.Handle("/cancel", server.CancelAddEmail)
bot.Handle("/delete_email", server.HandleDeleteEmail)
bot.Handle("/list_email", server.HandleListEmails)
bot.Handle(&tele.InlineButton{
Unique: server.InlineButtonDeleteEmail,
}, server.HandleDeleteEmailButton)
bot.Handle(tele.OnText, server.HandlePlainText)
bot.Start()
}
在用户添加邮件和系统初始化时, 需要给邮件连接添加一个 Listener, 从而实现邮件推送
func init() {
configs := data.GetAllConfigs()
for _, c := range configs {
mailConfig := c.Config
conn := mail.NewImapConnection(c.UserId, c.Email, mailConfig.(*data.ImapConfig))
conn.AddListener(server.HandleNewEmail)
err := mail.AddConnection(conn)
if err != nil {
log.Println("Failed to add connection:", err)
}
}
}