@@ -76,8 +76,11 @@ var (
)
const (
- goosDarwin = "darwin"
- folderInbox = "INBOX"
+ goosDarwin = "darwin"
+ folderInbox = "INBOX"
+ actionKindDelete = "delete"
+ actionKindArchive = "archive"
+ actionKindMove = "move"
)
// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
@@ -87,6 +90,20 @@ type UpdateAvailableMsg struct {
}
// internal struct for parsing GitHub release JSON.
+type pendingEmailAction struct {
+ jobID string
+ kind string // "delete", "archive", "move"
+ uids []uint32
+ accountID string
+ folderName string
+ destFolder string // for "move"
+ mailbox tui.MailboxKind
+ // Snapshots for undo restore
+ emailsSnap []fetcher.Email
+ acctSnap []fetcher.Email
+ folderSnap []fetcher.Email
+}
+
type githubRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
@@ -121,11 +138,13 @@ type mainModel struct {
// mailto: URL parsed from os.Args
mailtoURL *url.URL
// Optional in-app log panel.
- showLogPanel bool
- logCh <-chan logging.Entry
- logPanel *tui.LogPanel
- pendingJobID string
- sendNotice string
+ showLogPanel bool
+ logCh <-chan logging.Entry
+ logPanel *tui.LogPanel
+ pendingJobID string
+ sendNotice string
+ pendingAction *pendingEmailAction
+ actionNotice string
}
type logEntryMsg struct {
@@ -343,6 +362,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == config.Keybinds.Composer.UndoSend {
+ if m.pendingAction != nil {
+ m.restorePendingAction()
+ return m, nil
+ }
if m.pendingJobID != "" {
jobID := m.pendingJobID
m.pendingJobID = ""
@@ -880,6 +903,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
}
+ emailsSnap := slices.Clone(m.emails)
+ acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
+ folderSnap := slices.Clone(m.folderEmails[folderName])
+
m.removeEmailFromStores(msg.UID, msg.AccountID)
if emails, ok := m.folderEmails[folderName]; ok {
@@ -893,7 +920,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
go saveFolderEmailsToCache(folderName, filtered)
}
- return m, m.moveEmailToFolderCmd(msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder)
+ pa := &pendingEmailAction{
+ jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()),
+ kind: actionKindMove,
+ uids: []uint32{msg.UID},
+ accountID: msg.AccountID,
+ folderName: folderName,
+ destFolder: msg.DestFolder,
+ emailsSnap: emailsSnap,
+ acctSnap: acctSnap,
+ folderSnap: folderSnap,
+ }
+ flushCmd := m.flushPendingAction()
+ notice := fmt.Sprintf("Email moved to %s (%s to undo)", msg.DestFolder, config.Keybinds.Composer.UndoSend)
+ return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
case tui.UpdatePreviewMsg:
// Trigger preview body fetch
@@ -1803,6 +1843,15 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
m.current, _ = m.current.Update(m.currentWindowSize())
return m, m.current.Init()
+ case tui.ActionGracePeriodExpiredMsg:
+ if m.pendingAction != nil && m.pendingAction.jobID == msg.JobID {
+ pa := m.pendingAction
+ m.pendingAction = nil
+ m.actionNotice = ""
+ return m, m.executePendingAction(pa)
+ }
+ return m, nil
+
case tui.SendRSVPMsg:
account := m.config.GetAccountByID(msg.AccountID)
if account == nil {
@@ -1870,6 +1919,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
}
+ emailsSnap := slices.Clone(m.emails)
+ acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
+ folderSnap := slices.Clone(m.folderEmails[folderName])
+
m.removeEmailFromStores(msg.UID, msg.AccountID)
if emails, ok := m.folderEmails[folderName]; ok {
@@ -1883,7 +1936,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
go saveFolderEmailsToCache(folderName, filtered)
}
- return m, m.deleteFolderEmailCmd(msg.UID, msg.AccountID, folderName, msg.Mailbox)
+ pa := &pendingEmailAction{
+ jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()),
+ kind: actionKindDelete,
+ uids: []uint32{msg.UID},
+ accountID: msg.AccountID,
+ folderName: folderName,
+ mailbox: msg.Mailbox,
+ emailsSnap: emailsSnap,
+ acctSnap: acctSnap,
+ folderSnap: folderSnap,
+ }
+ flushCmd := m.flushPendingAction()
+ notice := fmt.Sprintf("Email deleted (%s to undo)", config.Keybinds.Composer.UndoSend)
+ return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
case tui.ArchiveEmailMsg:
tui.ClearKittyGraphics()
@@ -1903,6 +1969,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
}
+ emailsSnap := slices.Clone(m.emails)
+ acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
+ folderSnap := slices.Clone(m.folderEmails[folderName])
+
m.removeEmailFromStores(msg.UID, msg.AccountID)
if emails, ok := m.folderEmails[folderName]; ok {
@@ -1916,7 +1986,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
go saveFolderEmailsToCache(folderName, filtered)
}
- return m, m.archiveFolderEmailCmd(msg.UID, msg.AccountID, folderName, msg.Mailbox)
+ pa := &pendingEmailAction{
+ jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()),
+ kind: actionKindArchive,
+ uids: []uint32{msg.UID},
+ accountID: msg.AccountID,
+ folderName: folderName,
+ mailbox: msg.Mailbox,
+ emailsSnap: emailsSnap,
+ acctSnap: acctSnap,
+ folderSnap: folderSnap,
+ }
+ flushCmd := m.flushPendingAction()
+ notice := fmt.Sprintf("Email archived (%s to undo)", config.Keybinds.Composer.UndoSend)
+ return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
case tui.EmailMarkedReadMsg:
if msg.Err != nil {
@@ -1963,6 +2046,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
}
+ emailsSnap := slices.Clone(m.emails)
+ acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
+ folderSnap := slices.Clone(m.folderEmails[folderName])
+
for _, uid := range msg.UIDs {
m.removeEmailFromStores(uid, msg.AccountID)
}
@@ -1978,7 +2065,23 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
go saveFolderEmailsToCache(folderName, filtered)
}
- return m, m.batchDeleteEmailsCmd(msg.UIDs, msg.AccountID, folderName, msg.Mailbox, len(msg.UIDs))
+ pa := &pendingEmailAction{
+ jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()),
+ kind: actionKindDelete,
+ uids: msg.UIDs,
+ accountID: msg.AccountID,
+ folderName: folderName,
+ mailbox: msg.Mailbox,
+ emailsSnap: emailsSnap,
+ acctSnap: acctSnap,
+ folderSnap: folderSnap,
+ }
+ flushCmd := m.flushPendingAction()
+ notice := fmt.Sprintf("%d emails deleted (%s to undo)", len(msg.UIDs), config.Keybinds.Composer.UndoSend)
+ if len(msg.UIDs) == 1 {
+ notice = fmt.Sprintf("Email deleted (%s to undo)", config.Keybinds.Composer.UndoSend)
+ }
+ return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
case tui.BatchArchiveEmailsMsg:
tui.ClearKittyGraphics()
@@ -1997,6 +2100,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
}
+ emailsSnap := slices.Clone(m.emails)
+ acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
+ folderSnap := slices.Clone(m.folderEmails[folderName])
+
for _, uid := range msg.UIDs {
m.removeEmailFromStores(uid, msg.AccountID)
}
@@ -2012,7 +2119,23 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
go saveFolderEmailsToCache(folderName, filtered)
}
- return m, m.batchArchiveEmailsCmd(msg.UIDs, msg.AccountID, folderName, msg.Mailbox, len(msg.UIDs))
+ pa := &pendingEmailAction{
+ jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()),
+ kind: actionKindArchive,
+ uids: msg.UIDs,
+ accountID: msg.AccountID,
+ folderName: folderName,
+ mailbox: msg.Mailbox,
+ emailsSnap: emailsSnap,
+ acctSnap: acctSnap,
+ folderSnap: folderSnap,
+ }
+ flushCmd := m.flushPendingAction()
+ notice := fmt.Sprintf("%d emails archived (%s to undo)", len(msg.UIDs), config.Keybinds.Composer.UndoSend)
+ if len(msg.UIDs) == 1 {
+ notice = fmt.Sprintf("Email archived (%s to undo)", config.Keybinds.Composer.UndoSend)
+ }
+ return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
case tui.BatchMoveEmailsMsg:
if m.config == nil {
@@ -2029,6 +2152,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
}
+ emailsSnap := slices.Clone(m.emails)
+ acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
+ folderSnap := slices.Clone(m.folderEmails[folderName])
+
for _, uid := range msg.UIDs {
m.removeEmailFromStores(uid, msg.AccountID)
}
@@ -2044,7 +2171,23 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
go saveFolderEmailsToCache(folderName, filtered)
}
- return m, m.batchMoveEmailsCmd(msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, len(msg.UIDs))
+ pa := &pendingEmailAction{
+ jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()),
+ kind: actionKindMove,
+ uids: msg.UIDs,
+ accountID: msg.AccountID,
+ folderName: folderName,
+ destFolder: msg.DestFolder,
+ emailsSnap: emailsSnap,
+ acctSnap: acctSnap,
+ folderSnap: folderSnap,
+ }
+ flushCmd := m.flushPendingAction()
+ notice := fmt.Sprintf("%d emails moved to %s (%s to undo)", len(msg.UIDs), msg.DestFolder, config.Keybinds.Composer.UndoSend)
+ if len(msg.UIDs) == 1 {
+ notice = fmt.Sprintf("Email moved to %s (%s to undo)", msg.DestFolder, config.Keybinds.Composer.UndoSend)
+ }
+ return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
case tui.BatchEmailActionDoneMsg:
if msg.Err != nil {
@@ -2127,6 +2270,9 @@ func (m *mainModel) View() tea.View {
if m.sendNotice != "" {
v.Content = m.renderSendNoticeOverlay(v.Content)
}
+ if m.actionNotice != "" {
+ v.Content = m.renderActionNoticeOverlay(v.Content)
+ }
v.AltScreen = true
return v
}
@@ -2143,6 +2289,68 @@ func (m *mainModel) renderSendNoticeOverlay(content string) string {
return overlay.Block(content, lines, 0, col)
}
+func (m *mainModel) renderActionNoticeOverlay(content string) string {
+ box := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(theme.ActiveTheme.Accent).
+ Padding(0, 1).
+ Render(m.actionNotice)
+ lines := strings.Split(box, "\n")
+ boxWidth := lipgloss.Width(lines[0])
+ col := max(0, m.width-boxWidth)
+ return overlay.Block(content, lines, 0, col)
+}
+
+func (m *mainModel) startActionGracePeriod(pa *pendingEmailAction, notice string) tea.Cmd {
+ m.pendingAction = pa
+ m.actionNotice = notice
+ delay := time.Duration(m.config.GetUndoDelaySeconds()) * time.Second
+ jobID := pa.jobID
+ return tea.Tick(delay, func(t time.Time) tea.Msg {
+ return tui.ActionGracePeriodExpiredMsg{JobID: jobID}
+ })
+}
+
+func (m *mainModel) flushPendingAction() tea.Cmd {
+ if m.pendingAction == nil {
+ return nil
+ }
+ pa := m.pendingAction
+ m.pendingAction = nil
+ m.actionNotice = ""
+ return m.executePendingAction(pa)
+}
+
+func (m *mainModel) executePendingAction(pa *pendingEmailAction) tea.Cmd {
+ switch pa.kind {
+ case actionKindDelete:
+ return m.batchDeleteEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.mailbox, len(pa.uids))
+ case actionKindArchive:
+ return m.batchArchiveEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.mailbox, len(pa.uids))
+ case actionKindMove:
+ return m.batchMoveEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.destFolder, len(pa.uids))
+ }
+ return nil
+}
+
+func (m *mainModel) restorePendingAction() {
+ if m.pendingAction == nil {
+ return
+ }
+ pa := m.pendingAction
+ m.pendingAction = nil
+ m.actionNotice = ""
+
+ m.emails = pa.emailsSnap
+ m.emailsByAcct[pa.accountID] = pa.acctSnap
+ m.folderEmails[pa.folderName] = pa.folderSnap
+
+ if m.folderInbox != nil {
+ m.folderInbox.SetEmails(pa.folderSnap, m.config.Accounts)
+ }
+ go saveFolderEmailsToCache(pa.folderName, pa.folderSnap)
+}
+
func (m *mainModel) currentWindowSize() tea.WindowSizeMsg {
return tea.WindowSizeMsg{
Width: m.width,
@@ -3093,36 +3301,6 @@ func markEmailAsUnreadCmd(account *config.Account, uid uint32, accountID string,
}
}
-func (m *mainModel) deleteFolderEmailCmd(uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
- return func() tea.Msg {
- if m.service == nil {
- return tui.EmailActionDoneMsg{
- UID: uid,
- AccountID: accountID,
- Mailbox: mailbox,
- Err: fmt.Errorf("service not initialized"),
- }
- }
- err := m.service.DeleteEmails(accountID, folderName, []uint32{uid})
- return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
- }
-}
-
-func (m *mainModel) archiveFolderEmailCmd(uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
- return func() tea.Msg {
- if m.service == nil {
- return tui.EmailActionDoneMsg{
- UID: uid,
- AccountID: accountID,
- Mailbox: mailbox,
- Err: fmt.Errorf("service not initialized"),
- }
- }
- err := m.service.ArchiveEmails(accountID, folderName, []uint32{uid})
- return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
- }
-}
-
func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
return func() tea.Msg {
if m.service == nil {
@@ -3130,7 +3308,7 @@ func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName st
Count: count,
SuccessCount: 0,
FailureCount: count,
- Action: "delete",
+ Action: actionKindDelete,
Mailbox: mailbox,
Err: fmt.Errorf("service not initialized"),
}
@@ -3147,7 +3325,7 @@ func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName st
Count: count,
SuccessCount: successCount,
FailureCount: failureCount,
- Action: "delete",
+ Action: actionKindDelete,
Mailbox: mailbox,
Err: err,
}
@@ -3161,7 +3339,7 @@ func (m *mainModel) batchArchiveEmailsCmd(uids []uint32, accountID, folderName s
Count: count,
SuccessCount: 0,
FailureCount: count,
- Action: "archive",
+ Action: actionKindArchive,
Mailbox: mailbox,
Err: fmt.Errorf("service not initialized"),
}
@@ -3178,7 +3356,7 @@ func (m *mainModel) batchArchiveEmailsCmd(uids []uint32, accountID, folderName s
Count: count,
SuccessCount: successCount,
FailureCount: failureCount,
- Action: "archive",
+ Action: actionKindArchive,
Mailbox: mailbox,
Err: err,
}
@@ -3192,7 +3370,7 @@ func (m *mainModel) batchMoveEmailsCmd(uids []uint32, accountID, sourceFolder, d
Count: count,
SuccessCount: 0,
FailureCount: count,
- Action: "move",
+ Action: actionKindMove,
Err: fmt.Errorf("service not initialized"),
}
}
@@ -3208,30 +3386,7 @@ func (m *mainModel) batchMoveEmailsCmd(uids []uint32, accountID, sourceFolder, d
Count: count,
SuccessCount: successCount,
FailureCount: failureCount,
- Action: "move",
- Err: err,
- }
- }
-}
-
-func (m *mainModel) moveEmailToFolderCmd(uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
- return func() tea.Msg {
- if m.service == nil {
- return tui.EmailMovedMsg{
- UID: uid,
- AccountID: accountID,
- SourceFolder: sourceFolder,
- DestFolder: destFolder,
- Err: fmt.Errorf("service not initialized"),
- }
- }
-
- err := m.service.MoveEmails(accountID, []uint32{uid}, sourceFolder, destFolder)
- return tui.EmailMovedMsg{
- UID: uid,
- AccountID: accountID,
- SourceFolder: sourceFolder,
- DestFolder: destFolder,
+ Action: actionKindMove,
Err: err,
}
}