Detailed changes
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"github.com/google/uuid"
"github.com/zalando/go-keyring"
@@ -12,6 +13,21 @@ import (
const keyringServiceName = "matcha-email-client"
+// Date format presets use human-readable tokens. Supported tokens:
+//
+// YYYY (4-digit year), YY (2-digit year)
+// MM (month, or minutes when following an hour token + colon)
+// mm (minutes, explicit)
+// DD (day)
+// HH (24-hour), hh (12-hour, zero-padded)
+// SS, ss (seconds)
+// AM, PM (meridiem marker)
+const (
+ DateFormatISO = "YYYY-MM-DD HH:MM"
+ DateFormatUS = "MM/DD/YYYY hh:MM AM"
+ DateFormatEU = "DD/MM/YYYY HH:MM"
+)
+
// Account stores the configuration for a single email account.
type Account struct {
ID string `json:"id"`
@@ -69,6 +85,67 @@ type Config struct {
DisableNotifications bool `json:"disable_notifications,omitempty"`
Theme string `json:"theme,omitempty"`
MailingLists []MailingList `json:"mailing_lists,omitempty"`
+ DateFormat string `json:"date_format,omitempty"`
+}
+
+// GetDateFormat returns the Go time reference layout translated from the
+// user's configured human-readable format. Defaults to EU when unset.
+func (c *Config) GetDateFormat() string {
+ f := c.DateFormat
+ if f == "" {
+ f = DateFormatEU
+ }
+ return translateDateFormat(f)
+}
+
+// translateDateFormat converts a human-readable format string (e.g.
+// "DD/MM/YYYY HH:MM") into a Go reference-time layout usable by
+// time.Format. MM is disambiguated by context: when it directly follows
+// an hour token plus ":", it maps to minutes; otherwise to month.
+func translateDateFormat(f string) string {
+ var b strings.Builder
+ i := 0
+ for i < len(f) {
+ rest := f[i:]
+ switch {
+ case strings.HasPrefix(rest, "YYYY"):
+ b.WriteString("2006")
+ i += 4
+ case strings.HasPrefix(rest, "YY"):
+ b.WriteString("06")
+ i += 2
+ case strings.HasPrefix(rest, "DD"):
+ b.WriteString("02")
+ i += 2
+ case strings.HasPrefix(rest, "HH"):
+ b.WriteString("15")
+ i += 2
+ case strings.HasPrefix(rest, "hh"):
+ b.WriteString("03")
+ i += 2
+ case strings.HasPrefix(rest, "mm"):
+ b.WriteString("04")
+ i += 2
+ case strings.HasPrefix(rest, "SS"), strings.HasPrefix(rest, "ss"):
+ b.WriteString("05")
+ i += 2
+ case strings.HasPrefix(rest, "MM"):
+ cur := b.String()
+ if strings.HasSuffix(cur, "15:") || strings.HasSuffix(cur, "03:") {
+ b.WriteString("04")
+ } else {
+ b.WriteString("01")
+ }
+ i += 2
+ case strings.HasPrefix(rest, "AM"), strings.HasPrefix(rest, "PM"):
+ b.WriteString("PM")
+ i += 2
+ default:
+ b.WriteByte(f[i])
+ i++
+ }
+ }
+ return b.String()
}
// GetIMAPServer returns the IMAP server address for the account.
@@ -291,6 +368,7 @@ type secureDiskConfig struct {
DisableNotifications bool `json:"disable_notifications,omitempty"`
Theme string `json:"theme,omitempty"`
MailingLists []MailingList `json:"mailing_lists,omitempty"`
+ DateFormat string `json:"date_format,omitempty"`
}
// SaveConfig saves the given configuration to the config file and passwords to the keyring.
@@ -326,6 +404,7 @@ func SaveConfig(config *Config) error {
DisableNotifications: config.DisableNotifications,
Theme: config.Theme,
MailingLists: config.MailingLists,
+ DateFormat: config.DateFormat,
}
for _, acc := range config.Accounts {
sdc.Accounts = append(sdc.Accounts, secureDiskAccount{
@@ -417,6 +496,7 @@ func LoadConfig() (*Config, error) {
DisableNotifications bool `json:"disable_notifications,omitempty"`
Theme string `json:"theme,omitempty"`
MailingLists []MailingList `json:"mailing_lists,omitempty"`
+ DateFormat string `json:"date_format,omitempty"`
}
var raw diskConfig
@@ -449,6 +529,7 @@ func LoadConfig() (*Config, error) {
config.DisableNotifications = raw.DisableNotifications
config.Theme = raw.Theme
config.MailingLists = raw.MailingLists
+ config.DateFormat = raw.DateFormat
for _, rawAcc := range raw.Accounts {
acc := Account{
ID: rawAcc.ID,
@@ -3,6 +3,7 @@ package config
import (
"reflect"
"testing"
+ "time"
"github.com/zalando/go-keyring"
)
@@ -312,3 +313,49 @@ func TestAccountSendIdentityHelpers(t *testing.T) {
}
})
}
+
+func TestTranslateDateFormat(t *testing.T) {
+ testCases := []struct {
+ name string
+ input string
+ want string
+ sample string // expected output of time.Format for a fixed sample instant
+ }{
+ {"EU preset", DateFormatEU, "02/01/2006 15:04", "17/04/2026 09:05"},
+ {"US preset", DateFormatUS, "01/02/2006 03:04 PM", "04/17/2026 09:05 AM"},
+ {"ISO preset", DateFormatISO, "2006-01-02 15:04", "2026-04-17 09:05"},
+ {"seconds", "HH:MM:SS", "15:04:05", "09:05:00"},
+ {"explicit minutes", "YYYY-MM-DD HH:mm", "2006-01-02 15:04", "2026-04-17 09:05"},
+ {"2-digit year", "DD/MM/YY", "02/01/06", "17/04/26"},
+ {"literal passthrough", "some text", "some text", "some text"},
+ }
+
+ sample := time.Date(2026, 4, 17, 9, 5, 0, 0, time.UTC)
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := translateDateFormat(tc.input)
+ if got != tc.want {
+ t.Fatalf("translateDateFormat(%q) = %q, want %q", tc.input, got, tc.want)
+ }
+ if rendered := sample.Format(got); rendered != tc.sample {
+ t.Fatalf("sample.Format(%q) = %q, want %q", got, rendered, tc.sample)
+ }
+ })
+ }
+}
+
+func TestConfigGetDateFormatDefault(t *testing.T) {
+ c := &Config{}
+ got := c.GetDateFormat()
+ want := translateDateFormat(DateFormatEU)
+ if got != want {
+ t.Fatalf("GetDateFormat() with empty DateFormat = %q, want %q", got, want)
+ }
+}
+
+func TestConfigGetDateFormatCustom(t *testing.T) {
+ c := &Config{DateFormat: "DD/MM/YYYY HH:MM"}
+ if got, want := c.GetDateFormat(), "02/01/2006 15:04"; got != want {
+ t.Fatalf("GetDateFormat() = %q, want %q", got, want)
+ }
+}
@@ -381,6 +381,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cachedFolders = []string{"INBOX"}
}
m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
+ m.folderInbox.SetDateFormat(m.config.GetDateFormat())
// Use cached INBOX emails for instant display (memory first, then disk)
if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
m.folderInbox.SetEmails(cached, m.config.Accounts)
@@ -96,6 +96,13 @@ func sortFolders(folders []string) []string {
return sorted
}
+// SetDateFormat propagates the configured date layout to the inner inbox.
+func (m *FolderInbox) SetDateFormat(layout string) {
+ if m.inbox != nil {
+ m.inbox.SetDateFormat(layout)
+ }
+}
+
// NewFolderInbox creates a new FolderInbox with the given folders and accounts.
func NewFolderInbox(folders []string, accounts []config.Account) *FolderInbox {
folders = sortFolders(folders)
@@ -74,7 +74,11 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
}
// Format and right-align date
- dateStr := formatRelativeDate(i.date)
+ layout := ""
+ if d.inbox != nil {
+ layout = d.inbox.dateFormat
+ }
+ dateStr := formatRelativeDate(i.date, layout)
styledDate := dateStyle.Render(dateStr)
if i.isRead {
styledDate = readEmailStyle.Render(dateStr)
@@ -159,8 +163,9 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
}
// formatRelativeDate formats a time as relative if within the last week,
-// otherwise as an absolute date.
-func formatRelativeDate(t time.Time) string {
+// otherwise as an absolute date using the caller-supplied Go time layout.
+// When layout is empty, falls back to the built-in short/long defaults.
+func formatRelativeDate(t time.Time, layout string) string {
if t.IsZero() {
return ""
}
@@ -189,6 +194,9 @@ func formatRelativeDate(t time.Time) string {
}
return fmt.Sprintf("%d days ago", days)
default:
+ if layout != "" {
+ return t.Format(layout)
+ }
if t.Year() == now.Year() {
return t.Format("Jan 02")
}
@@ -269,6 +277,17 @@ type Inbox struct {
visualAnchor int // Index where visual selection started
selectedUIDs map[uint32]string // map[uid]accountID for selected emails
selectionOrder []uint32 // Ordered list of UIDs for display
+
+ // dateFormat is the Go reference-time layout used for absolute dates
+ // older than a week. When empty, the built-in defaults apply.
+ dateFormat string
+}
+
+// SetDateFormat configures the Go time layout used to render absolute
+// dates in the email list. Pass the value returned by
+// config.Config.GetDateFormat.
+func (m *Inbox) SetDateFormat(layout string) {
+ m.dateFormat = layout
}
func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {