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.

update read indication if replied already

sspaeti ae3b61e6 957349c2

+67 -10
+25
internal/imap/client.go
··· 40 40 Subject string 41 41 Date time.Time 42 42 Seen bool 43 + Answered bool // \Answered flag — set when replied to from any client 43 44 Folder string 44 45 Size uint32 // RFC822 size in bytes 45 46 HasAttachment bool // true if BODYSTRUCTURE contains an attachment part ··· 263 264 for _, f := range m.Flags { 264 265 if f == imap.FlagSeen { 265 266 e.Seen = true 267 + } 268 + if f == imap.FlagAnswered { 269 + e.Answered = true 266 270 } 267 271 } 268 272 if m.Envelope != nil { ··· 569 573 for _, f := range m.Flags { 570 574 if f == imap.FlagSeen { 571 575 e.Seen = true 576 + } 577 + if f == imap.FlagAnswered { 578 + e.Answered = true 572 579 } 573 580 } 574 581 if m.Envelope != nil { ··· 784 791 return conn.Store(uidSet, &imap.StoreFlags{ 785 792 Op: imap.StoreFlagsDel, 786 793 Flags: []imap.Flag{imap.FlagSeen}, 794 + }, nil).Close() 795 + }) 796 + } 797 + 798 + // MarkAnswered adds the \Answered flag to a message (set after replying). 799 + func (c *Client) MarkAnswered(ctx context.Context, folder string, uid uint32) error { 800 + if ctx == nil { 801 + ctx = context.Background() 802 + } 803 + return c.withConn(ctx, func(conn *imapclient.Client) error { 804 + if err := c.selectMailbox(folder); err != nil { 805 + return err 806 + } 807 + var uidSet imap.UIDSet 808 + uidSet.AddNum(imap.UID(uid)) 809 + return conn.Store(uidSet, &imap.StoreFlags{ 810 + Op: imap.StoreFlagsAdd, 811 + Flags: []imap.Flag{imap.FlagAnswered}, 787 812 }, nil).Close() 788 813 }) 789 814 }
+8 -4
internal/ui/inbox.go
··· 85 85 } 86 86 sizeStr := fmtSize(e.email.Size) 87 87 88 - fixed := colNumWidth + colFlagWidth + colThreadWidth + colDateWidth + colAttachWidth + colSizeWidth + 2 // 2 spaces padding 88 + fixed := colNumWidth + colFlagWidth + colReplyWidth + colThreadWidth + colDateWidth + colAttachWidth + colSizeWidth + 2 // 2 spaces padding 89 89 fromMax := 20 90 90 subjectMax := width - fixed - fromMax - 2 91 91 if subjectMax < 8 { ··· 102 102 subject := truncate(e.email.Subject, subjectMax) 103 103 104 104 if isSelected { 105 - row := fmt.Sprintf("%s%s%s%s%s%-*s %-*s %s", 106 - num, flag, threadStr, dateStr, attachStr, 105 + row := fmt.Sprintf("%s%s%s%s%s%s%-*s %-*s %s", 106 + num, flag, replyStr, threadStr, dateStr, attachStr, 107 107 fromMax, from, 108 108 subjectMax, subject, 109 109 sizeStr, ··· 123 123 default: 124 124 flagS = lipgloss.NewStyle().Foreground(colorMuted).Render(flag) 125 125 } 126 + replyS := lipgloss.NewStyle().Foreground(colorMuted).Render(replyStr) 127 + if e.email.Answered { 128 + replyS = lipgloss.NewStyle().Foreground(colorPrimary).Render(replyStr) 129 + } 126 130 threadS := lipgloss.NewStyle().Foreground(colorBorder).Render(threadStr) 127 131 dateS := lipgloss.NewStyle().Foreground(colorDateCol).Render(dateStr) 128 132 attachS := lipgloss.NewStyle().Foreground(colorDateCol).Render(attachStr) ··· 137 141 subS := subStyle.Render(fmt.Sprintf("%-*s", subjectMax, subject)) 138 142 sizeS := lipgloss.NewStyle().Foreground(colorSizeCol).Render(sizeStr) 139 143 140 - fmt.Fprint(w, numS+flagS+threadS+dateS+attachS+fromS+" "+subS+" "+sizeS) 144 + fmt.Fprint(w, numS+flagS+replyS+threadS+dateS+attachS+fromS+" "+subS+" "+sizeS) 141 145 } 142 146 143 147 // cleanFrom strips the <addr> part when a display name is present.
+34 -6
internal/ui/model.go
··· 51 51 attachments []imap.Attachment 52 52 } 53 53 sendDoneMsg struct { 54 - err error 55 - warning string 54 + err error 55 + warning string 56 + replyToUID uint32 // set \Answered on this email after send 57 + replyToFolder string 56 58 } 57 59 screenDoneMsg struct{ err error } 58 60 autoScreenDoneMsg struct { ··· 369 371 // pendingSendData holds a composed message waiting in the pre-send review screen. 370 372 type pendingSendData struct { 371 373 to, cc, bcc, subject, body string 374 + // replyToUID/replyToFolder track the original email when this is a reply, 375 + // so we can set \Answered after sending. Zero means not a reply. 376 + replyToUID uint32 377 + replyToFolder string 372 378 } 373 379 374 380 // undoMove records one IMAP move so it can be reversed with u. ··· 656 662 } 657 663 } 658 664 659 - func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string) tea.Cmd { 665 + func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string, replyToUID uint32, replyToFolder string) tea.Cmd { 660 666 h, p := splitAddr(smtpAcct.SMTP) 661 667 cfg := smtp.Config{ 662 668 Host: h, ··· 681 687 } 682 688 // Save copy to Sent; non-fatal if it fails, but warn user. 683 689 if saveErr := cli.SaveSent(nil, sentFolder, raw); saveErr != nil { 684 - return sendDoneMsg{warning: "Sent, but failed to save to Sent folder: " + saveErr.Error()} 690 + return sendDoneMsg{warning: "Sent, but failed to save to Sent folder: " + saveErr.Error(), replyToUID: replyToUID, replyToFolder: replyToFolder} 685 691 } 686 - return sendDoneMsg{} 692 + // Mark original email as \Answered (non-fatal). 693 + if replyToUID > 0 && replyToFolder != "" { 694 + _ = cli.MarkAnswered(nil, replyToFolder, replyToUID) 695 + } 696 + return sendDoneMsg{replyToUID: replyToUID, replyToFolder: replyToFolder} 687 697 } 688 698 } 689 699 ··· 1295 1305 m.isError = false 1296 1306 m.state = stateInbox 1297 1307 } 1308 + // Update local Answered flag so the reply indicator shows immediately. 1309 + if msg.replyToUID > 0 { 1310 + items := m.list.Items() 1311 + for i, it := range items { 1312 + if ei, ok := it.(emailItem); ok && ei.email.UID == msg.replyToUID { 1313 + ei.email.Answered = true 1314 + items[i] = ei 1315 + break 1316 + } 1317 + } 1318 + m.list.SetItems(items) 1319 + } 1298 1320 return m, nil 1299 1321 1300 1322 case attachOpenDoneMsg: ··· 1554 1576 m.pendingSend = &pendingSendData{ 1555 1577 to: msg.to, cc: msg.cc, bcc: mergeAutoBCC(msg.bcc, m.cfg.AutoBCC), 1556 1578 subject: msg.subject, body: cleanBody, 1579 + } 1580 + // Track original email for \Answered flag (replies/forwards). 1581 + if m.openEmail != nil && strings.HasPrefix(strings.ToLower(msg.subject), "re:") { 1582 + m.pendingSend.replyToUID = m.openEmail.UID 1583 + m.pendingSend.replyToFolder = m.openEmail.Folder 1557 1584 } 1558 1585 m.state = statePresend 1559 1586 return m, nil ··· 2656 2683 from := m.presendFrom() 2657 2684 smtpAcct := m.presendSMTPAccount() 2658 2685 attachments := m.attachments 2686 + replyUID, replyFolder := ps.replyToUID, ps.replyToFolder 2659 2687 m.attachments = nil 2660 2688 m.pendingSend = nil 2661 - return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, ps.body, attachments)) 2689 + return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, ps.body, attachments, replyUID, replyFolder)) 2662 2690 case "ctrl+f": 2663 2691 froms := m.presendFroms() 2664 2692 if len(froms) > 1 {