Automating attendance email with Go
A small Go service that watches a mailbox over IMAP, checks the attendance records, then builds a company-format HTML email and sends it on a schedule.
- Go
- Gin
- gocron
- IMAP
- Docker
Every evening I did exactly one thing: open the attendance system, check whether my checkout had been recorded, and if I had forgotten, write an email asking HR to confirm the day, in the company's usual format. It repeated often enough that I wrote a small Go service to do it for me.
A boring job you cannot forget#
The hard part was never writing the email — the wording is always the same. The hard part is
remembering, on exactly the evening I am most tired. So the service is not clever, it just
has to be dependable: one Gin process on port 8080, a gocron scheduler with three fixed
times a day, and a goroutine reading the mailbox alongside it. All of it in a single
container on my own Armbian server.
Polling the mailbox instead of waiting on it#
The listener does not use IMAP IDLE. On every tick of an interval read from config, it opens
a fresh TLS connection to the IMAP server, logs in, selects INBOX, compares UidNext
against the last UID it handled, and hangs up. That costs a few seconds per round, but there
is no long-lived connection to keep alive: a dropped network, an expired session or a server
closing the socket just means the next tick dials again. Every error is logged and returned
from, so no broken state survives the round.
var lastCheckedUID uint32 = 0
func checkForNewEmails() {
c, err := client.DialTLS(imapAddr, &tls.Config{ServerName: imapConfig.Host})
if err != nil {
/* Dropped connections are routine: skip this round, redial on the next tick. */
log.Printf("Failed to connect to IMAP server: %v", err)
return
}
defer c.Logout()
mbox, err := c.Select("INBOX", false)
/* First run only plants the watermark; it never replays the existing mailbox. */
if lastCheckedUID == 0 {
lastCheckedUID = mbox.UidNext - 1
return
}
if mbox.UidNext <= lastCheckedUID+1 {
return // nothing new
}
seqSet := new(imap.SeqSet)
seqSet.AddRange(lastCheckedUID+1, mbox.UidNext-1)
go func() { c.UidFetch(seqSet, []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}, messages) }()
}That UID watermark is what prevents duplicate alerts: each message crosses the line exactly
once. I also fetch only the ENVELOPE rather than the body, since a round of polling needs
nothing more than the sender and subject to push a Telegram notification.
Only send when I actually forgot#
The decision to send is not made from the email at all, it comes from the attendance records themselves, pulled over JSON-RPC: the five most recent entries, sorted newest first, then filtered down to today.
func CheckMissedCheckout() bool {
now := time.Now().In(loc)
// No records today -> it was a day off, not a missed checkout.
if len(todayRecords) == 0 {
return false
}
// Sorted newest -> oldest, so the first entry is the last action of the day.
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
}Those three branches are the whole difficulty: a day off has no records at all and must
never trigger a mail, a sign_out means I already left, and only a last state of sign_in
after 19:30 counts as forgetting. The mailing job runs once each evening, so at most one
email per day; the two reporting jobs only post to Telegram and skip Sundays on their own.
Making it look like a human wrote it#
The formatter assembles the RFC headers itself — Message-ID, an RFC1123Z Date,
MIME-Version and Content-Type: text/html; charset="UTF-8" — and joins them to an
inline-styled HTML body in the company's format: greeting, employee ID, the date as
Monday, January 2, 2006, signature and footer. After the SMTP-over-TLS send, the service
APPENDs a copy to the sent folder, trying Sent, Sent Items, a UTF-7 encoded Vietnamese
name and finally INBOX.Sent, because every server names it differently. Without that step
there would be no trace of the mail in my own mailbox.
Outcome#
- Nothing left to remember: the service checks by itself and mails only on the days it should
- A multi-arch Docker image for
arm64andamd64, running directly on ARM hardware config.jsonis mounted from the host and kept out of the repo, so no secret is committed- Two manual endpoints for running a check or sending the mail by hand when needed