A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

commit first version

sspaeti c1c9581f

+2738
+7
.claude/settings.local.json
··· 1 + { 2 + "permissions": { 3 + "allow": [ 4 + "Bash(go build:*)" 5 + ] 6 + } 7 + }
+15
.gitignore
··· 1 + # Binary 2 + neomd 3 + 4 + # Go build cache 5 + *.test 6 + *.out 7 + 8 + # Config with real credentials (use config.toml.example instead) 9 + config.toml 10 + 11 + # Editor temp files (compose buffers) 12 + neomd-*.md 13 + 14 + # OS 15 + .DS_Store
+42
Makefile
··· 1 + BINARY := neomd 2 + CMD := ./cmd/neomd 3 + INSTALL := $(HOME)/.local/bin 4 + 5 + .PHONY: build run install clean test vet fmt lint tidy 6 + 7 + ## build: compile the binary into ./neomd 8 + build: 9 + go build -o $(BINARY) $(CMD) 10 + 11 + ## run: build and run (pass ARGS="--config /path/to/config.toml" to override) 12 + run: build 13 + ./$(BINARY) $(ARGS) 14 + 15 + ## install: install the binary to ~/.local/bin 16 + install: build 17 + install -Dm755 $(BINARY) $(INSTALL)/$(BINARY) 18 + @echo "Installed to $(INSTALL)/$(BINARY)" 19 + 20 + ## test: run all tests 21 + test: 22 + go test ./... 23 + 24 + ## vet: run go vet 25 + vet: 26 + go vet ./... 27 + 28 + ## fmt: format all Go source files 29 + fmt: 30 + gofmt -w . 31 + 32 + ## tidy: tidy go.mod and go.sum 33 + tidy: 34 + go mod tidy 35 + 36 + ## clean: remove the compiled binary 37 + clean: 38 + rm -f $(BINARY) 39 + 40 + ## help: print this help 41 + help: 42 + @grep -E '^## ' Makefile | sed 's/^## //'
+137
README.md
··· 1 + # neomd 2 + 3 + A minimal terminal email client for people who write in Markdown and live in Neovim. 4 + 5 + Compose emails in your editor, read them rendered with [glamour](https://github.com/charmbracelet/glamour), and manage your inbox with a [HEY-style screener](https://www.hey.com/features/the-screener/) — all from the terminal. 6 + 7 + ## Features 8 + 9 + - **Write in Markdown, send beautifully** — compose in `$EDITOR` (defaults to `nvim`), send as `multipart/alternative`: raw Markdown as plain text + goldmark-rendered HTML so recipients get clickable links and formatting 10 + - **Glamour reading** — incoming emails rendered as styled Markdown in the terminal 11 + - **HEY-style screener** — unknown senders land in `ToScreen`; press `I/O/F/P` to approve, block, mark as Feed, or mark as PaperTrail; reuses your existing `screened_in.txt` lists from neomutt 12 + - **Folder tabs** — switch between Inbox, ToScreen, Feed, and PaperTrail with `Tab` 13 + - **IMAP + SMTP** — direct connection, no local sync daemon required 14 + 15 + ## Install 16 + 17 + ```sh 18 + git clone https://github.com/sspaeti/neomd 19 + cd neomd 20 + make install # installs to ~/.local/bin/neomd 21 + ``` 22 + 23 + Or just build locally: 24 + 25 + ```sh 26 + make build 27 + ./neomd 28 + ``` 29 + 30 + ## Configuration 31 + 32 + On first run, neomd creates `~/.config/neomd/config.toml` with placeholders: 33 + 34 + ```toml 35 + [account] 36 + name = "Personal" 37 + imap = "imap.example.com:993" # :993 = TLS, :143 = STARTTLS 38 + smtp = "smtp.example.com:587" 39 + user = "me@example.com" 40 + password = "app-password" 41 + from = "Me <me@example.com>" 42 + 43 + [screener] 44 + # reuse your existing neomutt allowlist files 45 + screened_in = "~/.config/mutt/screened_in.txt" 46 + screened_out = "~/.config/mutt/screened_out.txt" 47 + feed = "~/.config/mutt/feed.txt" 48 + papertrail = "~/.config/mutt/papertrail.txt" 49 + 50 + [folders] 51 + inbox = "INBOX" 52 + sent = "Sent" 53 + trash = "Trash" 54 + to_screen = "ToScreen" 55 + feed = "Feed" 56 + papertrail = "PaperTrail" 57 + screened_out = "ScreenedOut" 58 + 59 + [ui] 60 + theme = "dark" # dark | light | auto 61 + inbox_count = 50 62 + ``` 63 + 64 + Use an app-specific password (Gmail, Fastmail, etc.) rather than your main account password. 65 + 66 + ## Keybindings 67 + 68 + ### Inbox 69 + 70 + | Key | Action | 71 + |-----|--------| 72 + | `j` / `k` | Navigate up/down | 73 + | `Enter` | Open email | 74 + | `c` | Compose new email | 75 + | `Tab` | Switch folder (Inbox → ToScreen → Feed → PaperTrail) | 76 + | `r` | Refresh current folder | 77 + | `/` | Filter emails | 78 + | `q` | Quit | 79 + 80 + ### ToScreen folder 81 + 82 + | Key | Action | 83 + |-----|--------| 84 + | `I` | Approve sender → move to Inbox, add to `screened_in.txt` | 85 + | `O` | Block sender → move to ScreenedOut, add to `screened_out.txt` | 86 + | `F` | Mark as Feed → move to Feed folder, add to `feed.txt` | 87 + | `P` | Mark as PaperTrail → move to PaperTrail, add to `papertrail.txt` | 88 + 89 + ### Reading 90 + 91 + | Key | Action | 92 + |-----|--------| 93 + | `j` / `k` / `Space` | Scroll | 94 + | `q` / `Esc` | Back to inbox | 95 + 96 + ### Composing 97 + 98 + | Key | Action | 99 + |-----|--------| 100 + | `Tab` / `Enter` | Move to next field | 101 + | `Enter` (on Subject) | Open `$EDITOR` with a `.md` temp file | 102 + | `Esc` | Cancel | 103 + 104 + After saving and closing the editor, the email is sent automatically. 105 + 106 + ## How Sending Works 107 + 108 + neomd sends every email as `multipart/alternative`: 109 + 110 + - **`text/plain`** — the raw Markdown you wrote (readable as-is in any client) 111 + - **`text/html`** — rendered by [goldmark](https://github.com/yuin/goldmark) with a clean CSS wrapper 112 + 113 + This means recipients using Gmail, Apple Mail, Outlook, etc. see properly formatted links, bold, headers, and code blocks — while you write nothing but Markdown. 114 + 115 + ## Make Targets 116 + 117 + ``` 118 + make build compile ./neomd 119 + make run build and run 120 + make install install to ~/.local/bin 121 + make test run tests 122 + make vet go vet 123 + make fmt gofmt -w . 124 + make tidy go mod tidy 125 + make clean remove compiled binary 126 + make help print this list 127 + ``` 128 + 129 + ## Stack 130 + 131 + - [Bubble Tea](https://github.com/charmbracelet/bubbletea) — TUI framework 132 + - [Bubbles](https://github.com/charmbracelet/bubbles) — list, viewport, textinput components 133 + - [Glamour](https://github.com/charmbracelet/glamour) — Markdown → terminal rendering 134 + - [Lipgloss](https://github.com/charmbracelet/lipgloss) — styling 135 + - [go-imap/v2](https://github.com/emersion/go-imap) — IMAP client 136 + - [go-message](https://github.com/emersion/go-message) — MIME parsing 137 + - [goldmark](https://github.com/yuin/goldmark) — Markdown → HTML for sending
+329
_prompts/prompt-plan.md
··· 1 + # Plan: neomd — Minimal Neovim-flavored Markdown Email Client 2 + 3 + ## Context 4 + 5 + The user wants a small, beautiful terminal email client that feels like neomutt but is built from scratch with a simpler codebase. Key motivations: 6 + - Write and read emails in Markdown (composed in neovim, rendered with glamour) 7 + - HEY-style screener: folder/tag-based inbox gating (allowlist already exists) 8 + - Send as multipart/alternative (plain text + minimal HTML) so links and formatting render nicely for recipients 9 + - Charmbracelet aesthetic (bubbletea TUI, glamour, lipgloss) 10 + - Go (preferred, already used in msgvault and hey-cli) 11 + 12 + MVP scope: **Inbox list → Read email → Compose in neovim → Send via SMTP** 13 + 14 + --- 15 + 16 + ## Architecture 17 + 18 + ``` 19 + neomd/ 20 + ├── cmd/neomd/ 21 + │ └── main.go # entry point: load config, start bubbletea 22 + ├── internal/ 23 + │ ├── config/ 24 + │ │ └── config.go # TOML reader → ~/.config/neomd/config.toml 25 + │ ├── imap/ 26 + │ │ └── client.go # go-imap/v2: connect, list folders, fetch, move 27 + │ ├── smtp/ 28 + │ │ └── sender.go # net/smtp TLS: build multipart/alt MIME, send 29 + │ ├── screener/ 30 + │ │ ├── screener.go # load/save allowlists; classify incoming email 31 + │ │ └── lists.go # read screened_in.txt, screened_out.txt, feed.txt, papertrail.txt 32 + │ ├── editor/ 33 + │ │ └── editor.go # spawn $EDITOR (nvim), return tmp file content 34 + │ ├── render/ 35 + │ │ ├── markdown.go # glamour: markdown → ANSI for viewport 36 + │ │ └── html.go # goldmark: markdown → HTML (for sending) 37 + │ └── ui/ 38 + │ ├── model.go # root bubbletea Model, viewState enum, Update, View 39 + │ ├── inbox.go # bubbles/list for inbox, folder switcher 40 + │ ├── reader.go # bubbles/viewport for reading email 41 + │ ├── compose.go # bubbles/textinput (To, Subject), then nvim 42 + │ └── styles.go # lipgloss palette and layout 43 + ├── go.mod 44 + └── go.sum 45 + ``` 46 + 47 + --- 48 + 49 + ## Key Dependencies 50 + 51 + ``` 52 + github.com/charmbracelet/bubbletea v1.3.x # TUI (same as msgvault) 53 + github.com/charmbracelet/bubbles v1.x # list, viewport, textinput, spinner 54 + github.com/charmbracelet/glamour v0.x # markdown → ANSI rendering 55 + github.com/charmbracelet/lipgloss v1.x # styling 56 + github.com/emersion/go-imap/v2 v2.x # IMAP (already proven in msgvault) 57 + github.com/emersion/go-message v0.18.x # MIME/header parsing 58 + github.com/yuin/goldmark v1.x # Markdown → HTML for sending 59 + github.com/BurntSushi/toml v1.x # config (same as msgvault) 60 + ``` 61 + 62 + --- 63 + 64 + ## Config File 65 + 66 + `~/.config/neomd/config.toml` (auto-created with placeholder on first run): 67 + 68 + ```toml 69 + [account] 70 + name = "Personal" 71 + imap = "imap.example.com:993" # TLS; :143 + starttls = true for STARTTLS 72 + smtp = "smtp.example.com:587" 73 + user = "me@example.com" 74 + password = "app-password" 75 + from = "Me <me@example.com>" 76 + 77 + [screener] 78 + # paths to existing allowlist files (reuse from neomutt setup) 79 + screened_in = "~/.config/mutt/screened_in.txt" 80 + screened_out = "~/.config/mutt/screened_out.txt" 81 + feed = "~/.config/mutt/feed.txt" 82 + papertrail = "~/.config/mutt/papertrail.txt" 83 + 84 + [folders] 85 + inbox = "INBOX" 86 + sent = "Sent" 87 + trash = "Trash" 88 + drafts = "Drafts" 89 + to_screen = "ToScreen" 90 + feed = "Feed" 91 + papertrail = "PaperTrail" 92 + screened_out = "ScreenedOut" 93 + 94 + [ui] 95 + theme = "dark" # dark | light | auto 96 + inbox_count = 50 97 + ``` 98 + 99 + --- 100 + 101 + ## TUI State Machine 102 + 103 + ``` 104 + viewState enum: 105 + stateInbox → bubbles/list of email summaries 106 + stateReading → bubbles/viewport with glamour-rendered body 107 + stateCompose → textinput for To/Subject, then hands off to $EDITOR 108 + stateToScreen → list of unscreened senders awaiting decision 109 + 110 + Transitions: 111 + Inbox →[Enter]→ Reading 112 + Inbox →[c]→ Compose 113 + Inbox →[Tab]→ cycle folders (Inbox / ToScreen / Feed / PaperTrail) 114 + ToScreen →[I]→ approve sender → add to screened_in.txt, move to INBOX 115 + ToScreen →[O]→ block sender → add to screened_out.txt, move to ScreenedOut 116 + ToScreen →[F]→ mark as Feed → add to feed.txt, move to Feed 117 + ToScreen →[P]→ mark PaperTrail → add to papertrail.txt, move to PaperTrail 118 + Reading →[q]→ Inbox 119 + Compose →[Enter after Subject]→ suspend TUI → nvim → resume → send → Inbox 120 + ``` 121 + 122 + --- 123 + 124 + ## IMAP Flow (internal/imap/client.go) 125 + 126 + Adapted from `/home/sspaeti/git/email/msgvault/internal/imap/client.go`: 127 + 128 + - `Connect()` → `imapclient.DialTLS` or `DialStartTLS` 129 + - `FetchHeaders(folder string, n int) []EmailSummary` → SELECT folder, UID FETCH last N with ENVELOPE 130 + - `FetchBody(uid uint32) string` → UID FETCH BODY[], parse with go-message: 131 + - Prefer `text/plain` part 132 + - Fall back to stripping `text/html` if no plain part 133 + - `MoveMessage(uid, from, to string)` → UID COPY + UID STORE \Deleted + EXPUNGE 134 + 135 + Async pattern: bubbletea `tea.Cmd` functions emit typed messages (`inboxLoadedMsg`, `bodyLoadedMsg`, `errMsg`). 136 + 137 + **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. 138 + 139 + --- 140 + 141 + ## Screener (internal/screener/) 142 + 143 + Reuses the four existing plain-text lists from the neomutt setup: 144 + ``` 145 + screened_in.txt — approved senders (one email per line) 146 + screened_out.txt — blocked senders 147 + feed.txt — newsletter/feed senders 148 + papertrail.txt — receipt/notification senders 149 + ``` 150 + 151 + ```go 152 + type Screener struct { 153 + screenedIn []string // loaded at startup 154 + screenedOut []string 155 + feed []string 156 + papertrail []string 157 + } 158 + 159 + func (s *Screener) Classify(from string) Category 160 + // Category: Inbox | ToScreen | ScreenedOut | Feed | PaperTrail 161 + 162 + func (s *Screener) Approve(email string) error // append to screened_in.txt 163 + func (s *Screener) Block(email string) error // append to screened_out.txt 164 + func (s *Screener) MarkFeed(email string) error // append to feed.txt 165 + func (s *Screener) MarkPaperTrail(email string) // append to papertrail.txt 166 + ``` 167 + 168 + On startup, neomd can optionally run a screening pass on INBOX: any unrecognized sender is moved to `ToScreen` (same logic as `initial_screening.sh`). 169 + 170 + --- 171 + 172 + ## Sending: Multipart/Alternative (plain text + HTML) 173 + 174 + **The problem with plain text only:** markdown syntax like `[link](url)` shows as literal text. Links are unclickable. Bold `**text**` shows with asterisks. 175 + 176 + **Solution:** Send as `multipart/alternative` — every mail client picks the best part: 177 + - `text/plain` — the raw markdown as typed (readable, no rendering needed) 178 + - `text/html` — goldmark-converted HTML wrapped in a minimal CSS template 179 + 180 + ```go 181 + // internal/render/html.go 182 + func MarkdownToHTML(md string) (string, error) { 183 + // Use goldmark to convert markdown → HTML fragment 184 + // Wrap in minimal template (derived from listmonk template): 185 + // max-width 650px, system fonts, styled links, <pre> for code 186 + // No tracking pixels, no complex layout 187 + } 188 + 189 + // internal/smtp/sender.go 190 + func Send(cfg Config, to, subject, markdownBody string) error { 191 + plainText := markdownBody // raw markdown = readable plain text 192 + htmlBody, _ := render.MarkdownToHTML(markdownBody) 193 + 194 + // Build multipart/alternative MIME message 195 + // Part 1: text/plain; charset=utf-8 196 + // Part 2: text/html; charset=utf-8 197 + // Headers: From, To, Subject, Date, Message-ID 198 + // Send via net/smtp with STARTTLS 199 + } 200 + ``` 201 + 202 + **Minimal HTML wrapper** (inlined from listmonk template, stripped to essentials): 203 + ```html 204 + <html><body style="font-family:system-ui,sans-serif;max-width:650px; 205 + margin:0 auto;padding:20px;color:#333;line-height:1.6"> 206 + {{ BODY }} 207 + </body></html> 208 + ``` 209 + 210 + This gives recipients proper link rendering, bold/italic, code blocks — while the sender still writes pure markdown in neovim. 211 + 212 + Reference template: `/home/sspaeti/git/sspaeti.com/listmonk/misc/email-template.html` 213 + Pandoc template (for design reference): `/home/sspaeti/git/general/dotfiles/mutt/.config/mutt/templates/email.html` 214 + 215 + --- 216 + 217 + ## Editor Flow (internal/editor/editor.go) 218 + 219 + ```go 220 + func Compose(prelude string) (string, error) { 221 + // prelude = "To: ...\nSubject: ...\n\n---\n\n" for context 222 + f, _ := os.CreateTemp("", "neomd-*.md") 223 + f.WriteString(prelude) 224 + f.Close() 225 + 226 + editor := os.Getenv("EDITOR") 227 + if editor == "" { editor = "nvim" } 228 + 229 + cmd := exec.Command(editor, f.Name()) 230 + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr 231 + cmd.Run() 232 + 233 + content, _ := os.ReadFile(f.Name()) 234 + os.Remove(f.Name()) 235 + return string(content), nil 236 + } 237 + ``` 238 + 239 + The bubbletea program calls `tea.Suspend` before spawning nvim, then `tea.Resume` after — same pattern hey-cli uses for external processes. 240 + 241 + --- 242 + 243 + ## Inbox View (ui/inbox.go) 244 + 245 + - `bubbles/list` with custom `ItemDelegate` 246 + - Each row: `● From │ Subject │ Date` (● = unread indicator) 247 + - Tab key cycles folders: `Inbox` → `ToScreen` → `Feed` → `PaperTrail` 248 + - Folder name shown in header via lipgloss 249 + - Spinner while fetching via `bubbles/spinner` 250 + 251 + ## Reader View (ui/reader.go) 252 + 253 + - `bubbles/viewport` with glamour-rendered body 254 + - Lipgloss bordered header block: From / To / Subject / Date 255 + - `j/k/Space/PgDn` scroll, `q` back to inbox 256 + 257 + ## Compose View (ui/compose.go) 258 + 259 + - Two `bubbles/textinput` fields: **To** and **Subject** 260 + - Tab moves between fields; Enter on Subject → suspend → nvim → resume → send 261 + - Status message in inbox after send 262 + 263 + --- 264 + 265 + ## Files to Create (all new in /home/sspaeti/git/email/neomd/) 266 + 267 + ``` 268 + go.mod 269 + go.sum (after go mod tidy) 270 + cmd/neomd/main.go 271 + internal/config/config.go 272 + internal/imap/client.go ← adapt from msgvault 273 + internal/smtp/sender.go 274 + internal/screener/screener.go 275 + internal/screener/lists.go 276 + internal/editor/editor.go 277 + internal/render/markdown.go 278 + internal/render/html.go 279 + internal/ui/model.go 280 + internal/ui/inbox.go 281 + internal/ui/reader.go 282 + internal/ui/compose.go 283 + internal/ui/styles.go 284 + ``` 285 + 286 + --- 287 + 288 + ## Reference Files 289 + 290 + | Purpose | File | 291 + |---------|------| 292 + | IMAP client pattern | `/home/sspaeti/git/email/msgvault/internal/imap/client.go` | 293 + | TUI state machine | `/home/sspaeti/git/email/hey-cli/internal/tui/tui.go` | 294 + | Config parsing | `/home/sspaeti/git/email/msgvault/internal/config/config.go` | 295 + | Screener lists (reuse) | `/home/sspaeti/git/general/dotfiles/mutt/.config/mutt/screened_in.txt` etc. | 296 + | Screener bash logic | `/home/sspaeti/git/general/dotfiles/mutt/.config/mutt/initial_screening.sh` | 297 + | HTML email template | `/home/sspaeti/git/sspaeti.com/listmonk/misc/email-template.html` | 298 + | Pandoc email template | `/home/sspaeti/git/general/dotfiles/mutt/.config/mutt/templates/email.html` | 299 + | SMTP config reference | `/home/sspaeti/git/general/dotfiles/mutt/.msmtprc` | 300 + | Neomutt C source | `/home/sspaeti/git/email/neomutt/` (reference for edge cases: imap/, notmuch/) | 301 + 302 + --- 303 + 304 + ## Offline Support (Future, not MVP) 305 + 306 + Architecture is designed for this. The IMAP layer will expose an interface: 307 + 308 + ```go 309 + type MailStore interface { 310 + FetchHeaders(folder string, n int) ([]EmailSummary, error) 311 + FetchBody(folder string, uid uint32) (string, error) 312 + MoveMessage(uid uint32, from, to string) error 313 + } 314 + ``` 315 + 316 + MVP: `ImapStore` (live connection via go-imap/v2). 317 + Future: `MaildirStore` (local sync via mbsync → reads Maildir directly, no network needed). 318 + 319 + --- 320 + 321 + ## Verification 322 + 323 + 1. `go build ./cmd/neomd` — compiles cleanly 324 + 2. Fill `~/.config/neomd/config.toml` with real IMAP/SMTP credentials 325 + 3. `./neomd` → inbox loads, emails listed with sender/subject/date 326 + 4. Tab → switch to ToScreen folder; press `I` on an email → sender added to screened_in.txt 327 + 5. Enter on inbox email → glamour-rendered body in viewport 328 + 6. Press `c` → fill To/Subject → nvim opens `neomd-*.md` → write markdown → save → email sent 329 + 7. Recipient receives email with properly rendered HTML (links clickable, bold/italic work) and plain text fallback
+49
_prompts/prompt.md
··· 1 + # neomd 2 + I love neovim! I love RSS reader like newsboat (dotfiles... 3 + 4 + and general TUIs, I use a lot of them. 5 + 6 + See my configs in dotfiles (its with stow, so there hidden files) - e.g. check out my customization and shortcuts for neomutt in) - e.g. check out my customization and shortcuts for neomutt in /home/sspaeti/git/general/dotfiles/mutt - I even built a screener that was in HEY.com/ which i like a lot and which is my client I use: https://www.hey.com/features/the-screener/ - there's also a CLI now see /home/sspaeti/git/email/hey-cli 7 + 8 + 9 + On hackernews I just saw this: https://www.emailmd.dev/, sending email as markdown. 10 + 11 + 12 + That made me thinking, how hard would it be, to have similar experience with neomutt, but much simpler for some key features - to send and view emails that are configured via IMAP or similar? 13 + 14 + 15 + With all the dotfiles available and code from HEY cli, newsboat or neomutt, how would I build something similar to send email with neovim and read too, everything based on Markdown? 16 + 17 + Maybe we can also use https://github.com/kepano/defuddle (code here: /home/sspaeti/git/email/defuddle) that converts any HTML into Markdown and then we have emailmd.dev to convert MD to HTML. 18 + 19 + 20 + TO be hontest, I don't even want to send HTML, i just want to send simple text. E.g. in my newsletter I have this template which is simple and I like (/home/sspaeti/git/sspaeti.com/listmonk/misc/email-template.html) so maybe we can make a very simple template, or none at all, just plain markdown converted to plain text. 21 + 22 + but having links and headers would be nice still, or bold, italic etc. some basic fomrattings. 23 + 24 + 25 + Please research what's the easiest way to build a terminal based email client that works with neovim and markdown similar to neomutt, but simple, built from scratch - use any library that's useful such as crush to make beautiful, or Ratatui to create terminal UIs (don't create everything from scratch, just the summarized tool). 26 + 27 + I guess prefered languages are Rust or Go (e.g. /home/sspaeti/git/email/msgvault was recently built with Go and is also to do with email, also hey-cli is written in go). 28 + 29 + Also use https://github.com/charmbracelet/gum for making the CLI or TUI nice, similar to or https://github.com/charmbracelet/glamour to make render Markdown in the terminal. 30 + 31 + Speaking of aestetics, there's also pop email for go: https://github.com/charmbracelet/pop 32 + 33 + 34 + 35 + ## Updates/additions #1 36 + 37 + 1. i added neomutt code /home/sspaeti/git/email/neomutt, it's in C but it can help if we need to solve something complex or as they probably have solved all ways of email. 38 + 39 + 2. for offline availablility neomutt used /home/sspaeti/git/general/dotfiles/mutt/.msmtprc (i blieve??) which could be used, but don't have too, at least it seems to work. but if there's a simpler or direct go implementation, even better. but offline support is something that would be great too, at some point. But IMAP is also ok to start without offline. 40 + 41 + 3. can you prepare the architecture to have the HEY Screener that only allows emails in inbox that are approved in a screened_in.txt list e.g. all the code in bash and for different folder such as inbox (screened in only) screened out, papertrail for receipts etc and feed for newsletter etc. all code is in /home/sspaeti/git/general/dotfiles/mutt/.config/mutt. can we if not yet implement, at least 42 + 43 + 44 + ## Created Claude Plan 45 + /home/sspaeti/.claude/plans/giggly-juggling-hinton.md 46 + [plan](prompt-plan.md) 47 + 48 + 49 +
+49
go.mod
··· 1 + module github.com/sspaeti/neomd 2 + 3 + go 1.24.2 4 + 5 + require ( 6 + github.com/BurntSushi/toml v1.6.0 7 + github.com/charmbracelet/bubbles v1.0.0 8 + github.com/charmbracelet/bubbletea v1.3.10 9 + github.com/charmbracelet/glamour v0.9.1 10 + github.com/charmbracelet/lipgloss v1.1.0 11 + github.com/emersion/go-imap/v2 v2.0.0-beta.8 12 + github.com/emersion/go-message v0.18.2 13 + github.com/yuin/goldmark v1.7.8 14 + ) 15 + 16 + require ( 17 + github.com/alecthomas/chroma/v2 v2.14.0 // indirect 18 + github.com/atotto/clipboard v0.1.4 // indirect 19 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 20 + github.com/aymerick/douceur v0.2.0 // indirect 21 + github.com/charmbracelet/colorprofile v0.4.1 // indirect 22 + github.com/charmbracelet/x/ansi v0.11.6 // indirect 23 + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect 24 + github.com/charmbracelet/x/term v0.2.2 // indirect 25 + github.com/clipperhouse/displaywidth v0.9.0 // indirect 26 + github.com/clipperhouse/stringish v0.1.1 // indirect 27 + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect 28 + github.com/dlclark/regexp2 v1.11.0 // indirect 29 + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect 30 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 31 + github.com/gorilla/css v1.0.1 // indirect 32 + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 33 + github.com/mattn/go-isatty v0.0.20 // indirect 34 + github.com/mattn/go-localereader v0.0.1 // indirect 35 + github.com/mattn/go-runewidth v0.0.19 // indirect 36 + github.com/microcosm-cc/bluemonday v1.0.27 // indirect 37 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 38 + github.com/muesli/cancelreader v0.2.2 // indirect 39 + github.com/muesli/reflow v0.3.0 // indirect 40 + github.com/muesli/termenv v0.16.0 // indirect 41 + github.com/rivo/uniseg v0.4.7 // indirect 42 + github.com/sahilm/fuzzy v0.1.1 // indirect 43 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 44 + github.com/yuin/goldmark-emoji v1.0.5 // indirect 45 + golang.org/x/net v0.33.0 // indirect 46 + golang.org/x/sys v0.38.0 // indirect 47 + golang.org/x/term v0.30.0 // indirect 48 + golang.org/x/text v0.23.0 // indirect 49 + )
+131
go.sum
··· 1 + github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= 2 + github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 + github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= 4 + github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 5 + github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= 6 + github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= 7 + github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 8 + github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 9 + github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 10 + github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 11 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 12 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 13 + github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= 14 + github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 15 + github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 16 + github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 17 + github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= 18 + github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= 19 + github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 20 + github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 21 + github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= 22 + github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= 23 + github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM= 24 + github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk= 25 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 26 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 27 + github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= 28 + github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= 29 + github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= 30 + github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= 31 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 32 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 33 + github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 34 + github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 35 + github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= 36 + github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= 37 + github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= 38 + github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 39 + github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= 40 + github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 41 + github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 42 + github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 43 + github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= 44 + github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= 45 + github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= 46 + github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= 47 + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= 48 + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 49 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 50 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 51 + github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 52 + github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 53 + github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 54 + github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 55 + github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 56 + github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 57 + github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 58 + github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 59 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 60 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 61 + github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 62 + github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 63 + github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 64 + github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 65 + github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 66 + github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 67 + github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 68 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 69 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 70 + github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 71 + github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 72 + github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 73 + github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 74 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 75 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 76 + github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 77 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 78 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 79 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 80 + github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 81 + github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 82 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 83 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 84 + github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 85 + github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 86 + github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= 87 + github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 88 + github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= 89 + github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 90 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 91 + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 92 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 93 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 94 + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 95 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 96 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 97 + golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 98 + golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 99 + golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 100 + golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 101 + golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 102 + golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 103 + golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 104 + golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 105 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 106 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 107 + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 108 + golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 109 + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 110 + golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 111 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 112 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 113 + golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 114 + golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 115 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 116 + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 117 + golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 118 + golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 119 + golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 120 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 121 + golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 122 + golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 123 + golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 124 + golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 125 + golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 126 + golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 127 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 128 + golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 129 + golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 130 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 131 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+152
internal/config/config.go
··· 1 + // Package config loads neomd configuration from ~/.config/neomd/config.toml. 2 + package config 3 + 4 + import ( 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + "strings" 9 + 10 + "github.com/BurntSushi/toml" 11 + ) 12 + 13 + // AccountConfig holds IMAP/SMTP connection settings. 14 + type AccountConfig struct { 15 + Name string `toml:"name"` 16 + IMAP string `toml:"imap"` // host:port (993 = TLS, 143 = STARTTLS) 17 + SMTP string `toml:"smtp"` // host:port (587 = STARTTLS, 465 = TLS) 18 + User string `toml:"user"` 19 + Password string `toml:"password"` 20 + From string `toml:"from"` // "Name <email@example.com>" 21 + STARTTLS bool `toml:"starttls"` 22 + } 23 + 24 + // ScreenerConfig points to the allowlist/blocklist files. 25 + type ScreenerConfig struct { 26 + ScreenedIn string `toml:"screened_in"` 27 + ScreenedOut string `toml:"screened_out"` 28 + Feed string `toml:"feed"` 29 + PaperTrail string `toml:"papertrail"` 30 + } 31 + 32 + // FoldersConfig maps logical names to actual IMAP mailbox names. 33 + type FoldersConfig struct { 34 + Inbox string `toml:"inbox"` 35 + Sent string `toml:"sent"` 36 + Trash string `toml:"trash"` 37 + Drafts string `toml:"drafts"` 38 + ToScreen string `toml:"to_screen"` 39 + Feed string `toml:"feed"` 40 + PaperTrail string `toml:"papertrail"` 41 + ScreenedOut string `toml:"screened_out"` 42 + } 43 + 44 + // UIConfig holds display preferences. 45 + type UIConfig struct { 46 + Theme string `toml:"theme"` // dark | light | auto 47 + InboxCount int `toml:"inbox_count"` // number of messages to fetch 48 + } 49 + 50 + // Config is the root neomd configuration. 51 + type Config struct { 52 + Account AccountConfig `toml:"account"` 53 + Screener ScreenerConfig `toml:"screener"` 54 + Folders FoldersConfig `toml:"folders"` 55 + UI UIConfig `toml:"ui"` 56 + } 57 + 58 + // DefaultPath returns ~/.config/neomd/config.toml. 59 + func DefaultPath() string { 60 + home, _ := os.UserHomeDir() 61 + return filepath.Join(home, ".config", "neomd", "config.toml") 62 + } 63 + 64 + // Load reads config from path (or default location if path is empty). 65 + // If no config exists, returns a placeholder config and prints a hint. 66 + func Load(path string) (*Config, error) { 67 + if path == "" { 68 + path = DefaultPath() 69 + } 70 + path = expandPath(path) 71 + 72 + cfg := defaults() 73 + 74 + if _, err := os.Stat(path); os.IsNotExist(err) { 75 + if err := writeDefault(path, cfg); err == nil { 76 + return nil, fmt.Errorf("created default config at %s — please fill in your credentials", path) 77 + } 78 + return nil, fmt.Errorf("config not found at %s", path) 79 + } 80 + 81 + if _, err := toml.DecodeFile(path, cfg); err != nil { 82 + return nil, fmt.Errorf("parse config %s: %w", path, err) 83 + } 84 + 85 + cfg.Screener.ScreenedIn = expandPath(cfg.Screener.ScreenedIn) 86 + cfg.Screener.ScreenedOut = expandPath(cfg.Screener.ScreenedOut) 87 + cfg.Screener.Feed = expandPath(cfg.Screener.Feed) 88 + cfg.Screener.PaperTrail = expandPath(cfg.Screener.PaperTrail) 89 + 90 + return cfg, nil 91 + } 92 + 93 + func defaults() *Config { 94 + home, _ := os.UserHomeDir() 95 + muttDir := filepath.Join(home, ".config", "mutt") 96 + return &Config{ 97 + Account: AccountConfig{ 98 + Name: "Personal", 99 + IMAP: "imap.example.com:993", 100 + SMTP: "smtp.example.com:587", 101 + }, 102 + Screener: ScreenerConfig{ 103 + ScreenedIn: filepath.Join(muttDir, "screened_in.txt"), 104 + ScreenedOut: filepath.Join(muttDir, "screened_out.txt"), 105 + Feed: filepath.Join(muttDir, "feed.txt"), 106 + PaperTrail: filepath.Join(muttDir, "papertrail.txt"), 107 + }, 108 + Folders: FoldersConfig{ 109 + Inbox: "INBOX", 110 + Sent: "Sent", 111 + Trash: "Trash", 112 + Drafts: "Drafts", 113 + ToScreen: "ToScreen", 114 + Feed: "Feed", 115 + PaperTrail: "PaperTrail", 116 + ScreenedOut: "ScreenedOut", 117 + }, 118 + UI: UIConfig{ 119 + Theme: "dark", 120 + InboxCount: 50, 121 + }, 122 + } 123 + } 124 + 125 + func writeDefault(path string, cfg *Config) error { 126 + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { 127 + return err 128 + } 129 + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0600) 130 + if err != nil { 131 + return err 132 + } 133 + defer f.Close() 134 + return toml.NewEncoder(f).Encode(cfg) 135 + } 136 + 137 + func expandPath(path string) string { 138 + if path == "" { 139 + return path 140 + } 141 + if path == "~" || strings.HasPrefix(path, "~/") { 142 + home, err := os.UserHomeDir() 143 + if err != nil { 144 + return path 145 + } 146 + if path == "~" { 147 + return home 148 + } 149 + return filepath.Join(home, path[2:]) 150 + } 151 + return path 152 + }
+83
internal/editor/editor.go
··· 1 + // Package editor opens an external editor ($EDITOR, defaulting to nvim) 2 + // for composing email bodies in Markdown. 3 + package editor 4 + 5 + import ( 6 + "fmt" 7 + "os" 8 + "os/exec" 9 + ) 10 + 11 + // Compose writes prelude to a temp .md file, opens $EDITOR, waits for it 12 + // to close, reads and returns the file contents. 13 + // The caller is responsible for suspending/resuming the bubbletea program 14 + // around this call (via tea.ExecProcess or tea.Suspend/Resume). 15 + func Compose(prelude string) (string, error) { 16 + f, err := os.CreateTemp("", "neomd-*.md") 17 + if err != nil { 18 + return "", fmt.Errorf("create temp file: %w", err) 19 + } 20 + tmpPath := f.Name() 21 + defer os.Remove(tmpPath) 22 + 23 + if _, err := f.WriteString(prelude); err != nil { 24 + f.Close() 25 + return "", fmt.Errorf("write prelude: %w", err) 26 + } 27 + f.Close() 28 + 29 + editorBin := os.Getenv("EDITOR") 30 + if editorBin == "" { 31 + editorBin = "nvim" 32 + } 33 + 34 + cmd := exec.Command(editorBin, tmpPath) 35 + cmd.Stdin = os.Stdin 36 + cmd.Stdout = os.Stdout 37 + cmd.Stderr = os.Stderr 38 + if err := cmd.Run(); err != nil { 39 + return "", fmt.Errorf("editor exited: %w", err) 40 + } 41 + 42 + content, err := os.ReadFile(tmpPath) 43 + if err != nil { 44 + return "", fmt.Errorf("read composed file: %w", err) 45 + } 46 + return string(content), nil 47 + } 48 + 49 + // Prelude builds the comment header shown at the top of a new compose buffer. 50 + func Prelude(to, subject string) string { 51 + return fmt.Sprintf("<!-- To: %s -->\n<!-- Subject: %s -->\n\n", to, subject) 52 + } 53 + 54 + // ReplyPrelude builds a quote block for replies. 55 + func ReplyPrelude(to, subject, originalFrom, originalBody string) string { 56 + return fmt.Sprintf( 57 + "<!-- To: %s -->\n<!-- Subject: Re: %s -->\n\n---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n", 58 + to, subject, originalFrom, quoteLines(originalBody), 59 + ) 60 + } 61 + 62 + func quoteLines(body string) string { 63 + lines := "" 64 + for _, line := range splitLines(body) { 65 + lines += "> " + line + "\n" 66 + } 67 + return lines 68 + } 69 + 70 + func splitLines(s string) []string { 71 + var out []string 72 + start := 0 73 + for i, c := range s { 74 + if c == '\n' { 75 + out = append(out, s[start:i]) 76 + start = i + 1 77 + } 78 + } 79 + if start < len(s) { 80 + out = append(out, s[start:]) 81 + } 82 + return out 83 + }
+392
internal/imap/client.go
··· 1 + // Package imap provides a minimal IMAP client for neomd. 2 + // Adapted from github.com/wesm/msgvault/internal/imap/client.go. 3 + package imap 4 + 5 + import ( 6 + "bytes" 7 + "context" 8 + "fmt" 9 + "io" 10 + "log/slog" 11 + "regexp" 12 + "sort" 13 + "strings" 14 + "sync" 15 + "time" 16 + 17 + imap "github.com/emersion/go-imap/v2" 18 + "github.com/emersion/go-imap/v2/imapclient" 19 + "github.com/emersion/go-message" 20 + "github.com/emersion/go-message/mail" 21 + ) 22 + 23 + // Email is a fully parsed email message. 24 + type Email struct { 25 + UID uint32 26 + From string 27 + To string 28 + Subject string 29 + Date time.Time 30 + Seen bool 31 + Folder string 32 + } 33 + 34 + // Config holds connection parameters. 35 + type Config struct { 36 + Host string // e.g. "imap.example.com" 37 + Port string // e.g. "993" or "143" 38 + User string 39 + Password string 40 + TLS bool // implicit TLS (port 993) 41 + STARTTLS bool // STARTTLS upgrade (port 143) 42 + } 43 + 44 + // Client wraps an IMAP connection with reconnection management. 45 + type Client struct { 46 + cfg Config 47 + logger *slog.Logger 48 + 49 + mu sync.Mutex 50 + conn *imapclient.Client 51 + selectedMailbox string 52 + } 53 + 54 + // New creates a new IMAP client (does not connect yet). 55 + func New(cfg Config) *Client { 56 + return &Client{cfg: cfg, logger: slog.Default()} 57 + } 58 + 59 + func (c *Client) addr() string { 60 + return c.cfg.Host + ":" + c.cfg.Port 61 + } 62 + 63 + // connect establishes and authenticates the connection. Caller must hold mu. 64 + func (c *Client) connect(_ context.Context) error { 65 + if c.conn != nil { 66 + return nil 67 + } 68 + addr := c.addr() 69 + opts := &imapclient.Options{} 70 + var ( 71 + conn *imapclient.Client 72 + err error 73 + ) 74 + switch { 75 + case c.cfg.TLS: 76 + conn, err = imapclient.DialTLS(addr, opts) 77 + case c.cfg.STARTTLS: 78 + conn, err = imapclient.DialStartTLS(addr, opts) 79 + default: 80 + conn, err = imapclient.DialInsecure(addr, opts) 81 + } 82 + if err != nil { 83 + return fmt.Errorf("dial %s: %w", addr, err) 84 + } 85 + if err := conn.Login(c.cfg.User, c.cfg.Password).Wait(); err != nil { 86 + _ = conn.Close() 87 + return fmt.Errorf("IMAP login: %w", err) 88 + } 89 + c.conn = conn 90 + c.selectedMailbox = "" 91 + return nil 92 + } 93 + 94 + func (c *Client) reconnect(ctx context.Context) error { 95 + if c.conn != nil { 96 + _ = c.conn.Close() 97 + c.conn = nil 98 + } 99 + c.selectedMailbox = "" 100 + return c.connect(ctx) 101 + } 102 + 103 + func (c *Client) withConn(ctx context.Context, fn func(*imapclient.Client) error) error { 104 + c.mu.Lock() 105 + defer c.mu.Unlock() 106 + if err := c.connect(ctx); err != nil { 107 + return err 108 + } 109 + if err := fn(c.conn); err != nil { 110 + if isNetErr(err) { 111 + _ = c.conn.Close() 112 + c.conn = nil 113 + c.selectedMailbox = "" 114 + } 115 + return err 116 + } 117 + return nil 118 + } 119 + 120 + func (c *Client) selectMailbox(mailbox string) error { 121 + if c.selectedMailbox == mailbox { 122 + return nil 123 + } 124 + if _, err := c.conn.Select(mailbox, nil).Wait(); err != nil { 125 + return fmt.Errorf("SELECT %q: %w", mailbox, err) 126 + } 127 + c.selectedMailbox = mailbox 128 + return nil 129 + } 130 + 131 + // Close logs out and closes the IMAP connection. 132 + func (c *Client) Close() { 133 + c.mu.Lock() 134 + defer c.mu.Unlock() 135 + if c.conn != nil { 136 + _ = c.conn.Logout().Wait() 137 + _ = c.conn.Close() 138 + c.conn = nil 139 + } 140 + } 141 + 142 + // FetchHeaders fetches the latest n message summaries from folder. 143 + func (c *Client) FetchHeaders(ctx context.Context, folder string, n int) ([]Email, error) { 144 + if ctx == nil { 145 + ctx = context.Background() 146 + } 147 + var emails []Email 148 + err := c.withConn(ctx, func(conn *imapclient.Client) error { 149 + if err := c.selectMailbox(folder); err != nil { 150 + return err 151 + } 152 + 153 + searchData, err := conn.UIDSearch(&imap.SearchCriteria{}, nil).Wait() 154 + if err != nil { 155 + return fmt.Errorf("UID SEARCH: %w", err) 156 + } 157 + 158 + uidSet, ok := searchData.All.(imap.UIDSet) 159 + if !ok { 160 + return nil 161 + } 162 + allUIDs, _ := uidSet.Nums() 163 + if len(allUIDs) == 0 { 164 + return nil 165 + } 166 + 167 + // Take the last n UIDs (most recent) and reverse to newest-first. 168 + sort.Slice(allUIDs, func(i, j int) bool { return allUIDs[i] < allUIDs[j] }) 169 + if len(allUIDs) > n { 170 + allUIDs = allUIDs[len(allUIDs)-n:] 171 + } 172 + for i, j := 0, len(allUIDs)-1; i < j; i, j = i+1, j-1 { 173 + allUIDs[i], allUIDs[j] = allUIDs[j], allUIDs[i] 174 + } 175 + 176 + var fetchSet imap.UIDSet 177 + for _, uid := range allUIDs { 178 + fetchSet.AddNum(uid) 179 + } 180 + 181 + msgs, err := conn.Fetch(fetchSet, &imap.FetchOptions{ 182 + UID: true, 183 + Flags: true, 184 + Envelope: true, 185 + }).Collect() 186 + if err != nil { 187 + return fmt.Errorf("FETCH headers: %w", err) 188 + } 189 + 190 + byUID := make(map[imap.UID]*imapclient.FetchMessageBuffer, len(msgs)) 191 + for _, m := range msgs { 192 + byUID[m.UID] = m 193 + } 194 + 195 + for _, uid := range allUIDs { 196 + m, ok := byUID[uid] 197 + if !ok { 198 + continue 199 + } 200 + e := Email{UID: uint32(m.UID), Folder: folder} 201 + for _, f := range m.Flags { 202 + if f == imap.FlagSeen { 203 + e.Seen = true 204 + } 205 + } 206 + if m.Envelope != nil { 207 + e.Subject = m.Envelope.Subject 208 + e.Date = m.Envelope.Date 209 + if len(m.Envelope.From) > 0 { 210 + a := m.Envelope.From[0] 211 + if a.Name != "" { 212 + e.From = a.Name + " <" + a.Addr() + ">" 213 + } else { 214 + e.From = a.Addr() 215 + } 216 + } 217 + if len(m.Envelope.To) > 0 { 218 + e.To = m.Envelope.To[0].Addr() 219 + } 220 + } 221 + emails = append(emails, e) 222 + } 223 + return nil 224 + }) 225 + return emails, err 226 + } 227 + 228 + // FetchBody fetches and returns the plain-text body of a single message. 229 + func (c *Client) FetchBody(ctx context.Context, folder string, uid uint32) (string, error) { 230 + if ctx == nil { 231 + ctx = context.Background() 232 + } 233 + var body string 234 + err := c.withConn(ctx, func(conn *imapclient.Client) error { 235 + if err := c.selectMailbox(folder); err != nil { 236 + return err 237 + } 238 + 239 + var fetchSet imap.UIDSet 240 + fetchSet.AddNum(imap.UID(uid)) 241 + 242 + msgs, err := conn.Fetch(fetchSet, &imap.FetchOptions{ 243 + UID: true, 244 + BodySection: []*imap.FetchItemBodySection{{Peek: true}}, 245 + }).Collect() 246 + if err != nil { 247 + return fmt.Errorf("FETCH body uid=%d: %w", uid, err) 248 + } 249 + if len(msgs) == 0 { 250 + return fmt.Errorf("message uid=%d not found in %s", uid, folder) 251 + } 252 + 253 + if len(msgs[0].BodySection) > 0 { 254 + body = parsePlainText(msgs[0].BodySection[0].Bytes) 255 + } 256 + return nil 257 + }) 258 + return body, err 259 + } 260 + 261 + // MoveMessage copies uid from src to dst, then deletes it from src. 262 + func (c *Client) MoveMessage(ctx context.Context, src string, uid uint32, dst string) error { 263 + if ctx == nil { 264 + ctx = context.Background() 265 + } 266 + return c.withConn(ctx, func(conn *imapclient.Client) error { 267 + if err := c.selectMailbox(src); err != nil { 268 + return err 269 + } 270 + 271 + var uidSet imap.UIDSet 272 + uidSet.AddNum(imap.UID(uid)) 273 + 274 + if _, err := conn.Copy(uidSet, dst).Wait(); err != nil { 275 + return fmt.Errorf("COPY to %s: %w", dst, err) 276 + } 277 + 278 + if err := conn.Store(uidSet, &imap.StoreFlags{ 279 + Op: imap.StoreFlagsAdd, 280 + Silent: true, 281 + Flags: []imap.Flag{imap.FlagDeleted}, 282 + }, nil).Close(); err != nil { 283 + return fmt.Errorf("STORE \\Deleted: %w", err) 284 + } 285 + 286 + if err := conn.Expunge().Close(); err != nil { 287 + return fmt.Errorf("EXPUNGE: %w", err) 288 + } 289 + c.selectedMailbox = "" // state changed after EXPUNGE 290 + return nil 291 + }) 292 + } 293 + 294 + // MarkSeen marks a message as \Seen. 295 + func (c *Client) MarkSeen(ctx context.Context, folder string, uid uint32) error { 296 + if ctx == nil { 297 + ctx = context.Background() 298 + } 299 + return c.withConn(ctx, func(conn *imapclient.Client) error { 300 + if err := c.selectMailbox(folder); err != nil { 301 + return err 302 + } 303 + var uidSet imap.UIDSet 304 + uidSet.AddNum(imap.UID(uid)) 305 + return conn.Store(uidSet, &imap.StoreFlags{ 306 + Op: imap.StoreFlagsAdd, 307 + Flags: []imap.Flag{imap.FlagSeen}, 308 + }, nil).Close() 309 + }) 310 + } 311 + 312 + // parsePlainText extracts the best available plain text from a raw MIME message. 313 + func parsePlainText(raw []byte) string { 314 + e, err := message.Read(bytes.NewReader(raw)) 315 + if err != nil && !message.IsUnknownCharset(err) { 316 + return string(raw) 317 + } 318 + 319 + mr := mail.NewReader(e) 320 + var plainText, htmlText string 321 + 322 + for { 323 + p, err := mr.NextPart() 324 + if err == io.EOF { 325 + break 326 + } 327 + if err != nil { 328 + if !message.IsUnknownCharset(err) { 329 + break 330 + } 331 + if p == nil { 332 + continue 333 + } 334 + } 335 + 336 + var ct string 337 + switch h := p.Header.(type) { 338 + case *mail.InlineHeader: 339 + ct, _, _ = h.ContentType() 340 + case *mail.AttachmentHeader: 341 + ct, _, _ = h.ContentType() 342 + } 343 + 344 + body, _ := io.ReadAll(p.Body) 345 + switch ct { 346 + case "text/plain": 347 + if plainText == "" { 348 + plainText = string(body) 349 + } 350 + case "text/html": 351 + if htmlText == "" { 352 + htmlText = string(body) 353 + } 354 + } 355 + } 356 + 357 + if plainText != "" { 358 + return plainText 359 + } 360 + if htmlText != "" { 361 + return stripHTML(htmlText) 362 + } 363 + return "(no body)" 364 + } 365 + 366 + // stripHTML removes HTML tags, leaving readable plain text. 367 + func stripHTML(h string) string { 368 + reBlock := regexp.MustCompile(`(?is)<(style|script)[^>]*>.*?</(style|script)>`) 369 + h = reBlock.ReplaceAllString(h, "") 370 + reNewline := regexp.MustCompile(`(?i)</(p|div|br|li|tr|h[1-6]|blockquote)>`) 371 + h = reNewline.ReplaceAllString(h, "\n") 372 + reTags := regexp.MustCompile(`<[^>]+>`) 373 + h = reTags.ReplaceAllString(h, "") 374 + lines := strings.Split(h, "\n") 375 + var out []string 376 + for _, l := range lines { 377 + l = strings.TrimSpace(l) 378 + out = append(out, l) 379 + } 380 + return strings.TrimSpace(strings.Join(out, "\n")) 381 + } 382 + 383 + func isNetErr(err error) bool { 384 + if err == nil { 385 + return false 386 + } 387 + s := err.Error() 388 + return strings.Contains(s, "use of closed network connection") || 389 + strings.Contains(s, "connection reset by peer") || 390 + strings.Contains(s, "broken pipe") || 391 + strings.Contains(s, "EOF") 392 + }
+59
internal/render/html.go
··· 1 + package render 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + 7 + "github.com/yuin/goldmark" 8 + "github.com/yuin/goldmark/extension" 9 + "github.com/yuin/goldmark/renderer/html" 10 + ) 11 + 12 + // htmlTemplate is a minimal, self-contained email wrapper. 13 + // Derived from the listmonk template at: 14 + // /home/sspaeti/git/sspaeti.com/listmonk/misc/email-template.html 15 + const htmlTemplate = `<!DOCTYPE html> 16 + <html> 17 + <head> 18 + <meta charset="UTF-8"> 19 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 20 + <style> 21 + body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; 22 + line-height:1.6;color:#333;margin:0;padding:0} 23 + .w{max-width:650px;margin:0 auto;padding:20px} 24 + a{color:#ff5d62;text-decoration:none} 25 + a:hover{text-decoration:underline} 26 + hr{border:0;border-bottom:1px solid #eaeaea;margin:25px 0} 27 + h1{font-size:24px;color:#24292e;margin-top:1.5em;margin-bottom:.5em;line-height:1.3} 28 + h2{font-size:20px;color:#24292e;margin-top:1.5em;margin-bottom:.5em;line-height:1.3} 29 + h3{font-size:18px;color:#24292e;margin-top:1.5em;margin-bottom:.5em;line-height:1.3} 30 + p,ul,li{font-size:16px;margin-bottom:1em} 31 + code{background:#f6f8fa;padding:2px 5px;border-radius:3px; 32 + font-family:SFMono-Regular,Consolas,"Liberation Mono",Menlo,monospace;font-size:85%%} 33 + pre{background:#f6f8fa;padding:16px;border-radius:6px;overflow:auto; 34 + font-family:SFMono-Regular,Consolas,"Liberation Mono",Menlo,monospace;font-size:85%%;line-height:1.45} 35 + blockquote{border-left:3px solid #e1e4e8;color:#6a737d;margin-left:0;padding-left:1em} 36 + img{max-width:100%%;height:auto} 37 + </style> 38 + </head> 39 + <body> 40 + <div class="w"> 41 + %s 42 + </div> 43 + </body> 44 + </html>` 45 + 46 + // md is the goldmark renderer with GFM extensions. 47 + var md = goldmark.New( 48 + goldmark.WithExtensions(extension.GFM), 49 + goldmark.WithRendererOptions(html.WithHardWraps()), 50 + ) 51 + 52 + // ToHTML converts a Markdown string to a complete HTML email document. 53 + func ToHTML(markdown string) (string, error) { 54 + var fragment bytes.Buffer 55 + if err := md.Convert([]byte(markdown), &fragment); err != nil { 56 + return "", fmt.Errorf("markdown to html: %w", err) 57 + } 58 + return fmt.Sprintf(htmlTemplate, fragment.String()), nil 59 + }
+23
internal/render/markdown.go
··· 1 + // Package render handles Markdown rendering for display and email sending. 2 + package render 3 + 4 + import ( 5 + "github.com/charmbracelet/glamour" 6 + ) 7 + 8 + // ToANSI renders markdown as ANSI-styled terminal output for the reader view. 9 + // theme should be "dark", "light", or "auto". 10 + func ToANSI(markdown, theme string) (string, error) { 11 + if theme == "" { 12 + theme = "dark" 13 + } 14 + r, err := glamour.NewTermRenderer( 15 + glamour.WithStylePath(theme), 16 + glamour.WithWordWrap(100), 17 + ) 18 + if err != nil { 19 + // Fall back to notty (no styling) 20 + return glamour.Render(markdown, "notty") 21 + } 22 + return r.Render(markdown) 23 + }
+173
internal/screener/screener.go
··· 1 + // Package screener classifies email senders into inbox categories, 2 + // mirroring the HEY-style screener used in the neomutt setup. 3 + // It reads/writes the same plain-text allowlist files used by the 4 + // existing notmuch_screening.sh and initial_screening.sh scripts. 5 + package screener 6 + 7 + import ( 8 + "bufio" 9 + "fmt" 10 + "os" 11 + "strings" 12 + ) 13 + 14 + // Category is the inbox bucket for a sender. 15 + type Category int 16 + 17 + const ( 18 + CategoryToScreen Category = iota // unknown — awaiting decision 19 + CategoryInbox // approved sender 20 + CategoryScreenedOut // blocked 21 + CategoryFeed // newsletter / feed 22 + CategoryPaperTrail // receipts / notifications 23 + ) 24 + 25 + func (c Category) String() string { 26 + switch c { 27 + case CategoryInbox: 28 + return "Inbox" 29 + case CategoryScreenedOut: 30 + return "ScreenedOut" 31 + case CategoryFeed: 32 + return "Feed" 33 + case CategoryPaperTrail: 34 + return "PaperTrail" 35 + default: 36 + return "ToScreen" 37 + } 38 + } 39 + 40 + // Config maps each category to its list file path. 41 + type Config struct { 42 + ScreenedIn string 43 + ScreenedOut string 44 + Feed string 45 + PaperTrail string 46 + } 47 + 48 + // Screener holds loaded allowlists in memory for fast classification. 49 + type Screener struct { 50 + cfg Config 51 + screenedIn map[string]bool 52 + screenedOut map[string]bool 53 + feed map[string]bool 54 + paperTrail map[string]bool 55 + } 56 + 57 + // New loads all four lists from the paths in cfg. 58 + // Missing files are silently skipped (treated as empty). 59 + func New(cfg Config) (*Screener, error) { 60 + s := &Screener{ 61 + cfg: cfg, 62 + screenedIn: make(map[string]bool), 63 + screenedOut: make(map[string]bool), 64 + feed: make(map[string]bool), 65 + paperTrail: make(map[string]bool), 66 + } 67 + for path, m := range map[string]map[string]bool{ 68 + cfg.ScreenedIn: s.screenedIn, 69 + cfg.ScreenedOut: s.screenedOut, 70 + cfg.Feed: s.feed, 71 + cfg.PaperTrail: s.paperTrail, 72 + } { 73 + if err := loadList(path, m); err != nil { 74 + return nil, fmt.Errorf("load screener list %s: %w", path, err) 75 + } 76 + } 77 + return s, nil 78 + } 79 + 80 + // Classify returns the category for a given "from" email address. 81 + // The address is normalised to lowercase before matching. 82 + func (s *Screener) Classify(from string) Category { 83 + addr := normalise(from) 84 + switch { 85 + case s.screenedIn[addr]: 86 + return CategoryInbox 87 + case s.screenedOut[addr]: 88 + return CategoryScreenedOut 89 + case s.feed[addr]: 90 + return CategoryFeed 91 + case s.paperTrail[addr]: 92 + return CategoryPaperTrail 93 + default: 94 + return CategoryToScreen 95 + } 96 + } 97 + 98 + // Approve adds addr to screened_in.txt and updates the in-memory set. 99 + func (s *Screener) Approve(from string) error { 100 + return s.addToList(s.cfg.ScreenedIn, s.screenedIn, from) 101 + } 102 + 103 + // Block adds addr to screened_out.txt and updates the in-memory set. 104 + func (s *Screener) Block(from string) error { 105 + return s.addToList(s.cfg.ScreenedOut, s.screenedOut, from) 106 + } 107 + 108 + // MarkFeed adds addr to feed.txt and updates the in-memory set. 109 + func (s *Screener) MarkFeed(from string) error { 110 + return s.addToList(s.cfg.Feed, s.feed, from) 111 + } 112 + 113 + // MarkPaperTrail adds addr to papertrail.txt and updates the in-memory set. 114 + func (s *Screener) MarkPaperTrail(from string) error { 115 + return s.addToList(s.cfg.PaperTrail, s.paperTrail, from) 116 + } 117 + 118 + func (s *Screener) addToList(path string, m map[string]bool, from string) error { 119 + addr := normalise(from) 120 + if m[addr] { 121 + return nil // already present 122 + } 123 + if err := appendLine(path, addr); err != nil { 124 + return err 125 + } 126 + m[addr] = true 127 + return nil 128 + } 129 + 130 + // loadList reads a one-address-per-line file into a set. 131 + func loadList(path string, m map[string]bool) error { 132 + f, err := os.Open(path) 133 + if os.IsNotExist(err) { 134 + return nil 135 + } 136 + if err != nil { 137 + return err 138 + } 139 + defer f.Close() 140 + sc := bufio.NewScanner(f) 141 + for sc.Scan() { 142 + line := strings.TrimSpace(sc.Text()) 143 + if line == "" || strings.HasPrefix(line, "#") { 144 + continue 145 + } 146 + m[strings.ToLower(line)] = true 147 + } 148 + return sc.Err() 149 + } 150 + 151 + // appendLine appends a single line to path, creating the file if needed. 152 + func appendLine(path, line string) error { 153 + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) 154 + if err != nil { 155 + return fmt.Errorf("open %s: %w", path, err) 156 + } 157 + defer f.Close() 158 + _, err = fmt.Fprintln(f, line) 159 + return err 160 + } 161 + 162 + // normalise extracts the email address from a From header value and 163 + // lowercases it. Handles "Name <addr>" and bare "addr" forms. 164 + func normalise(from string) string { 165 + from = strings.TrimSpace(from) 166 + if i := strings.IndexByte(from, '<'); i >= 0 { 167 + j := strings.IndexByte(from, '>') 168 + if j > i { 169 + from = from[i+1 : j] 170 + } 171 + } 172 + return strings.ToLower(strings.TrimSpace(from)) 173 + }
+209
internal/smtp/sender.go
··· 1 + // Package smtp handles outgoing email via SMTP. 2 + // Sends multipart/alternative (text/plain + text/html) so recipients 3 + // get clickable links and formatted output while you write pure Markdown. 4 + package smtp 5 + 6 + import ( 7 + "bytes" 8 + "crypto/rand" 9 + "crypto/tls" 10 + "encoding/hex" 11 + "fmt" 12 + "mime" 13 + "net" 14 + "net/smtp" 15 + "strings" 16 + "time" 17 + 18 + "github.com/sspaeti/neomd/internal/render" 19 + ) 20 + 21 + // Config holds outgoing mail settings. 22 + type Config struct { 23 + Host string // e.g. "smtp.example.com" 24 + Port string // e.g. "587" (STARTTLS) or "465" (TLS) 25 + User string 26 + Password string 27 + From string // "Name <email>" 28 + } 29 + 30 + // Send composes and sends an email. 31 + // markdownBody is sent as text/plain (raw) and text/html (goldmark-rendered). 32 + func Send(cfg Config, to, subject, markdownBody string) error { 33 + htmlBody, err := render.ToHTML(markdownBody) 34 + if err != nil { 35 + return fmt.Errorf("markdown to html: %w", err) 36 + } 37 + 38 + raw, err := buildMessage(cfg.From, to, subject, markdownBody, htmlBody) 39 + if err != nil { 40 + return fmt.Errorf("build message: %w", err) 41 + } 42 + 43 + toAddrs := []string{extractAddr(to)} 44 + fromAddr := extractAddr(cfg.From) 45 + 46 + addr := cfg.Host + ":" + cfg.Port 47 + switch cfg.Port { 48 + case "465": // Implicit TLS (SMTPS) 49 + return sendTLS(addr, cfg.Host, cfg.User, cfg.Password, fromAddr, toAddrs, raw) 50 + default: // STARTTLS (587) or plain (25) 51 + return sendSTARTTLS(addr, cfg.Host, cfg.User, cfg.Password, fromAddr, toAddrs, raw) 52 + } 53 + } 54 + 55 + // sendSTARTTLS sends via STARTTLS upgrade (port 587). 56 + func sendSTARTTLS(addr, host, user, password, from string, to []string, msg []byte) error { 57 + auth := smtp.PlainAuth("", user, password, host) 58 + return smtp.SendMail(addr, auth, from, to, msg) 59 + } 60 + 61 + // sendTLS sends via implicit TLS (port 465 / SMTPS). 62 + func sendTLS(addr, host, user, password, from string, to []string, msg []byte) error { 63 + tlsCfg := &tls.Config{ServerName: host} 64 + conn, err := tls.Dial("tcp", addr, tlsCfg) 65 + if err != nil { 66 + return fmt.Errorf("TLS dial %s: %w", addr, err) 67 + } 68 + 69 + c, err := smtp.NewClient(conn, host) 70 + if err != nil { 71 + return fmt.Errorf("SMTP new client: %w", err) 72 + } 73 + defer c.Close() 74 + 75 + auth := smtp.PlainAuth("", user, password, host) 76 + if err := c.Auth(auth); err != nil { 77 + return fmt.Errorf("SMTP auth: %w", err) 78 + } 79 + if err := c.Mail(from); err != nil { 80 + return fmt.Errorf("SMTP MAIL FROM: %w", err) 81 + } 82 + for _, r := range to { 83 + if err := c.Rcpt(r); err != nil { 84 + return fmt.Errorf("SMTP RCPT TO %s: %w", r, err) 85 + } 86 + } 87 + w, err := c.Data() 88 + if err != nil { 89 + return fmt.Errorf("SMTP DATA: %w", err) 90 + } 91 + if _, err := w.Write(msg); err != nil { 92 + return fmt.Errorf("write message: %w", err) 93 + } 94 + return w.Close() 95 + } 96 + 97 + // buildMessage constructs a multipart/alternative MIME message. 98 + func buildMessage(from, to, subject, plainText, htmlBody string) ([]byte, error) { 99 + boundary, err := randomBoundary() 100 + if err != nil { 101 + return nil, err 102 + } 103 + msgID, err := randomMsgID() 104 + if err != nil { 105 + return nil, err 106 + } 107 + 108 + var b bytes.Buffer 109 + 110 + // Headers 111 + hdr := func(k, v string) { fmt.Fprintf(&b, "%s: %s\r\n", k, v) } 112 + hdr("From", from) 113 + hdr("To", to) 114 + hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) 115 + hdr("Date", time.Now().Format(time.RFC1123Z)) 116 + hdr("Message-ID", "<"+msgID+"@neomd>") 117 + hdr("MIME-Version", "1.0") 118 + hdr("Content-Type", `multipart/alternative; boundary="`+boundary+`"`) 119 + hdr("X-Mailer", "neomd") 120 + b.WriteString("\r\n") 121 + 122 + // text/plain part (raw markdown — readable as-is in any client) 123 + fmt.Fprintf(&b, "--%s\r\n", boundary) 124 + b.WriteString("Content-Type: text/plain; charset=utf-8\r\n") 125 + b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") 126 + b.WriteString("\r\n") 127 + writeQP(&b, plainText) 128 + b.WriteString("\r\n") 129 + 130 + // text/html part (goldmark rendered) 131 + fmt.Fprintf(&b, "--%s\r\n", boundary) 132 + b.WriteString("Content-Type: text/html; charset=utf-8\r\n") 133 + b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") 134 + b.WriteString("\r\n") 135 + writeQP(&b, htmlBody) 136 + b.WriteString("\r\n") 137 + 138 + // Closing boundary 139 + fmt.Fprintf(&b, "--%s--\r\n", boundary) 140 + 141 + return b.Bytes(), nil 142 + } 143 + 144 + // writeQP writes s as simplified quoted-printable (ASCII passthrough, 145 + // encodes only non-ASCII and special chars). Good enough for UTF-8 prose. 146 + func writeQP(b *bytes.Buffer, s string) { 147 + lineLen := 0 148 + for i := 0; i < len(s); i++ { 149 + c := s[i] 150 + if c == '\n' { 151 + b.WriteString("\r\n") 152 + lineLen = 0 153 + continue 154 + } 155 + if c == '\r' { 156 + continue // CRLF handled above 157 + } 158 + if (c >= 33 && c <= 126 && c != '=') || c == '\t' || c == ' ' { 159 + if lineLen >= 75 { 160 + b.WriteString("=\r\n") 161 + lineLen = 0 162 + } 163 + b.WriteByte(c) 164 + lineLen++ 165 + } else { 166 + enc := fmt.Sprintf("=%02X", c) 167 + if lineLen+3 > 75 { 168 + b.WriteString("=\r\n") 169 + lineLen = 0 170 + } 171 + b.WriteString(enc) 172 + lineLen += 3 173 + } 174 + } 175 + } 176 + 177 + func randomBoundary() (string, error) { 178 + b := make([]byte, 16) 179 + if _, err := rand.Read(b); err != nil { 180 + return "", err 181 + } 182 + return "neomd-" + hex.EncodeToString(b), nil 183 + } 184 + 185 + func randomMsgID() (string, error) { 186 + b := make([]byte, 12) 187 + if _, err := rand.Read(b); err != nil { 188 + return "", err 189 + } 190 + return hex.EncodeToString(b), nil 191 + } 192 + 193 + // extractAddr pulls the bare email address from "Name <addr>" or "addr". 194 + func extractAddr(s string) string { 195 + s = strings.TrimSpace(s) 196 + if i := strings.IndexByte(s, '<'); i >= 0 { 197 + j := strings.IndexByte(s, '>') 198 + if j > i { 199 + h, _, _ := strings.Cut(s[i+1:j], "@") 200 + _ = h 201 + // validate it looks like an address 202 + addr := s[i+1 : j] 203 + if _, err := net.LookupHost(strings.SplitN(addr, "@", 2)[len(strings.SplitN(addr, "@", 2))-1]); err == nil || strings.Contains(addr, "@") { 204 + return addr 205 + } 206 + } 207 + } 208 + return s 209 + }
+93
internal/ui/compose.go
··· 1 + package ui 2 + 3 + import ( 4 + "github.com/charmbracelet/bubbles/textinput" 5 + tea "github.com/charmbracelet/bubbletea" 6 + ) 7 + 8 + // composeStep tracks which field is active in the compose form. 9 + type composeStep int 10 + 11 + const ( 12 + stepTo composeStep = iota 13 + stepSubject // after Subject is filled, launch editor 14 + ) 15 + 16 + // composeModel holds state for the compose view. 17 + type composeModel struct { 18 + to textinput.Model 19 + subject textinput.Model 20 + step composeStep 21 + } 22 + 23 + func newComposeModel() composeModel { 24 + to := textinput.New() 25 + to.Placeholder = "recipient@example.com" 26 + to.Focus() 27 + to.CharLimit = 256 28 + to.Width = 60 29 + to.Prompt = "" 30 + 31 + sub := textinput.New() 32 + sub.Placeholder = "Subject" 33 + sub.CharLimit = 256 34 + sub.Width = 60 35 + sub.Prompt = "" 36 + 37 + return composeModel{to: to, subject: sub, step: stepTo} 38 + } 39 + 40 + // reset clears both fields and refocuses on To. 41 + func (c *composeModel) reset() { 42 + c.to.Reset() 43 + c.subject.Reset() 44 + c.step = stepTo 45 + c.to.Focus() 46 + c.subject.Blur() 47 + } 48 + 49 + // update handles key input for the compose form. 50 + // Returns (updated model, cmd, launchEditor bool). 51 + func (c composeModel) update(msg tea.Msg) (composeModel, tea.Cmd, bool) { 52 + switch msg := msg.(type) { 53 + case tea.KeyMsg: 54 + switch msg.String() { 55 + case "tab", "enter": 56 + if c.step == stepTo { 57 + c.step = stepSubject 58 + c.to.Blur() 59 + c.subject.Focus() 60 + return c, nil, false 61 + } 62 + if c.step == stepSubject { 63 + // Signal to model to launch editor 64 + return c, nil, true 65 + } 66 + } 67 + } 68 + 69 + var cmd tea.Cmd 70 + if c.step == stepTo { 71 + c.to, cmd = c.to.Update(msg) 72 + } else { 73 + c.subject, cmd = c.subject.Update(msg) 74 + } 75 + return c, cmd, false 76 + } 77 + 78 + // view renders the compose header form. 79 + func (c composeModel) view() string { 80 + toLabel := styleInputLabel.Render("To:") 81 + subLabel := styleInputLabel.Render("Subject:") 82 + 83 + toField := c.to.View() 84 + subField := c.subject.View() 85 + 86 + if c.step == stepTo { 87 + toField = styleInputField.Render(toField) 88 + } else { 89 + subField = styleInputField.Render(subField) 90 + } 91 + 92 + return toLabel + toField + "\n" + subLabel + subField 93 + }
+141
internal/ui/inbox.go
··· 1 + package ui 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "strings" 7 + "time" 8 + 9 + "github.com/charmbracelet/bubbles/list" 10 + tea "github.com/charmbracelet/bubbletea" 11 + "github.com/charmbracelet/lipgloss" 12 + "github.com/sspaeti/neomd/internal/imap" 13 + ) 14 + 15 + // emailItem wraps imap.Email to satisfy bubbles/list.Item. 16 + type emailItem struct { 17 + email imap.Email 18 + } 19 + 20 + func (e emailItem) FilterValue() string { 21 + return e.email.From + " " + e.email.Subject 22 + } 23 + 24 + func (e emailItem) Title() string { return e.email.Subject } 25 + func (e emailItem) Description() string { return e.email.From } 26 + 27 + // emailDelegate is a custom list.ItemDelegate that renders one email per row. 28 + type emailDelegate struct{} 29 + 30 + func (d emailDelegate) Height() int { return 1 } 31 + func (d emailDelegate) Spacing() int { return 0 } 32 + func (d emailDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 33 + 34 + func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { 35 + e, ok := item.(emailItem) 36 + if !ok { 37 + return 38 + } 39 + 40 + isSelected := index == m.Index() 41 + 42 + // Unread indicator 43 + indicator := " " 44 + fromStyle := styleRead 45 + if !e.email.Seen { 46 + indicator = "● " 47 + fromStyle = styleUnread 48 + } 49 + 50 + // Truncate from and subject to fit terminal width 51 + width := m.Width() 52 + if width <= 0 { 53 + width = 80 54 + } 55 + dateStr := fmtDate(e.email.Date) 56 + dateWidth := len(dateStr) + 2 57 + fromMax := 25 58 + subjectMax := width - fromMax - dateWidth - 6 59 + if subjectMax < 10 { 60 + subjectMax = 10 61 + } 62 + 63 + from := truncate(e.email.From, fromMax) 64 + subject := truncate(e.email.Subject, subjectMax) 65 + 66 + row := fmt.Sprintf("%s%-*s %-*s %s", 67 + indicator, 68 + fromMax, from, 69 + subjectMax, subject, 70 + dateStr, 71 + ) 72 + 73 + if isSelected { 74 + row = styleSelected.Render(row) 75 + } else { 76 + // Apply from style to the whole line (unread = brighter) 77 + _ = fromStyle // style applied via indicator colour above 78 + row = lipgloss.NewStyle().Foreground(colorText).Render(row) 79 + if !e.email.Seen { 80 + row = lipgloss.NewStyle().Foreground(colorUnread).Bold(true).Render(row) 81 + } 82 + } 83 + 84 + fmt.Fprint(w, row) 85 + } 86 + 87 + func fmtDate(t time.Time) string { 88 + if t.IsZero() { 89 + return "—" 90 + } 91 + now := time.Now() 92 + if t.Year() == now.Year() && t.YearDay() == now.YearDay() { 93 + return t.Format("15:04") 94 + } 95 + if t.Year() == now.Year() { 96 + return t.Format("Jan 02") 97 + } 98 + return t.Format("2006") 99 + } 100 + 101 + func truncate(s string, max int) string { 102 + s = strings.TrimSpace(s) 103 + if len(s) <= max { 104 + return s 105 + } 106 + if max <= 1 { 107 + return "…" 108 + } 109 + return s[:max-1] + "…" 110 + } 111 + 112 + // newInboxList creates a bubbles/list configured for the email inbox. 113 + func newInboxList(width, height int) list.Model { 114 + l := list.New(nil, emailDelegate{}, width, height) 115 + l.SetShowTitle(false) 116 + l.SetShowStatusBar(false) 117 + l.SetShowHelp(false) 118 + l.SetFilteringEnabled(true) 119 + l.DisableQuitKeybindings() 120 + l.Styles.NoItems = styleStatus 121 + return l 122 + } 123 + 124 + // setEmails replaces the list contents. 125 + func setEmails(l *list.Model, emails []imap.Email) tea.Cmd { 126 + items := make([]list.Item, len(emails)) 127 + for i, e := range emails { 128 + items[i] = emailItem{email: e} 129 + } 130 + return l.SetItems(items) 131 + } 132 + 133 + // selectedEmail returns the currently highlighted email, or nil. 134 + func selectedEmail(l list.Model) *imap.Email { 135 + item, ok := l.SelectedItem().(emailItem) 136 + if !ok { 137 + return nil 138 + } 139 + e := item.email 140 + return &e 141 + }
+469
internal/ui/model.go
··· 1 + // Package ui contains the bubbletea TUI model for neomd. 2 + package ui 3 + 4 + import ( 5 + "fmt" 6 + "os" 7 + "os/exec" 8 + "strings" 9 + 10 + "github.com/charmbracelet/bubbles/list" 11 + "github.com/charmbracelet/bubbles/spinner" 12 + "github.com/charmbracelet/bubbles/viewport" 13 + tea "github.com/charmbracelet/bubbletea" 14 + "github.com/sspaeti/neomd/internal/config" 15 + "github.com/sspaeti/neomd/internal/editor" 16 + "github.com/sspaeti/neomd/internal/imap" 17 + "github.com/sspaeti/neomd/internal/screener" 18 + "github.com/sspaeti/neomd/internal/smtp" 19 + ) 20 + 21 + // viewState is the current screen. 22 + type viewState int 23 + 24 + const ( 25 + stateInbox viewState = iota 26 + stateReading // reading a single email 27 + stateCompose // composing a new email 28 + ) 29 + 30 + // async message types 31 + type ( 32 + emailsLoadedMsg struct { 33 + emails []imap.Email 34 + folder string 35 + } 36 + bodyLoadedMsg struct { 37 + email *imap.Email 38 + body string 39 + } 40 + sendDoneMsg struct{ err error } 41 + screenDoneMsg struct{ err error } 42 + errMsg struct{ err error } 43 + editorDoneMsg struct { 44 + to, subject, body string 45 + err error 46 + } 47 + ) 48 + 49 + // Model is the root bubbletea model. 50 + type Model struct { 51 + cfg *config.Config 52 + imapCli *imap.Client 53 + screener *screener.Screener 54 + 55 + state viewState 56 + width int 57 + height int 58 + loading bool 59 + 60 + // Folder switcher 61 + folders []string 62 + activeFolderI int 63 + 64 + // Inbox 65 + inbox list.Model 66 + emails []imap.Email 67 + spinner spinner.Model 68 + 69 + // Reader 70 + reader viewport.Model 71 + openEmail *imap.Email 72 + 73 + // Compose 74 + compose composeModel 75 + 76 + // Status / error 77 + status string 78 + isError bool 79 + } 80 + 81 + // New creates and initialises the TUI model. 82 + func New(cfg *config.Config, imapCli *imap.Client, sc *screener.Screener) Model { 83 + sp := spinner.New() 84 + sp.Spinner = spinner.Dot 85 + 86 + return Model{ 87 + cfg: cfg, 88 + imapCli: imapCli, 89 + screener: sc, 90 + state: stateInbox, 91 + loading: true, 92 + folders: []string{"Inbox", "ToScreen", "Feed", "PaperTrail"}, 93 + compose: newComposeModel(), 94 + spinner: sp, 95 + } 96 + } 97 + 98 + func (m Model) Init() tea.Cmd { 99 + return tea.Batch( 100 + m.spinner.Tick, 101 + m.fetchFolderCmd(m.activeFolder()), 102 + ) 103 + } 104 + 105 + // activeFolder maps the active tab label to an IMAP mailbox name. 106 + func (m Model) activeFolder() string { 107 + switch m.folders[m.activeFolderI] { 108 + case "ToScreen": 109 + return m.cfg.Folders.ToScreen 110 + case "Feed": 111 + return m.cfg.Folders.Feed 112 + case "PaperTrail": 113 + return m.cfg.Folders.PaperTrail 114 + default: 115 + return m.cfg.Folders.Inbox 116 + } 117 + } 118 + 119 + // ── Commands ───────────────────────────────────────────────────────────── 120 + 121 + func (m Model) fetchFolderCmd(folder string) tea.Cmd { 122 + return func() tea.Msg { 123 + emails, err := m.imapCli.FetchHeaders(nil, folder, m.cfg.UI.InboxCount) 124 + if err != nil { 125 + return errMsg{err} 126 + } 127 + return emailsLoadedMsg{emails: emails, folder: folder} 128 + } 129 + } 130 + 131 + func (m Model) fetchBodyCmd(e *imap.Email) tea.Cmd { 132 + return func() tea.Msg { 133 + body, err := m.imapCli.FetchBody(nil, e.Folder, e.UID) 134 + if err != nil { 135 + return errMsg{err} 136 + } 137 + return bodyLoadedMsg{email: e, body: body} 138 + } 139 + } 140 + 141 + func (m Model) sendEmailCmd(to, subject, body string) tea.Cmd { 142 + h, p := splitAddr(m.cfg.Account.SMTP) 143 + cfg := smtp.Config{ 144 + Host: h, 145 + Port: p, 146 + User: m.cfg.Account.User, 147 + Password: m.cfg.Account.Password, 148 + From: m.cfg.Account.From, 149 + } 150 + return func() tea.Msg { 151 + return sendDoneMsg{smtp.Send(cfg, to, subject, body)} 152 + } 153 + } 154 + 155 + func (m Model) screenerCmd(e *imap.Email, action string) tea.Cmd { 156 + folder := m.activeFolder() 157 + return func() tea.Msg { 158 + var dst string 159 + var addErr error 160 + switch action { 161 + case "I": 162 + addErr = m.screener.Approve(e.From) 163 + dst = m.cfg.Folders.Inbox 164 + case "O": 165 + addErr = m.screener.Block(e.From) 166 + dst = m.cfg.Folders.ScreenedOut 167 + case "F": 168 + addErr = m.screener.MarkFeed(e.From) 169 + dst = m.cfg.Folders.Feed 170 + case "P": 171 + addErr = m.screener.MarkPaperTrail(e.From) 172 + dst = m.cfg.Folders.PaperTrail 173 + } 174 + if addErr != nil { 175 + return errMsg{addErr} 176 + } 177 + if dst != "" && dst != folder { 178 + if err := m.imapCli.MoveMessage(nil, folder, e.UID, dst); err != nil { 179 + return errMsg{err} 180 + } 181 + } 182 + return screenDoneMsg{} 183 + } 184 + } 185 + 186 + // ── Update ──────────────────────────────────────────────────────────────── 187 + 188 + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 189 + switch msg := msg.(type) { 190 + 191 + case tea.WindowSizeMsg: 192 + m.width = msg.Width 193 + m.height = msg.Height 194 + listH := msg.Height - 4 195 + if listH < 5 { 196 + listH = 5 197 + } 198 + if m.inbox.Width() == 0 { 199 + m.inbox = newInboxList(msg.Width, listH) 200 + } else { 201 + m.inbox.SetSize(msg.Width, listH) 202 + } 203 + m.reader = newReader(msg.Width, msg.Height-3) 204 + return m, nil 205 + 206 + case spinner.TickMsg: 207 + var cmd tea.Cmd 208 + m.spinner, cmd = m.spinner.Update(msg) 209 + return m, cmd 210 + 211 + case emailsLoadedMsg: 212 + m.loading = false 213 + m.emails = msg.emails 214 + cmd := setEmails(&m.inbox, msg.emails) 215 + return m, cmd 216 + 217 + case bodyLoadedMsg: 218 + m.loading = false 219 + m.openEmail = msg.email 220 + _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, m.cfg.UI.Theme, m.width) 221 + m.state = stateReading 222 + // Mark as seen in background (best-effort) 223 + uid := msg.email.UID 224 + folder := msg.email.Folder 225 + go func() { _ = m.imapCli.MarkSeen(nil, folder, uid) }() 226 + return m, nil 227 + 228 + case sendDoneMsg: 229 + m.loading = false 230 + if msg.err != nil { 231 + m.status = msg.err.Error() 232 + m.isError = true 233 + } else { 234 + m.status = "Sent!" 235 + m.isError = false 236 + m.state = stateInbox 237 + } 238 + return m, nil 239 + 240 + case screenDoneMsg: 241 + m.loading = false 242 + if msg.err != nil { 243 + m.status = msg.err.Error() 244 + m.isError = true 245 + return m, nil 246 + } 247 + m.status = "Done." 248 + m.isError = false 249 + m.loading = true 250 + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 251 + 252 + case errMsg: 253 + m.loading = false 254 + m.status = msg.err.Error() 255 + m.isError = true 256 + return m, nil 257 + 258 + case editorDoneMsg: 259 + if msg.err != nil { 260 + m.status = msg.err.Error() 261 + m.isError = true 262 + m.state = stateInbox 263 + return m, nil 264 + } 265 + if strings.TrimSpace(msg.body) == "" { 266 + m.status = "Cancelled (empty body)." 267 + m.state = stateInbox 268 + return m, nil 269 + } 270 + m.loading = true 271 + return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(msg.to, msg.subject, msg.body)) 272 + 273 + case tea.KeyMsg: 274 + switch m.state { 275 + case stateInbox: 276 + return m.updateInbox(msg) 277 + case stateReading: 278 + return m.updateReader(msg) 279 + case stateCompose: 280 + return m.updateCompose(msg) 281 + } 282 + } 283 + 284 + return m, nil 285 + } 286 + 287 + func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 288 + m.status = "" 289 + m.isError = false 290 + 291 + switch msg.String() { 292 + case "ctrl+c", "q": 293 + return m, tea.Quit 294 + 295 + case "tab": 296 + m.activeFolderI = (m.activeFolderI + 1) % len(m.folders) 297 + m.loading = true 298 + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 299 + 300 + case "c": 301 + m.state = stateCompose 302 + m.compose.reset() 303 + return m, nil 304 + 305 + case "r": 306 + m.loading = true 307 + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 308 + 309 + case "enter": 310 + e := selectedEmail(m.inbox) 311 + if e == nil { 312 + return m, nil 313 + } 314 + m.loading = true 315 + return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) 316 + 317 + case "I", "O", "F", "P": 318 + if m.folders[m.activeFolderI] != "ToScreen" { 319 + break 320 + } 321 + e := selectedEmail(m.inbox) 322 + if e == nil { 323 + return m, nil 324 + } 325 + m.loading = true 326 + return m, tea.Batch(m.spinner.Tick, m.screenerCmd(e, msg.String())) 327 + } 328 + 329 + // Forward remaining keys (j/k navigation, filter /) to list 330 + var cmd tea.Cmd 331 + m.inbox, cmd = m.inbox.Update(msg) 332 + return m, cmd 333 + } 334 + 335 + func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 336 + switch msg.String() { 337 + case "q", "esc": 338 + m.state = stateInbox 339 + return m, nil 340 + } 341 + var cmd tea.Cmd 342 + m.reader, cmd = m.reader.Update(msg) 343 + return m, cmd 344 + } 345 + 346 + func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 347 + switch msg.String() { 348 + case "esc": 349 + m.state = stateInbox 350 + return m, nil 351 + } 352 + 353 + var cmd tea.Cmd 354 + var launch bool 355 + m.compose, cmd, launch = m.compose.update(msg) 356 + if launch { 357 + return m.launchEditorCmd() 358 + } 359 + return m, cmd 360 + } 361 + 362 + func (m Model) launchEditorCmd() (tea.Model, tea.Cmd) { 363 + to := m.compose.to.Value() 364 + subject := m.compose.subject.Value() 365 + prelude := editor.Prelude(to, subject) 366 + 367 + // Write temp file 368 + f, err := os.CreateTemp("", "neomd-*.md") 369 + if err != nil { 370 + m.status = err.Error() 371 + m.isError = true 372 + m.state = stateInbox 373 + return m, nil 374 + } 375 + tmpPath := f.Name() 376 + f.WriteString(prelude) //nolint 377 + f.Close() 378 + 379 + editorBin := os.Getenv("EDITOR") 380 + if editorBin == "" { 381 + editorBin = "nvim" 382 + } 383 + 384 + cmd := exec.Command(editorBin, tmpPath) 385 + return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg { 386 + defer os.Remove(tmpPath) 387 + if execErr != nil { 388 + return editorDoneMsg{err: execErr} 389 + } 390 + raw, readErr := os.ReadFile(tmpPath) 391 + if readErr != nil { 392 + return editorDoneMsg{err: readErr} 393 + } 394 + return editorDoneMsg{to: to, subject: subject, body: string(raw)} 395 + }) 396 + } 397 + 398 + // ── View ────────────────────────────────────────────────────────────────── 399 + 400 + func (m Model) View() string { 401 + if m.width == 0 { 402 + return "Loading…" 403 + } 404 + switch m.state { 405 + case stateInbox: 406 + return m.viewInbox() 407 + case stateReading: 408 + return m.viewReader() 409 + case stateCompose: 410 + return m.viewCompose() 411 + } 412 + return "" 413 + } 414 + 415 + func (m Model) viewInbox() string { 416 + var b strings.Builder 417 + b.WriteString(folderTabs(m.folders, m.folders[m.activeFolderI]) + "\n") 418 + b.WriteString(styleSeparator.Render(strings.Repeat("─", m.width)) + "\n") 419 + 420 + if m.loading { 421 + b.WriteString(fmt.Sprintf(" %s Loading…\n", m.spinner.View())) 422 + } else if len(m.emails) == 0 { 423 + b.WriteString(styleStatus.Render(" No messages.") + "\n") 424 + } else { 425 + b.WriteString(m.inbox.View()) 426 + } 427 + 428 + b.WriteString("\n") 429 + if m.status != "" { 430 + b.WriteString(statusBar(m.status, m.isError)) 431 + } else { 432 + b.WriteString(inboxHelp(m.folders[m.activeFolderI])) 433 + } 434 + return b.String() 435 + } 436 + 437 + func (m Model) viewReader() string { 438 + var b strings.Builder 439 + b.WriteString(styleHeader.Render(" ← q") + " " + styleStatus.Render(m.folders[m.activeFolderI]) + "\n") 440 + if m.loading { 441 + b.WriteString(fmt.Sprintf(" %s Loading…\n", m.spinner.View())) 442 + } else { 443 + b.WriteString(m.reader.View()) 444 + } 445 + b.WriteString("\n" + readerHelp()) 446 + return b.String() 447 + } 448 + 449 + func (m Model) viewCompose() string { 450 + var b strings.Builder 451 + b.WriteString(styleHeader.Render(" New Message") + "\n") 452 + b.WriteString(styleSeparator.Render(strings.Repeat("─", m.width)) + "\n\n") 453 + b.WriteString(m.compose.view() + "\n\n") 454 + b.WriteString(composeHelp(int(m.compose.step))) 455 + return b.String() 456 + } 457 + 458 + // ── Helpers ─────────────────────────────────────────────────────────────── 459 + 460 + func splitAddr(addr string) (host, port string) { 461 + h, p, _ := strings.Cut(addr, ":") 462 + if p == "" { 463 + p = "587" 464 + } 465 + return h, p 466 + } 467 + 468 + // Ensure Model satisfies tea.Model. 469 + var _ tea.Model = Model{}
+87
internal/ui/reader.go
··· 1 + package ui 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/charmbracelet/bubbles/viewport" 8 + "github.com/sspaeti/neomd/internal/imap" 9 + "github.com/sspaeti/neomd/internal/render" 10 + ) 11 + 12 + // newReader creates a viewport for reading emails. 13 + func newReader(width, height int) viewport.Model { 14 + vp := viewport.New(width, height) 15 + vp.Style = styleInputField 16 + return vp 17 + } 18 + 19 + // loadEmailIntoReader renders the email and sets the viewport content. 20 + func loadEmailIntoReader(vp *viewport.Model, email *imap.Email, body, theme string, width int) error { 21 + header := renderEmailHeader(email, width) 22 + 23 + rendered, err := render.ToANSI(body, theme) 24 + if err != nil { 25 + rendered = body // fall back to raw markdown 26 + } 27 + 28 + vp.SetContent(header + "\n" + rendered) 29 + vp.GotoTop() 30 + return nil 31 + } 32 + 33 + func renderEmailHeader(e *imap.Email, width int) string { 34 + if e == nil { 35 + return "" 36 + } 37 + 38 + lines := []string{ 39 + styleFrom.Render("From: ") + e.From, 40 + styleDate.Render("To: ") + e.To, 41 + styleSubject.Render("Subject: ") + e.Subject, 42 + styleDate.Render("Date: ") + fmtDate(e.Date), 43 + } 44 + 45 + content := strings.Join(lines, "\n") 46 + _ = width // box will size itself 47 + 48 + return styleEmailMeta.Render(content) + "\n" 49 + } 50 + 51 + // readerHelp returns the one-line help string for the reader view. 52 + func readerHelp() string { 53 + keys := []string{"j/k scroll", "space page", "q back"} 54 + return styleHelp.Render(" " + strings.Join(keys, " · ")) 55 + } 56 + 57 + // inboxHelp returns the one-line help string for the inbox view. 58 + func inboxHelp(folder string) string { 59 + base := []string{"enter open", "c compose", "/ filter", "tab switch folder", "q quit"} 60 + if folder == "ToScreen" { 61 + base = []string{"I approve", "O block", "F feed", "P papertrail", "q back"} 62 + } 63 + return styleHelp.Render(" " + strings.Join(base, " · ")) 64 + } 65 + 66 + // composeHelp returns the one-line help string for the compose view. 67 + func composeHelp(step int) string { 68 + switch step { 69 + case 0: 70 + return styleHelp.Render(" tab next field · enter next field") 71 + case 1: 72 + return styleHelp.Render(" enter open editor") 73 + default: 74 + return styleHelp.Render(" esc cancel · enter send") 75 + } 76 + } 77 + 78 + // statusBar formats a status/error message for the bottom bar. 79 + func statusBar(msg string, isErr bool) string { 80 + if isErr { 81 + return styleError.Render(fmt.Sprintf(" ✗ %s", msg)) 82 + } 83 + if msg != "" { 84 + return styleSuccess.Render(fmt.Sprintf(" ✓ %s", msg)) 85 + } 86 + return "" 87 + }
+98
internal/ui/styles.go
··· 1 + package ui 2 + 3 + import "github.com/charmbracelet/lipgloss" 4 + 5 + var ( 6 + colorPrimary = lipgloss.Color("#ff5d62") // warm red, from newsletter theme 7 + colorMuted = lipgloss.Color("#6c7086") 8 + colorSubtle = lipgloss.Color("#313244") 9 + colorText = lipgloss.Color("#cdd6f4") 10 + colorUnread = lipgloss.Color("#cba6f7") // lavender for unread 11 + colorBg = lipgloss.Color("#1e1e2e") 12 + colorBorder = lipgloss.Color("#45475a") 13 + colorSelected = lipgloss.Color("#585b70") 14 + 15 + styleHeader = lipgloss.NewStyle(). 16 + Foreground(colorPrimary). 17 + Bold(true). 18 + Padding(0, 1) 19 + 20 + styleFolder = lipgloss.NewStyle(). 21 + Foreground(colorMuted). 22 + Padding(0, 1) 23 + 24 + styleStatus = lipgloss.NewStyle(). 25 + Foreground(colorMuted). 26 + Padding(0, 1) 27 + 28 + styleError = lipgloss.NewStyle(). 29 + Foreground(lipgloss.Color("#f38ba8")). 30 + Padding(0, 1) 31 + 32 + styleEmailMeta = lipgloss.NewStyle(). 33 + BorderStyle(lipgloss.RoundedBorder()). 34 + BorderForeground(colorBorder). 35 + Padding(0, 1). 36 + MarginBottom(1) 37 + 38 + styleFrom = lipgloss.NewStyle(). 39 + Foreground(colorPrimary). 40 + Bold(true) 41 + 42 + styleSubject = lipgloss.NewStyle(). 43 + Foreground(colorText). 44 + Bold(true) 45 + 46 + styleDate = lipgloss.NewStyle(). 47 + Foreground(colorMuted) 48 + 49 + styleUnread = lipgloss.NewStyle(). 50 + Foreground(colorUnread). 51 + Bold(true) 52 + 53 + styleRead = lipgloss.NewStyle(). 54 + Foreground(colorMuted) 55 + 56 + styleSelected = lipgloss.NewStyle(). 57 + Background(colorSelected). 58 + Foreground(colorText) 59 + 60 + styleHelp = lipgloss.NewStyle(). 61 + Foreground(colorMuted). 62 + Padding(0, 1) 63 + 64 + styleSeparator = lipgloss.NewStyle(). 65 + Foreground(colorBorder) 66 + 67 + styleInputLabel = lipgloss.NewStyle(). 68 + Foreground(colorPrimary). 69 + Bold(true). 70 + Width(10) 71 + 72 + styleInputField = lipgloss.NewStyle(). 73 + Foreground(colorText) 74 + 75 + styleSuccess = lipgloss.NewStyle(). 76 + Foreground(lipgloss.Color("#a6e3a1")) 77 + ) 78 + 79 + // folderTabs renders the folder switcher bar. 80 + func folderTabs(folders []string, active string) string { 81 + var tabs []string 82 + for _, f := range folders { 83 + if f == active { 84 + tabs = append(tabs, styleHeader.Render(f)) 85 + } else { 86 + tabs = append(tabs, styleFolder.Render(f)) 87 + } 88 + } 89 + sep := styleSeparator.Render(" │ ") 90 + result := "" 91 + for i, t := range tabs { 92 + if i > 0 { 93 + result += sep 94 + } 95 + result += t 96 + } 97 + return result 98 + }