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.

add :debug mode

sspaeti 824a10f7 91644a4f

+173
+16
internal/imap/client.go
··· 154 154 } 155 155 } 156 156 157 + // Addr returns the IMAP server address (host:port). 158 + func (c *Client) Addr() string { return c.addr() } 159 + 160 + // User returns the IMAP username. 161 + func (c *Client) User() string { return c.cfg.User } 162 + 163 + // Ping tests the IMAP connection by issuing a NOOP command. 164 + func (c *Client) Ping(ctx context.Context) error { 165 + if ctx == nil { 166 + ctx = context.Background() 167 + } 168 + return c.withConn(ctx, func(conn *imapclient.Client) error { 169 + return conn.Noop().Wait() 170 + }) 171 + } 172 + 157 173 // FetchHeaders fetches the latest n message summaries from folder. 158 174 func (c *Client) FetchHeaders(ctx context.Context, folder string, n int) ([]Email, error) { 159 175 if ctx == nil {
+8
internal/ui/cmdline.go
··· 153 153 }, 154 154 }, 155 155 { 156 + name: "debug", 157 + aliases: []string{"dbg"}, 158 + desc: "write diagnostic report to /tmp/neomd/debug.log and open it", 159 + run: func(m *Model) (tea.Model, tea.Cmd) { 160 + return m, m.writeDebugReport() 161 + }, 162 + }, 163 + { 156 164 name: "quit", 157 165 aliases: []string{"q"}, 158 166 desc: "quit neomd",
+149
internal/ui/model.go
··· 104 104 return dir 105 105 } 106 106 107 + // maskEmail masks the local part of an email address: "user@example.com" → "u***@example.com". 108 + // For "Name <email>" format, masks the email part only. 109 + func maskEmail(s string) string { 110 + // Extract email from "Name <email>" format 111 + email := s 112 + prefix := "" 113 + if i := strings.LastIndex(s, "<"); i >= 0 { 114 + if j := strings.Index(s[i:], ">"); j >= 0 { 115 + prefix = s[:i] 116 + email = s[i+1 : i+j] 117 + } 118 + } 119 + // Mask local part 120 + at := strings.Index(email, "@") 121 + if at <= 0 { 122 + return s // not an email, return as-is 123 + } 124 + local := email[:at] 125 + domain := email[at:] 126 + masked := string(local[0]) + "***" + domain 127 + if prefix != "" { 128 + return prefix + "<" + masked + ">" 129 + } 130 + return masked 131 + } 132 + 133 + // writeDebugReport generates a diagnostic report and opens it in the reader. 134 + func (m Model) writeDebugReport() tea.Cmd { 135 + return func() tea.Msg { 136 + var b strings.Builder 137 + b.WriteString("# neomd debug report\n\n") 138 + b.WriteString(fmt.Sprintf("Time: %s\n", time.Now().Format(time.RFC3339))) 139 + b.WriteString(fmt.Sprintf("Config: %s\n\n", config.DefaultPath())) 140 + 141 + // Accounts 142 + b.WriteString("## Accounts\n\n") 143 + for i, a := range m.accounts { 144 + active := "" 145 + if i == m.accountI { 146 + active = " (active)" 147 + } 148 + b.WriteString(fmt.Sprintf("- **%s**%s\n", a.Name, active)) 149 + b.WriteString(fmt.Sprintf(" - IMAP: `%s`\n", a.IMAP)) 150 + b.WriteString(fmt.Sprintf(" - SMTP: `%s`\n", a.SMTP)) 151 + b.WriteString(fmt.Sprintf(" - User: `%s`\n", maskEmail(a.User))) 152 + b.WriteString(fmt.Sprintf(" - From: `%s`\n", maskEmail(a.From))) 153 + hasPass := "set" 154 + if a.Password == "" { 155 + hasPass = "EMPTY" 156 + } 157 + b.WriteString(fmt.Sprintf(" - Password: %s\n", hasPass)) 158 + } 159 + 160 + // Connection test 161 + b.WriteString("\n## IMAP Connection\n\n") 162 + for i, cli := range m.clients { 163 + name := "unknown" 164 + if i < len(m.accounts) { 165 + name = m.accounts[i].Name 166 + } 167 + b.WriteString(fmt.Sprintf("- **%s** → `%s`\n", name, cli.Addr())) 168 + if err := cli.Ping(nil); err != nil { 169 + b.WriteString(fmt.Sprintf(" - PING: FAILED — `%s`\n", err)) 170 + } else { 171 + b.WriteString(" - PING: OK\n") 172 + } 173 + } 174 + 175 + // Folders 176 + b.WriteString("\n## Folder Mapping\n\n") 177 + f := m.cfg.Folders 178 + folders := [][2]string{ 179 + {"Inbox", f.Inbox}, {"Sent", f.Sent}, {"Trash", f.Trash}, 180 + {"Drafts", f.Drafts}, {"ToScreen", f.ToScreen}, {"Feed", f.Feed}, 181 + {"PaperTrail", f.PaperTrail}, {"ScreenedOut", f.ScreenedOut}, 182 + {"Archive", f.Archive}, {"Waiting", f.Waiting}, 183 + {"Scheduled", f.Scheduled}, {"Someday", f.Someday}, {"Spam", f.Spam}, 184 + } 185 + for _, kv := range folders { 186 + val := kv[1] 187 + if val == "" { 188 + val = "(not set)" 189 + } 190 + b.WriteString(fmt.Sprintf("- %s → `%s`\n", kv[0], val)) 191 + } 192 + 193 + // Tab order 194 + b.WriteString(fmt.Sprintf("\nTab order: %s\n", strings.Join(m.folders, " → "))) 195 + b.WriteString(fmt.Sprintf("Active tab: %s (index %d)\n", m.folders[m.activeFolderI], m.activeFolderI)) 196 + 197 + // Screener lists 198 + b.WriteString("\n## Screener Lists\n\n") 199 + sc := m.cfg.Screener 200 + lists := [][2]string{ 201 + {"screened_in", sc.ScreenedIn}, {"screened_out", sc.ScreenedOut}, 202 + {"feed", sc.Feed}, {"papertrail", sc.PaperTrail}, {"spam", sc.Spam}, 203 + } 204 + for _, kv := range lists { 205 + path := kv[1] 206 + if path == "" { 207 + b.WriteString(fmt.Sprintf("- %s: (not set)\n", kv[0])) 208 + continue 209 + } 210 + info, err := os.Stat(path) 211 + if err != nil { 212 + b.WriteString(fmt.Sprintf("- %s: `%s` — MISSING (%s)\n", kv[0], path, err)) 213 + } else { 214 + b.WriteString(fmt.Sprintf("- %s: `%s` (%d bytes)\n", kv[0], path, info.Size())) 215 + } 216 + } 217 + 218 + // UI config 219 + b.WriteString("\n## UI Config\n\n") 220 + b.WriteString(fmt.Sprintf("- inbox_count: %d\n", m.cfg.UI.InboxCount)) 221 + b.WriteString(fmt.Sprintf("- theme: %s\n", m.cfg.UI.Theme)) 222 + b.WriteString(fmt.Sprintf("- auto_screen_on_load: %v\n", m.cfg.UI.AutoScreen())) 223 + b.WriteString(fmt.Sprintf("- bg_sync_interval: %d min\n", m.cfg.UI.BgSyncInterval)) 224 + 225 + // Current state 226 + b.WriteString("\n## Current State\n\n") 227 + b.WriteString(fmt.Sprintf("- Loaded emails: %d\n", len(m.emails))) 228 + b.WriteString(fmt.Sprintf("- Loading: %v\n", m.loading)) 229 + b.WriteString(fmt.Sprintf("- View state: %d\n", m.state)) 230 + b.WriteString(fmt.Sprintf("- Last status: %s\n", m.status)) 231 + b.WriteString(fmt.Sprintf("- Is error: %v\n", m.isError)) 232 + 233 + // Folder unseen counts 234 + b.WriteString("\n## Unseen Counts\n\n") 235 + if len(m.folderCounts) == 0 { 236 + b.WriteString("(none loaded yet)\n") 237 + } 238 + for folder, n := range m.folderCounts { 239 + b.WriteString(fmt.Sprintf("- %s: %d\n", folder, n)) 240 + } 241 + 242 + // Write to file 243 + path := filepath.Join(neomdTempDir(), "debug.log") 244 + if err := os.WriteFile(path, []byte(b.String()), 0600); err != nil { 245 + return errMsg{fmt.Errorf("write debug report: %w", err)} 246 + } 247 + 248 + // Return as body to display in reader 249 + return bodyLoadedMsg{ 250 + email: &imap.Email{Subject: "neomd debug report", From: "neomd", Folder: "debug"}, 251 + body: b.String(), 252 + } 253 + } 254 + } 255 + 107 256 // pendingSendData holds a composed message waiting in the pre-send review screen. 108 257 type pendingSendData struct { 109 258 to, cc, bcc, subject, body string