Skip to content

Latest commit

 

History

History
369 lines (288 loc) · 12.1 KB

File metadata and controls

369 lines (288 loc) · 12.1 KB

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.


Project: "Gmail Tray Notifier" - Deep Dive Implementation Plan

This document outlines the detailed technical steps and architecture for building the notifier application in Go.

1. Project Directory Structure

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

2. Core Concepts for the Developer

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.

3. Detailed Package Implementation

3.1 config/config.go - Configuration Handling

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
}

3.2 models/email.go - Data Model

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
}

3.3 imap/client.go - The IMAP Worker

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
		}
	}
}

3.4 ui/tray.go - System Tray Manager

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.")
}

3.5 main.go - The Orchestrator

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

4. Intern's Development Roadmap

Here is a suggested plan of attack to make development manageable.

  • Week 1: Core Logic (Command-Line Only)

    1. Goal: Get the config and imap packages working.
    2. Task: In main.go, temporarily remove all ui and systray code.
    3. Task: Instead of sending to a channel, make the imap.Client just log.Printf() the details of any new email it finds.
    4. 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.
  • Week 2: UI Integration & Concurrency

    1. Goal: Integrate the System Tray UI and get notifications working.
    2. Task: Implement the ui/tray.go and main.go code as detailed above.
    3. Task: Create a simple assets/icon.go file with a base64 encoded icon.
    4. 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

    1. Goal: Add state management and package the application.
    2. Task: Implement the state/manager.go package. Its job is to save the highest UID for each account to a file. Modify the imap.Client to use this state, so it only fetches emails with a UID greater than the last seen one. This prevents old "unread" emails from re-appearing on every startup.
    3. Task: Improve error handling. What happens if the internet connection drops? The imap.Client should attempt to reconnect periodically.
    4. Task: Follow the previous guide to create a .deb package for easy installation. Write a README.md with installation and configuration instructions.