The user wants a small, beautiful terminal email client that feels like neomutt but is built from scratch with a simpler codebase. Key motivations:
- Write and read emails in Markdown (composed in neovim, rendered with glamour)
- HEY-style screener: folder/tag-based inbox gating (allowlist already exists)
- Send as multipart/alternative (plain text + minimal HTML) so links and formatting render nicely for recipients
- Charmbracelet aesthetic (bubbletea TUI, glamour, lipgloss)
- Go (preferred, already used in msgvault and hey-cli)
MVP scope: Inbox list → Read email → Compose in neovim → Send via SMTP
neomd/
├── cmd/neomd/
│ └── main.go # entry point: load config, start bubbletea
├── internal/
│ ├── config/
│ │ └── config.go # TOML reader → ~/.config/neomd/config.toml
│ ├── imap/
│ │ └── client.go # go-imap/v2: connect, list folders, fetch, move
│ ├── smtp/
│ │ └── sender.go # net/smtp TLS: build multipart/alt MIME, send
│ ├── screener/
│ │ ├── screener.go # load/save allowlists; classify incoming email
│ │ └── lists.go # read screened_in.txt, screened_out.txt, feed.txt, papertrail.txt
│ ├── editor/
│ │ └── editor.go # spawn $EDITOR (nvim), return tmp file content
│ ├── render/
│ │ ├── markdown.go # glamour: markdown → ANSI for viewport
│ │ └── html.go # goldmark: markdown → HTML (for sending)
│ └── ui/
│ ├── model.go # root bubbletea Model, viewState enum, Update, View
│ ├── inbox.go # bubbles/list for inbox, folder switcher
│ ├── reader.go # bubbles/viewport for reading email
│ ├── compose.go # bubbles/textinput (To, Subject), then nvim
│ └── styles.go # lipgloss palette and layout
├── go.mod
└── go.sum
github.com/charmbracelet/bubbletea v1.3.x # TUI (same as msgvault)
github.com/charmbracelet/bubbles v1.x # list, viewport, textinput, spinner
github.com/charmbracelet/glamour v0.x # markdown → ANSI rendering
github.com/charmbracelet/lipgloss v1.x # styling
github.com/emersion/go-imap/v2 v2.x # IMAP (already proven in msgvault)
github.com/emersion/go-message v0.18.x # MIME/header parsing
github.com/yuin/goldmark v1.x # Markdown → HTML for sending
github.com/BurntSushi/toml v1.x # config (same as msgvault)
~/.config/neomd/config.toml (auto-created with placeholder on first run):
[account]
name = "Personal"
imap = "imap.example.com:993" # TLS; :143 + starttls = true for STARTTLS
smtp = "smtp.example.com:587"
user = "me@example.com"
password = "app-password"
from = "Me <me@example.com>"
[screener]
# paths to existing allowlist files (reuse from neomutt setup)
screened_in = "~/.config/mutt/screened_in.txt"
screened_out = "~/.config/mutt/screened_out.txt"
feed = "~/.config/mutt/feed.txt"
papertrail = "~/.config/mutt/papertrail.txt"
[folders]
inbox = "INBOX"
sent = "Sent"
trash = "Trash"
drafts = "Drafts"
to_screen = "ToScreen"
feed = "Feed"
papertrail = "PaperTrail"
screened_out = "ScreenedOut"
[ui]
theme = "dark" # dark | light | auto
inbox_count = 50viewState enum:
stateInbox → bubbles/list of email summaries
stateReading → bubbles/viewport with glamour-rendered body
stateCompose → textinput for To/Subject, then hands off to $EDITOR
stateToScreen → list of unscreened senders awaiting decision
Transitions:
Inbox →[Enter]→ Reading
Inbox →[c]→ Compose
Inbox →[Tab]→ cycle folders (Inbox / ToScreen / Feed / PaperTrail)
ToScreen →[I]→ approve sender → add to screened_in.txt, move to INBOX
ToScreen →[O]→ block sender → add to screened_out.txt, move to ScreenedOut
ToScreen →[F]→ mark as Feed → add to feed.txt, move to Feed
ToScreen →[P]→ mark PaperTrail → add to papertrail.txt, move to PaperTrail
Reading →[q]→ Inbox
Compose →[Enter after Subject]→ suspend TUI → nvim → resume → send → Inbox
Adapted from /home/sspaeti/git/email/msgvault/internal/imap/client.go:
Connect()→imapclient.DialTLSorDialStartTLSFetchHeaders(folder string, n int) []EmailSummary→ SELECT folder, UID FETCH last N with ENVELOPEFetchBody(uid uint32) string→ UID FETCH BODY[], parse with go-message:- Prefer
text/plainpart - Fall back to stripping
text/htmlif no plain part
- Prefer
MoveMessage(uid, from, to string)→ UID COPY + UID STORE \Deleted + EXPUNGE
Async pattern: bubbletea tea.Cmd functions emit typed messages (inboxLoadedMsg, bodyLoadedMsg, errMsg).
Offline support (future): Architecture leaves room to swap the IMAP client for a Maildir reader (mbsync-synced local Maildir). The imap.Client interface can be backed by either live IMAP or local Maildir — same interface, swap implementation. For now: live IMAP only.
Reuses the four existing plain-text lists from the neomutt setup:
screened_in.txt — approved senders (one email per line)
screened_out.txt — blocked senders
feed.txt — newsletter/feed senders
papertrail.txt — receipt/notification senders
type Screener struct {
screenedIn []string // loaded at startup
screenedOut []string
feed []string
papertrail []string
}
func (s *Screener) Classify(from string) Category
// Category: Inbox | ToScreen | ScreenedOut | Feed | PaperTrail
func (s *Screener) Approve(email string) error // append to screened_in.txt
func (s *Screener) Block(email string) error // append to screened_out.txt
func (s *Screener) MarkFeed(email string) error // append to feed.txt
func (s *Screener) MarkPaperTrail(email string) // append to papertrail.txtOn startup, neomd can optionally run a screening pass on INBOX: any unrecognized sender is moved to ToScreen (same logic as initial_screening.sh).
The problem with plain text only: markdown syntax like [link](url) shows as literal text. Links are unclickable. Bold **text** shows with asterisks.
Solution: Send as multipart/alternative — every mail client picks the best part:
text/plain— the raw markdown as typed (readable, no rendering needed)text/html— goldmark-converted HTML wrapped in a minimal CSS template
// internal/render/html.go
func MarkdownToHTML(md string) (string, error) {
// Use goldmark to convert markdown → HTML fragment
// Wrap in minimal template (derived from listmonk template):
// max-width 650px, system fonts, styled links, <pre> for code
// No tracking pixels, no complex layout
}
// internal/smtp/sender.go
func Send(cfg Config, to, subject, markdownBody string) error {
plainText := markdownBody // raw markdown = readable plain text
htmlBody, _ := render.MarkdownToHTML(markdownBody)
// Build multipart/alternative MIME message
// Part 1: text/plain; charset=utf-8
// Part 2: text/html; charset=utf-8
// Headers: From, To, Subject, Date, Message-ID
// Send via net/smtp with STARTTLS
}Minimal HTML wrapper (inlined from listmonk template, stripped to essentials):
<html><body style="font-family:system-ui,sans-serif;max-width:650px;
margin:0 auto;padding:20px;color:#333;line-height:1.6">
{{ BODY }}
</body></html>This gives recipients proper link rendering, bold/italic, code blocks — while the sender still writes pure markdown in neovim.
Reference template: /home/sspaeti/git/sspaeti.com/listmonk/misc/email-template.html
Pandoc template (for design reference): /home/sspaeti/git/general/dotfiles/mutt/.config/mutt/templates/email.html
func Compose(prelude string) (string, error) {
// prelude = "To: ...\nSubject: ...\n\n---\n\n" for context
f, _ := os.CreateTemp("", "neomd-*.md")
f.WriteString(prelude)
f.Close()
editor := os.Getenv("EDITOR")
if editor == "" { editor = "nvim" }
cmd := exec.Command(editor, f.Name())
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Run()
content, _ := os.ReadFile(f.Name())
os.Remove(f.Name())
return string(content), nil
}The bubbletea program calls tea.Suspend before spawning nvim, then tea.Resume after — same pattern hey-cli uses for external processes.
bubbles/listwith customItemDelegate- Each row:
● From │ Subject │ Date(● = unread indicator) - Tab key cycles folders:
Inbox→ToScreen→Feed→PaperTrail - Folder name shown in header via lipgloss
- Spinner while fetching via
bubbles/spinner
bubbles/viewportwith glamour-rendered body- Lipgloss bordered header block: From / To / Subject / Date
j/k/Space/PgDnscroll,qback to inbox
- Two
bubbles/textinputfields: To and Subject - Tab moves between fields; Enter on Subject → suspend → nvim → resume → send
- Status message in inbox after send
go.mod
go.sum (after go mod tidy)
cmd/neomd/main.go
internal/config/config.go
internal/imap/client.go ← adapt from msgvault
internal/smtp/sender.go
internal/screener/screener.go
internal/screener/lists.go
internal/editor/editor.go
internal/render/markdown.go
internal/render/html.go
internal/ui/model.go
internal/ui/inbox.go
internal/ui/reader.go
internal/ui/compose.go
internal/ui/styles.go
| Purpose | File |
|---|---|
| IMAP client pattern | /home/sspaeti/git/email/msgvault/internal/imap/client.go |
| TUI state machine | /home/sspaeti/git/email/hey-cli/internal/tui/tui.go |
| Config parsing | /home/sspaeti/git/email/msgvault/internal/config/config.go |
| Screener lists (reuse) | /home/sspaeti/git/general/dotfiles/mutt/.config/mutt/screened_in.txt etc. |
| Screener bash logic | /home/sspaeti/git/general/dotfiles/mutt/.config/mutt/initial_screening.sh |
| HTML email template | /home/sspaeti/git/sspaeti.com/listmonk/misc/email-template.html |
| Pandoc email template | /home/sspaeti/git/general/dotfiles/mutt/.config/mutt/templates/email.html |
| SMTP config reference | /home/sspaeti/git/general/dotfiles/mutt/.msmtprc |
| Neomutt C source | /home/sspaeti/git/email/neomutt/ (reference for edge cases: imap/, notmuch/) |
Architecture is designed for this. The IMAP layer will expose an interface:
type MailStore interface {
FetchHeaders(folder string, n int) ([]EmailSummary, error)
FetchBody(folder string, uid uint32) (string, error)
MoveMessage(uid uint32, from, to string) error
}MVP: ImapStore (live connection via go-imap/v2).
Future: MaildirStore (local sync via mbsync → reads Maildir directly, no network needed).
go build ./cmd/neomd— compiles cleanly- Fill
~/.config/neomd/config.tomlwith real IMAP/SMTP credentials ./neomd→ inbox loads, emails listed with sender/subject/date- Tab → switch to ToScreen folder; press
Ion an email → sender added to screened_in.txt - Enter on inbox email → glamour-rendered body in viewport
- Press
c→ fill To/Subject → nvim opensneomd-*.md→ write markdown → save → email sent - Recipient receives email with properly rendered HTML (links clickable, bold/italic work) and plain text fallback