Tự động hoá email chấm công bằng Go
Dịch vụ Go nhỏ đọc hộp thư qua IMAP, đối chiếu dữ liệu chấm công rồi tự dựng email HTML theo template công ty và gửi theo lịch.
- Go
- Gin
- gocron
- IMAP
- Docker
Mỗi tối tôi đều làm đúng một việc: mở hệ thống chấm công xem đã ghi nhận giờ ra chưa, nếu quên thì viết một email xin xác nhận công theo đúng template của công ty. Việc đó lặp lại đủ lâu để tôi viết một dịch vụ Go nhỏ làm thay.
Một việc buồn tẻ nhưng không được quên#
Cái khó của việc này chưa bao giờ là soạn thư — template thư luôn giống nhau. Cái khó là
nhớ, vào đúng buổi tối mà tôi mệt nhất. Nên dịch vụ tôi viết không thông minh, nó chỉ cần
đáng tin: một tiến trình Gin nghe port 8080, một scheduler gocron với ba mốc giờ trong
ngày, và một goroutine đọc hộp thư chạy song song. Tất cả nằm trong một container duy nhất
trên server Armbian cá nhân.
Đọc hộp thư bằng cách hỏi lại, không phải chờ#
Phần listener không dùng IMAP IDLE. Cứ sau mỗi chu kỳ đọc từ config, dịch vụ mở một kết nối
TLS mới tới server IMAP, đăng nhập, chọn INBOX, so UidNext với UID cuối cùng đã xử lý
rồi đóng kết nối. Cách này tốn vài giây mỗi vòng nhưng đổi lại không có kết nối dài nào cần
giữ sống: mạng rớt, session hết hạn hay server đóng socket thì vòng sau chỉ đơn giản là
quay số lại từ đầu. Mọi lỗi đều được ghi log rồi return, không có trạng thái hỏng nào
tồn đọng.
var lastCheckedUID uint32 = 0
func checkForNewEmails() {
c, err := client.DialTLS(imapAddr, &tls.Config{ServerName: imapConfig.Host})
if err != nil {
/* Rớt kết nối là chuyện thường: bỏ vòng này, tick sau quay số lại. */
log.Printf("Failed to connect to IMAP server: %v", err)
return
}
defer c.Logout()
mbox, err := c.Select("INBOX", false)
/* Lần chạy đầu chỉ đặt mốc, không xử lý lại cả hộp thư cũ. */
if lastCheckedUID == 0 {
lastCheckedUID = mbox.UidNext - 1
return
}
if mbox.UidNext <= lastCheckedUID+1 {
return // không có thư mới
}
seqSet := new(imap.SeqSet)
seqSet.AddRange(lastCheckedUID+1, mbox.UidNext-1)
go func() { c.UidFetch(seqSet, []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}, messages) }()
}Mốc UID chính là thứ chống báo trùng: mỗi thư chỉ vượt qua ngưỡng đúng một lần. Tôi cũng chỉ
lấy ENVELOPE chứ không tải nội dung, nên một vòng quét chỉ cần người gửi và tiêu đề để bắn
thông báo sang Telegram.
Chỉ gửi khi thật sự quên#
Quyết định gửi thư không dựa vào email, mà dựa vào chính dữ liệu chấm công lấy qua JSON-RPC: năm record gần nhất, sắp xếp mới trước, rồi lọc lấy các record của hôm nay.
func CheckMissedCheckout() bool {
now := time.Now().In(loc)
// Không có bản ghi nào hôm nay -> hôm nay nghỉ, không phải quên chấm công.
if len(todayRecords) == 0 {
return false
}
// Đã sắp xếp mới -> cũ, nên phần tử đầu là hành động cuối cùng trong ngày.
lastRecord := todayRecords[0]
if lastRecord.AttendanceStatus == "sign_in" {
targetHour, targetMinute := 19, 30
if now.Hour() > targetHour || (now.Hour() == targetHour && now.Minute() >= targetMinute) {
return true
}
}
return false
}Ba nhánh đó là toàn bộ phần khó: ngày nghỉ không có record nên tuyệt đối không gửi, đã có
sign_out thì thôi, và chỉ khi trạng thái cuối vẫn là sign_in sau 19:30 mới coi là quên.
Job gửi thư chạy đúng một lần mỗi tối nên nhiều nhất là một email mỗi ngày; hai job báo cáo
sáng và tối chỉ nhắn Telegram và tự bỏ qua Chủ Nhật.
Thư phải giống thư do người viết#
Phần dựng thư tự ghép header RFC — Message-ID, Date dạng RFC1123Z, MIME-Version và
Content-Type: text/html; charset="UTF-8" — rồi nối với phần thân HTML dùng style inline
theo đúng template công ty: lời chào, mã nhân viên, ngày ở dạng Monday, January 2, 2006,
chữ ký và footer. Gửi xong qua SMTP over TLS, dịch vụ còn APPEND một bản sao vào hộp Đã
gửi, thử lần lượt Sent, Sent Items, tên tiếng Việt mã hoá UTF-7 rồi INBOX.Sent, vì mỗi
server đặt tên một kiểu. Thiếu bước đó thì trong hộp thư của tôi không còn dấu vết nào cả.
Kết quả#
- Không phải nhớ nữa: dịch vụ tự kiểm tra và chỉ gửi đúng những ngày cần gửi
- Docker image build đa kiến trúc
arm64vàamd64, chạy được thẳng trên board ARM config.jsonđược mount từ server và nằm ngoài repo, nên không bí mật nào bị commit- Hai endpoint thủ công để tự chạy kiểm tra hoặc gửi thư khi cần can thiệp