feat(inbox): add detailed dates (#1295)

FromSi and Andriy Chernov created

## What?

Adds an `EnableDetailedDates` config option and a **General Settings**
toggle for showing detailed inbox dates. When enabled, inbox timestamps
render as absolute dates using the configured **Date Format** instead of
relative labels like “2 hours ago”.

## Why?

Closes #569

Some users prefer absolute dates in the inbox for clarity and
consistency. This enables that preference while keeping the existing
relative date behavior as the default.

---------

Co-authored-by: Andriy Chernov <andriy@floatpane.com>

Change summary

config/config.go           |  6 +++++
docs/docs/Configuration.md |  4 +++
i18n/locales/ar.json       |  1 
i18n/locales/de.json       |  1 
i18n/locales/en.json       |  1 
i18n/locales/es.json       |  1 
i18n/locales/fr.json       |  1 
i18n/locales/ja.json       |  1 
i18n/locales/pl.json       |  1 
i18n/locales/pt.json       |  1 
i18n/locales/ru.json       |  1 
i18n/locales/uk.json       |  1 
i18n/locales/zh.json       |  1 
main.go                    |  3 ++
tui/folder_inbox.go        |  7 ++++++
tui/inbox.go               | 44 ++++++++++++++++++++++++++++-----------
tui/settings_general.go    | 11 +++++++--
17 files changed, 70 insertions(+), 16 deletions(-)

Detailed changes

config/config.go 🔗

@@ -93,6 +93,7 @@ type Config struct {
 	DisableNotifications bool          `json:"disable_notifications,omitempty"`
 	EnableSplitPane      bool          `json:"enable_split_pane,omitempty"`
 	EnableThreaded       bool          `json:"enable_threaded,omitempty"`
+	EnableDetailedDates  bool          `json:"enable_detailed_dates,omitempty"`
 	Theme                string        `json:"theme,omitempty"`
 	MailingLists         []MailingList `json:"mailing_lists,omitempty"`
 	DateFormat           string        `json:"date_format,omitempty"`
@@ -405,6 +406,7 @@ type secureDiskConfig struct {
 	DisableNotifications bool                              `json:"disable_notifications,omitempty"`
 	EnableSplitPane      bool                              `json:"enable_split_pane,omitempty"`
 	EnableThreaded       bool                              `json:"enable_threaded,omitempty"`
+	EnableDetailedDates  bool                              `json:"enable_detailed_dates,omitempty"`
 	Theme                string                            `json:"theme,omitempty"`
 	MailingLists         []MailingList                     `json:"mailing_lists,omitempty"`
 	DateFormat           string                            `json:"date_format,omitempty"`
@@ -451,6 +453,8 @@ func SaveConfig(config *Config) error {
 			HideTips:             config.HideTips,
 			DisableNotifications: config.DisableNotifications,
 			EnableSplitPane:      config.EnableSplitPane,
+			EnableThreaded:       config.EnableThreaded,
+			EnableDetailedDates:  config.EnableDetailedDates,
 			Theme:                config.Theme,
 			MailingLists:         config.MailingLists,
 			DateFormat:           config.DateFormat,
@@ -554,6 +558,7 @@ func LoadConfig() (*Config, error) {
 		DisableNotifications bool                              `json:"disable_notifications,omitempty"`
 		EnableSplitPane      bool                              `json:"enable_split_pane,omitempty"`
 		EnableThreaded       bool                              `json:"enable_threaded,omitempty"`
+		EnableDetailedDates  bool                              `json:"enable_detailed_dates,omitempty"`
 		Theme                string                            `json:"theme,omitempty"`
 		MailingLists         []MailingList                     `json:"mailing_lists,omitempty"`
 		DateFormat           string                            `json:"date_format,omitempty"`
@@ -592,6 +597,7 @@ func LoadConfig() (*Config, error) {
 	config.DisableNotifications = raw.DisableNotifications
 	config.EnableSplitPane = raw.EnableSplitPane
 	config.EnableThreaded = raw.EnableThreaded
+	config.EnableDetailedDates = raw.EnableDetailedDates
 	config.Theme = raw.Theme
 	config.MailingLists = raw.MailingLists
 	config.DateFormat = raw.DateFormat

docs/docs/Configuration.md 🔗

@@ -44,6 +44,8 @@ Configuration is stored in `~/.config/matcha/config.json`.
   ],
   "theme": "Matcha",
   "enable_split_pane": true,
+  "enable_detailed_dates": true,
+  "date_format": "DD/MM/YYYY HH:MM",
   "disable_images": true,
   "hide_tips": true,
   "body_cache_threshold_mb": 100
@@ -54,6 +56,8 @@ Configuration is stored in `~/.config/matcha/config.json`.
 
 `enable_split_pane` enables a side-by-side view where the email list and the selected email are shown on the same screen.
 
+`enable_detailed_dates` shows absolute inbox dates using your configured `date_format` instead of relative labels like "2 hours ago".
+
 `body_cache_threshold_mb` sets the maximum size (in megabytes) for the local email body cache. When this limit is reached, least recently accessed cached emails are evicted across all folders to make room for new ones. Defaults to `100` MB if not specified.
 
 ## Data Locations

i18n/locales/ar.json 🔗

@@ -153,6 +153,7 @@
       "disable_notifications": "تعطيل الإشعارات",
       "enable_split_pane": "عرض مقسم",
       "enable_threaded": "عرض المحادثات",
+      "enable_detailed_dates": "تواريخ مفصلة",
       "date_format": "تنسيق التاريخ",
       "language": "اللغة",
       "signature": "تعديل التوقيع",

i18n/locales/de.json 🔗

@@ -149,6 +149,7 @@
       "disable_notifications": "Benachrichtigungen Deaktivieren",
       "enable_split_pane": "Geteilte Ansicht",
       "enable_threaded": "Konversations-Threads",
+      "enable_detailed_dates": "Detaillierte Datumsangaben",
       "date_format": "Datumsformat",
       "language": "Sprache",
       "signature": "Signatur Bearbeiten",

i18n/locales/en.json 🔗

@@ -149,6 +149,7 @@
       "disable_notifications": "Disable Notifications",
       "enable_split_pane": "Split Pane View",
       "enable_threaded": "Threaded Conversation View",
+      "enable_detailed_dates": "Detailed Dates",
       "date_format": "Date Format",
       "language": "Language",
       "signature": "Edit Signature",

i18n/locales/es.json 🔗

@@ -149,6 +149,7 @@
       "disable_notifications": "Deshabilitar Notificaciones",
       "enable_split_pane": "Vista dividida",
       "enable_threaded": "Vista de conversación",
+      "enable_detailed_dates": "Fechas detalladas",
       "date_format": "Formato de Fecha",
       "language": "Idioma",
       "signature": "Editar Firma",

i18n/locales/fr.json 🔗

@@ -149,6 +149,7 @@
       "disable_notifications": "Désactiver les Notifications",
       "enable_split_pane": "Vue divisée",
       "enable_threaded": "Vue par conversation",
+      "enable_detailed_dates": "Dates détaillées",
       "date_format": "Format de Date",
       "language": "Langue",
       "signature": "Modifier la Signature",

i18n/locales/ja.json 🔗

@@ -147,6 +147,7 @@
       "disable_notifications": "通知を無効化",
       "enable_split_pane": "分割ビュー",
       "enable_threaded": "スレッド表示",
+      "enable_detailed_dates": "詳細な日付",
       "date_format": "日付形式",
       "language": "言語",
       "signature": "署名を編集",

i18n/locales/pl.json 🔗

@@ -153,6 +153,7 @@
       "disable_notifications": "Wyłącz Powiadomienia",
       "enable_split_pane": "Widok podzielony",
       "enable_threaded": "Widok wątków",
+      "enable_detailed_dates": "Szczegółowe daty",
       "date_format": "Format Daty",
       "language": "Język",
       "signature": "Edytuj Podpis",

i18n/locales/pt.json 🔗

@@ -149,6 +149,7 @@
       "disable_notifications": "Desativar Notificações",
       "enable_split_pane": "Vista dividida",
       "enable_threaded": "Vista de conversação",
+      "enable_detailed_dates": "Datas detalhadas",
       "date_format": "Formato de Data",
       "language": "Idioma",
       "signature": "Editar Assinatura",

i18n/locales/ru.json 🔗

@@ -153,6 +153,7 @@
       "disable_notifications": "Отключить Уведомления",
       "enable_split_pane": "Разделённый вид",
       "enable_threaded": "Просмотр беседами",
+      "enable_detailed_dates": "Подробные даты",
       "date_format": "Формат Даты",
       "language": "Язык",
       "signature": "Редактировать Подпись",

i18n/locales/uk.json 🔗

@@ -151,6 +151,7 @@
       "disable_notifications": "Вимкнути сповіщення",
       "enable_split_pane": "Розділений вигляд",
       "enable_threaded": "Перегляд розмов",
+      "enable_detailed_dates": "Детальні дати",
       "date_format": "Формат дати",
       "language": "Мова",
       "signature": "Редагувати підпис",

i18n/locales/zh.json 🔗

@@ -147,6 +147,7 @@
       "disable_notifications": "禁用通知",
       "enable_split_pane": "分屏视图",
       "enable_threaded": "会话视图",
+      "enable_detailed_dates": "详细日期",
       "date_format": "日期格式",
       "language": "语言",
       "signature": "编辑签名",

main.go 🔗

@@ -493,6 +493,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
 		m.folderInbox.SetDateFormat(m.config.GetDateFormat())
+		m.folderInbox.SetDetailedDates(m.config.EnableDetailedDates)
 		m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded)
 		// Use cached INBOX emails for instant display (memory first, then disk)
 		if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
@@ -1088,6 +1089,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 		if m.folderInbox != nil {
+			m.folderInbox.SetDateFormat(m.config.GetDateFormat())
+			m.folderInbox.SetDetailedDates(m.config.EnableDetailedDates)
 			m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded)
 		}
 		return m, nil

tui/folder_inbox.go 🔗

@@ -133,6 +133,13 @@ func (m *FolderInbox) SetDateFormat(layout string) {
 	}
 }
 
+// SetDetailedDates propagates the detailed date display toggle.
+func (m *FolderInbox) SetDetailedDates(enabled bool) {
+	if m.inbox != nil {
+		m.inbox.SetDetailedDates(enabled)
+	}
+}
+
 // SetDefaultThreaded propagates the global default threading toggle.
 func (m *FolderInbox) SetDefaultThreaded(v bool) {
 	if m.inbox != nil {

tui/inbox.go 🔗

@@ -105,10 +105,12 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 
 	// Format and right-align date
 	layout := ""
+	detailedDates := false
 	if d.inbox != nil {
 		layout = d.inbox.dateFormat
+		detailedDates = d.inbox.detailedDates
 	}
-	dateStr := formatRelativeDate(i.date, layout)
+	dateStr := formatInboxDate(i.date, layout, detailedDates)
 	listWidth := m.Width() - 2 // account for PaddingLeft(2) in itemStyle
 	isSelected := index == m.Index()
 
@@ -215,14 +217,18 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 	fmt.Fprint(w, fn(str+strings.Repeat(" ", padding)+styledDate))
 }
 
-// formatRelativeDate formats a time as relative if within the last week,
-// otherwise as an absolute date using the caller-supplied Go time layout.
+// formatInboxDate formats a time as relative unless detailed dates are enabled
+// or the timestamp is older than a week. Absolute dates use the caller-supplied
+// Go time layout.
 // When layout is empty, falls back to the built-in short/long defaults.
-func formatRelativeDate(timestamp time.Time, layout string) string {
+func formatInboxDate(timestamp time.Time, layout string, detailedDates bool) string {
 	if timestamp.IsZero() {
 		return ""
 	}
 	now := time.Now()
+	if detailedDates {
+		return formatAbsoluteDate(timestamp, layout, now)
+	}
 	d := now.Sub(timestamp)
 
 	switch {
@@ -238,15 +244,19 @@ func formatRelativeDate(timestamp time.Time, layout string) string {
 		days := int(d.Hours() / 24)
 		return tn("time.day_ago", days, map[string]interface{}{"count": days})
 	default:
-		timestamp = timestamp.Local()
-		if layout != "" {
-			return timestamp.Format(layout)
-		}
-		if timestamp.Year() == now.Year() {
-			return timestamp.Format("Jan 02")
-		}
-		return timestamp.Format("Jan 02, 2006")
+		return formatAbsoluteDate(timestamp, layout, now)
+	}
+}
+
+func formatAbsoluteDate(timestamp time.Time, layout string, now time.Time) string {
+	timestamp = timestamp.Local()
+	if layout != "" {
+		return timestamp.Format(layout)
+	}
+	if timestamp.Year() == now.Year() {
+		return timestamp.Format("Jan 02")
 	}
+	return timestamp.Format("Jan 02, 2006")
 }
 
 // parseSenderName extracts the display name from a "Name <email>" string,
@@ -332,7 +342,8 @@ type Inbox struct {
 
 	// dateFormat is the Go reference-time layout used for absolute dates
 	// older than a week. When empty, the built-in defaults apply.
-	dateFormat string
+	dateFormat    string
+	detailedDates bool
 }
 
 // SetDateFormat configures the Go time layout used to render absolute
@@ -342,6 +353,13 @@ func (m *Inbox) SetDateFormat(layout string) {
 	m.dateFormat = layout
 }
 
+// SetDetailedDates configures whether the email list should always render
+// absolute dates instead of recent relative dates.
+func (m *Inbox) SetDetailedDates(enabled bool) {
+	m.detailedDates = enabled
+	m.updateList()
+}
+
 func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
 	return NewInboxWithMailbox(emails, accounts, MailboxInbox)
 }

tui/settings_general.go 🔗

@@ -24,6 +24,7 @@ func (m *Settings) buildGeneralOptions() []generalOption {
 		{"settings_general.disable_notifications", onOff(m.cfg.DisableNotifications), "Turn off desktop notifications for new mail.", false, ""},
 		{"settings_general.enable_split_pane", onOff(m.cfg.EnableSplitPane), "View inbox and email side-by-side.", false, ""},
 		{"settings_general.enable_threaded", onOff(m.cfg.EnableThreaded), "Group emails into conversations by reply chain. Per-folder overrides are kept.", false, ""},
+		{"settings_general.enable_detailed_dates", onOff(m.cfg.EnableDetailedDates), "Show detailed inbox dates.", false, ""},
 		{"settings_general.date_format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed.", false, ""},
 		{"settings_general.language", getLanguageLabel(m.cfg.GetLanguage()), "Change the interface language. Changes apply instantly.", false, ""},
 		{"settings_general.signature", getSignatureStatus(), "Configure the global signature appended to your outgoing emails.", false, ""},
@@ -87,7 +88,11 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 				m.cfg.EnableThreaded = !m.cfg.EnableThreaded
 				_ = config.SaveConfig(m.cfg)
 				saved = true
-			case 5: // Date Format
+			case 5: // Detailed Dates
+				m.cfg.EnableDetailedDates = !m.cfg.EnableDetailedDates
+				_ = config.SaveConfig(m.cfg)
+				saved = true
+			case 6: // Date Format
 				switch m.cfg.DateFormat {
 				case config.DateFormatEU:
 					m.cfg.DateFormat = config.DateFormatUS
@@ -98,7 +103,7 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 				}
 				_ = config.SaveConfig(m.cfg)
 				saved = true
-			case 6: // Language
+			case 7: // Language
 				// Cycle through available languages
 				langs := i18n.LanguageCodes()
 				currentLang := m.cfg.GetLanguage()
@@ -119,7 +124,7 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 					func() tea.Msg { return ConfigSavedMsg{} },
 					func() tea.Msg { return LanguageChangedMsg{} },
 				)
-			case 7: // Edit Signature
+			case 8: // Edit Signature
 				if msg.String() == "enter" || msg.String() == "right" || msg.String() == "l" {
 					return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
 				}