avatar nyne
Open App

Telegram 邮件机器人

手上的邮箱越来越多, 管理它们变成一件困难的事. 我希望在所有设备上都能接收所有的邮件. 而我在所有设备上都高强度使用Telegram, 于是我便计划做一个邮件机器人.

项目规划

功能计划

技术规划

实现邮件接收

管理连接

使用 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)
		}
	}
}