Excellent. Here is a more granular, in-depth technical plan with Go code snippets and explanations. This document is designed to be handed directly to an intern or junior developer to build the "Gmail Tray Notifier" from the ground up.
This document outlines the detailed technical steps and architecture for building the notifier application in Go.
A clean project structure is essential. We'll separate concerns into different packages.
gmail-notifier/
├── cmd/
│ └── notifier/
│ └── main.go # Main application entry point
├── internal/
│ ├── config/
│ │ └── config.go # Structs and functions for loading config.json
│ ├── imap/
│ │ └── client.go # Core logic for connecting and listening to IMAP
│ ├── models/
│ │ └── email.go # Data structure for passing email info
│ ├── state/
│ │ └── manager.go # Handles saving/loading last seen email UID
│ └── ui/
│ └── tray.go # Manages the system tray icon, menu, and notifications
├── assets/
│ └── icon.go # Holds the byte data for the tray icon
└── go.mod # Go module file
Before coding, it's important to understand two key Go concepts we'll be using heavily.
- Goroutines (Concurrency): Think of a goroutine as a very lightweight thread. We will launch a separate goroutine for each email account. This allows all accounts to be monitored simultaneously without blocking each other. If one account is slow to respond, the others are unaffected.
- Channels (Communication): Channels are the pipes that connect our concurrent goroutines. Our IMAP goroutines will do their work in the background. When they find a new email, they will send the email's details through a channel to the main UI goroutine, which is responsible for updating the system tray menu. This is Go's primary method for safe communication between concurrent tasks.
Goal: Define data structures for our config and write code to load it from a JSON file.
This package will handle loading user credentials from ~/.config/gmail-notifier/config.json.
// internal/config/config.go
package config
import (
"encoding/json"
"os"
"path/filepath"
)
// Account holds the credentials for a single Gmail account.
type Account struct {
Email string `json:"email"`
AppPassword string `json:"app_password"`
}
// Config holds the list of all accounts to monitor.
type Config struct {
Accounts []Account `json:"accounts"`
}
// Load reads the configuration from the user's config directory.
func Load() (*Config, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
configPath := filepath.Join(home, ".config", "gmail-notifier", "config.json")
data, err := os.ReadFile(configPath)
if err != nil {
return nil, err // The main function should handle creating a template file if this fails.
}
var cfg Config
err = json.Unmarshal(data, &cfg)
if err != nil {
return nil, err
}
return &cfg, nil
}Goal: Create a simple struct to pass email information between goroutines.
// internal/models/email.go
package models
// Email represents the essential details of a new email.
type Email struct {
Account string // Which account this email belongs to
From string
Subject string
// We'll generate the link in the UI part
}Goal: This is the most complex part. It handles connecting, authenticating, and listening for new mail for a single account.
// internal/imap/client.go
package imap
import (
"crypto/tls"
"log"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/user/gmail-notifier/internal/models" // Use your actual module path
)
const gmailIMAPServer = "imap.gmail.com:993"
// Client manages the IMAP connection for one account.
type Client struct {
config config.Account
updates chan<- models.Email // Channel to send new emails to the UI
}
// NewClient creates a new IMAP client worker.
func NewClient(cfg config.Account, updates chan<- models.Email) *Client {
return &Client{config: cfg, updates: updates}
}
// Run starts the monitoring process. This should be run in a goroutine.
func (c *Client) Run() {
// 1. Connect and Login
client, err := imapclient.DialTLS(gmailIMAPServer, &tls.Config{})
if err != nil {
log.Printf("Failed to connect to IMAP for %s: %v", c.config.Email, err)
return
}
defer client.Logout()
if err := client.Login(c.config.Email, c.config.AppPassword).Wait(); err != nil {
log.Printf("Failed to login for %s: %v", c.config.Email, err)
return
}
log.Printf("Successfully logged in for %s", c.config.Email)
// 2. Select INBOX
if _, err := client.Select("INBOX", nil).Wait(); err != nil {
log.Printf("Failed to select INBOX for %s: %v", c.config.Email, err)
return
}
// TODO: Add initial sync logic here to fetch already unread emails.
// 3. Start IDLE loop to wait for new messages
for {
idleCmd, err := client.Idle()
if err != nil {
log.Printf("Failed to start IDLE for %s: %v", c.config.Email, err)
time.Sleep(30 * time.Second) // Wait before retrying
continue
}
// Wait for updates from the server
for {
update := <-idleCmd.Updates()
if _, ok := update.(*imapclient.MailboxUpdate); ok {
log.Printf("New mailbox update for %s", c.config.Email)
break // Exit inner loop to fetch new mail
}
}
// Stop IDLEing to fetch the new message
idleCmd.Close()
// 4. Fetch the newest message
// For simplicity, we search for all unseen messages and process the newest.
// A more robust solution would use the state manager to track UIDs.
searchCriteria := imap.NewSearchCriteria().WithFlags("!SEEN")
seqNums, err := client.Search(searchCriteria, nil).Wait()
if err != nil || len(seqNums) == 0 {
continue
}
// Fetch the latest message
latestSeqNum := seqNums[len(seqNums)-1]
fetchOptions := &imap.FetchOptions{Envelope: true}
msgStream := client.Fetch(imap.NewSeqSetNum(latestSeqNum), fetchOptions)
if msg, err := msgStream.Recv(); err == nil {
envelope := msg.Envelope
newEmail := models.Email{
Account: c.config.Email,
From: envelope.From[0].Address(),
Subject: envelope.Subject,
}
// Send the new email to the UI thread via the channel
c.updates <- newEmail
}
}
}Goal: Initialize the system tray, listen for new emails on a channel, and update the menu.
// internal/ui/tray.go
package ui
import (
"fmt"
"log"
"os/exec"
"github.com/gen2brain/beeep"
"github.com/getlantern/systray"
"github.com/user/gmail-notifier/internal/models" // Use your actual module path
"github.com/user/gmail-notifier/assets" // Import the icon package
)
const maxMenuItems = 15 // Max number of recent emails to show
// Run starts the system tray UI. This is a blocking call.
func Run(updates <-chan models.Email) {
systray.Run(func() { onReady(updates) }, onExit)
}
// onReady is called when the systray is initialized.
func onReady(updates <-chan models.Email) {
systray.SetIcon(assets.IconData) // Set icon from assets/icon.go
systray.SetTitle("Gmail Notifier")
systray.SetTooltip("No new mail")
mQuit := systray.AddMenuItem("Quit", "Quit the application")
systray.AddSeparator()
// Goroutine to listen for updates from the IMAP clients and UI clicks
go func() {
var menuItems []*systray.MenuItem
for {
select {
case email := <-updates:
// A new email has arrived!
log.Printf("UI received new email: %s", email.Subject)
// 1. Show notification
title := fmt.Sprintf("New Mail from %s", email.From)
body := email.Subject
beeep.Notify(title, body, "")
// 2. Add to top of menu
newItem := systray.AddMenuItem(fmt.Sprintf("[%s] %s", email.Account, email.Subject), email.From)
// Keep track of menu items to limit the list size
menuItems = append([]*systray.MenuItem{newItem}, menuItems...)
if len(menuItems) > maxMenuItems {
menuItems[maxMenuItems].Hide() // Hide oldest item
menuItems = menuItems[:maxMenuItems]
}
// Goroutine to handle clicks on this new menu item
go func(item *systray.MenuItem, accountEmail string) {
<-item.ClickedCh
link := fmt.Sprintf("https://mail.google.com/mail/u/%s/#inbox", accountEmail)
exec.Command("xdg-open", link).Start()
}(newItem, email.Account)
case <-mQuit.ClickedCh:
systray.Quit()
return
}
}
}()
}
// onExit is called when the application is closing.
func onExit() {
log.Println("Gmail Notifier is shutting down.")
}Goal: Tie everything together. Load config, create the channel, start the IMAP goroutines, and run the UI.
// cmd/notifier/main.go
package main
import (
"log"
"github.com/user/gmail-notifier/internal/config"
"github.com/user/gmail-notifier/internal/imap"
"github.com/user/gmail-notifier/internal/models"
"github.com/user/gmail-notifier/internal/ui"
)
func main() {
// 1. Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("FATAL: Could not load config.json. Please create one at ~/.config/gmail-notifier/config.json. Error: %v", err)
}
if len(cfg.Accounts) == 0 {
log.Fatal("FATAL: No accounts found in config.json. Please add at least one account.")
}
// 2. Create the channel for communication
// A buffered channel can hold a few emails without blocking, just in case the UI is slow.
emailUpdates := make(chan models.Email, len(cfg.Accounts)*5)
// 3. Start a goroutine for each account
for _, acc := range cfg.Accounts {
log.Printf("Starting worker for %s", acc.Email)
client := imap.NewClient(acc, emailUpdates)
go client.Run() // The "go" keyword starts the function in a new goroutine
}
// 4. Start the UI (this is a blocking call and must be last)
log.Println("Starting system tray UI...")
ui.Run(emailUpdates)
}Here is a suggested plan of attack to make development manageable.
-
Week 1: Core Logic (Command-Line Only)
- Goal: Get the
configandimappackages working. - Task: In
main.go, temporarily remove alluiandsystraycode. - Task: Instead of sending to a channel, make the
imap.Clientjustlog.Printf()the details of any new email it finds. - Test: Run the app from your terminal. It should connect, log in, and print subjects of unread emails. This confirms the hardest part (IMAP communication) works before adding UI complexity.
- Goal: Get the
-
Week 2: UI Integration & Concurrency
- Goal: Integrate the System Tray UI and get notifications working.
- Task: Implement the
ui/tray.goandmain.gocode as detailed above. - Task: Create a simple
assets/icon.gofile with a base64 encoded icon. - Test: Run the app. The icon should appear. When you send an email to one of your configured accounts, a desktop notification should pop up and a new item should appear in the tray menu. Clicking the item should open Gmail.
-
Week 3: Refinement & Packaging
- Goal: Add state management and package the application.
- Task: Implement the
state/manager.gopackage. Its job is to save the highestUIDfor each account to a file. Modify theimap.Clientto use this state, so it only fetches emails with aUIDgreater than the last seen one. This prevents old "unread" emails from re-appearing on every startup. - Task: Improve error handling. What happens if the internet connection drops? The
imap.Clientshould attempt to reconnect periodically. - Task: Follow the previous guide to create a
.debpackage for easy installation. Write aREADME.mdwith installation and configuration instructions.