feat: localization (#842)

Drew Smirnoff , Lea , Steve , and Andriy Chernov created

## What?

<!-- Describe what this PR changes. Keep it concise — what code was
added, removed, or modified? -->

Adds support for several languages, with easy-to-manage JSON format.

Includes a new library `github.com/floatpane/matcha/i18n` a self-written
library by us.

## Why?

<!-- Explain the motivation behind this change. What problem does it
solve, or what addition does it enable? Link related issues if
applicable. -->

Users would like support across their favorite languages.

---------

Signed-off-by: drew <me@andrinoff.com>
Co-authored-by: Lea <lea@floatpane.com>
Co-authored-by: Steve <steve@floatpane.com>
Co-authored-by: Andriy Chernov <andriy@floatpane.com>

Change summary

config/config.go           |  11 +
i18n/bundle.go             | 120 +++++++++++++++++
i18n/cache.go              |  66 +++++++++
i18n/context.go            |  24 +++
i18n/date_formatter.go     | 119 +++++++++++++++++
i18n/detector.go           |  80 +++++++++++
i18n/embed.go              |   8 +
i18n/errors.go             |  23 +++
i18n/fallback.go           |  66 +++++++++
i18n/formatter.go          |  67 +++++++++
i18n/init.go               |  20 ++
i18n/interpolator.go       |  45 ++++++
i18n/languages/ar.go       |  17 ++
i18n/languages/base.go     |  14 ++
i18n/languages/de.go       |  17 ++
i18n/languages/en.go       |  17 ++
i18n/languages/es.go       |  17 ++
i18n/languages/fr.go       |  17 ++
i18n/languages/ja.go       |  17 ++
i18n/languages/pl.go       |  17 ++
i18n/languages/pt.go       |  17 ++
i18n/languages/ru.go       |  17 ++
i18n/languages/uk.go       |  17 ++
i18n/languages/zh.go       |  17 ++
i18n/loader.go             | 101 ++++++++++++++
i18n/locale.go             |  74 ++++++++++
i18n/locales/ar.json       | 275 ++++++++++++++++++++++++++++++++++++++++
i18n/locales/de.json       | 253 ++++++++++++++++++++++++++++++++++++
i18n/locales/en.json       | 253 ++++++++++++++++++++++++++++++++++++
i18n/locales/es.json       | 253 ++++++++++++++++++++++++++++++++++++
i18n/locales/fr.json       | 253 ++++++++++++++++++++++++++++++++++++
i18n/locales/ja.json       | 242 +++++++++++++++++++++++++++++++++++
i18n/locales/pl.json       | 275 ++++++++++++++++++++++++++++++++++++++++
i18n/locales/pt.json       | 253 ++++++++++++++++++++++++++++++++++++
i18n/locales/ru.json       | 275 ++++++++++++++++++++++++++++++++++++++++
i18n/locales/uk.json       | 264 ++++++++++++++++++++++++++++++++++++++
i18n/locales/zh.json       | 242 +++++++++++++++++++++++++++++++++++
i18n/localizer.go          | 104 +++++++++++++++
i18n/manager.go            | 173 +++++++++++++++++++++++++
i18n/message.go            |  88 ++++++++++++
i18n/parser.go             | 101 ++++++++++++++
i18n/plural_rules.go       | 172 +++++++++++++++++++++++++
i18n/pluralizer.go         |  59 ++++++++
i18n/registry.go           |  68 +++++++++
i18n/template.go           |  91 +++++++++++++
i18n/validator.go          | 159 +++++++++++++++++++++++
main.go                    |  35 ++++
tui/choice.go              |  41 +++--
tui/composer.go            |  50 +++---
tui/folder_inbox.go        |  10 
tui/i18n_helper.go         |  21 +++
tui/inbox.go               |  43 ++---
tui/password_prompt.go     |   8 
tui/settings.go            |  16 +
tui/settings_accounts.go   |   8 
tui/settings_encryption.go |  30 ++--
tui/settings_general.go    |  58 +++++--
tui/settings_lists.go      |  17 +-
tui/settings_theme.go      |  12 
tui/theme.go               |   2 
60 files changed, 5,091 insertions(+), 138 deletions(-)

Detailed changes

config/config.go 🔗

@@ -87,6 +87,7 @@ type Config struct {
 	Theme                string        `json:"theme,omitempty"`
 	MailingLists         []MailingList `json:"mailing_lists,omitempty"`
 	DateFormat           string        `json:"date_format,omitempty"`
+	Language             string        `json:"language,omitempty"` // Language code (e.g., "en", "es", "de")
 }
 
 // GetDateFormat returns the Go time reference layout translated from the
@@ -99,6 +100,14 @@ func (c *Config) GetDateFormat() string {
 	return translateDateFormat(f)
 }
 
+// GetLanguage returns the configured language code, defaulting to "en".
+func (c *Config) GetLanguage() string {
+	if c.Language == "" {
+		return "en"
+	}
+	return c.Language
+}
+
 // 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
@@ -505,6 +514,7 @@ func LoadConfig() (*Config, error) {
 		Theme                string        `json:"theme,omitempty"`
 		MailingLists         []MailingList `json:"mailing_lists,omitempty"`
 		DateFormat           string        `json:"date_format,omitempty"`
+		Language             string        `json:"language,omitempty"`
 	}
 
 	var raw diskConfig
@@ -538,6 +548,7 @@ func LoadConfig() (*Config, error) {
 	config.Theme = raw.Theme
 	config.MailingLists = raw.MailingLists
 	config.DateFormat = raw.DateFormat
+	config.Language = raw.Language
 	for _, rawAcc := range raw.Accounts {
 		acc := Account{
 			ID:                 rawAcc.ID,

i18n/bundle.go 🔗

@@ -0,0 +1,120 @@
+package i18n
+
+import (
+	"fmt"
+	"sync"
+)
+
+// Bundle holds all translation messages for all languages.
+type Bundle struct {
+	defaultLang string
+	messages    map[string]MessageMap // lang -> MessageMap
+	locales     map[string]*Locale    // lang -> Locale
+	mu          sync.RWMutex
+}
+
+// NewBundle creates a new Bundle with a default language.
+func NewBundle(defaultLang string) *Bundle {
+	return &Bundle{
+		defaultLang: defaultLang,
+		messages:    make(map[string]MessageMap),
+		locales:     make(map[string]*Locale),
+	}
+}
+
+// AddMessages adds translation messages for a language.
+func (b *Bundle) AddMessages(lang string, messages MessageMap) error {
+	if lang == "" {
+		return ErrInvalidLocale
+	}
+
+	b.mu.Lock()
+	defer b.mu.Unlock()
+
+	if b.messages[lang] == nil {
+		b.messages[lang] = make(MessageMap)
+	}
+
+	// Merge messages
+	for id, msg := range messages {
+		b.messages[lang][id] = msg
+	}
+
+	return nil
+}
+
+// GetMessage retrieves a message for a specific language and ID.
+func (b *Bundle) GetMessage(lang, id string) (*Message, error) {
+	b.mu.RLock()
+	defer b.mu.RUnlock()
+
+	langMessages, ok := b.messages[lang]
+	if !ok {
+		return nil, fmt.Errorf("%w: %s", ErrLanguageNotFound, lang)
+	}
+
+	msg, ok := langMessages[id]
+	if !ok {
+		return nil, fmt.Errorf("%w: %s", ErrMessageNotFound, id)
+	}
+
+	return msg, nil
+}
+
+// RegisterLocale registers a locale configuration.
+func (b *Bundle) RegisterLocale(locale *Locale) {
+	if locale == nil || locale.Code == "" {
+		return
+	}
+
+	b.mu.Lock()
+	defer b.mu.Unlock()
+
+	b.locales[locale.Code] = locale
+}
+
+// GetLocale retrieves a registered locale.
+func (b *Bundle) GetLocale(lang string) (*Locale, bool) {
+	b.mu.RLock()
+	defer b.mu.RUnlock()
+
+	locale, ok := b.locales[lang]
+	return locale, ok
+}
+
+// AvailableLanguages returns a list of all languages with loaded messages.
+func (b *Bundle) AvailableLanguages() []string {
+	b.mu.RLock()
+	defer b.mu.RUnlock()
+
+	langs := make([]string, 0, len(b.messages))
+	for lang := range b.messages {
+		langs = append(langs, lang)
+	}
+	return langs
+}
+
+// DefaultLanguage returns the default language code.
+func (b *Bundle) DefaultLanguage() string {
+	return b.defaultLang
+}
+
+// MessageCount returns the number of messages for a language.
+func (b *Bundle) MessageCount(lang string) int {
+	b.mu.RLock()
+	defer b.mu.RUnlock()
+
+	if messages, ok := b.messages[lang]; ok {
+		return len(messages)
+	}
+	return 0
+}
+
+// HasLanguage checks if a language has been loaded.
+func (b *Bundle) HasLanguage(lang string) bool {
+	b.mu.RLock()
+	defer b.mu.RUnlock()
+
+	_, ok := b.messages[lang]
+	return ok
+}

i18n/cache.go 🔗

@@ -0,0 +1,66 @@
+package i18n
+
+import "sync"
+
+// Cache provides thread-safe caching for translated strings.
+type Cache struct {
+	items map[string]string
+	mu    sync.RWMutex
+}
+
+// NewCache creates a new Cache.
+func NewCache() *Cache {
+	return &Cache{
+		items: make(map[string]string),
+	}
+}
+
+// Get retrieves a cached value.
+func (c *Cache) Get(key string) (string, bool) {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+
+	val, ok := c.items[key]
+	return val, ok
+}
+
+// Set stores a value in the cache.
+func (c *Cache) Set(key, value string) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	c.items[key] = value
+}
+
+// Clear removes all cached values.
+func (c *Cache) Clear() {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	c.items = make(map[string]string)
+}
+
+// Size returns the number of cached items.
+func (c *Cache) Size() int {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+
+	return len(c.items)
+}
+
+// Delete removes a specific key from the cache.
+func (c *Cache) Delete(key string) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	delete(c.items, key)
+}
+
+// Has checks if a key exists in the cache.
+func (c *Cache) Has(key string) bool {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+
+	_, ok := c.items[key]
+	return ok
+}

i18n/context.go 🔗

@@ -0,0 +1,24 @@
+package i18n
+
+import "context"
+
+type contextKey int
+
+const (
+	// localeContextKey is the key for storing locale in context.
+	localeContextKey contextKey = iota
+)
+
+// WithLocale returns a new context with the given locale.
+func WithLocale(ctx context.Context, locale string) context.Context {
+	return context.WithValue(ctx, localeContextKey, locale)
+}
+
+// LocaleFromContext extracts the locale from context.
+// Returns empty string if no locale is set.
+func LocaleFromContext(ctx context.Context) string {
+	if locale, ok := ctx.Value(localeContextKey).(string); ok {
+		return locale
+	}
+	return ""
+}

i18n/date_formatter.go 🔗

@@ -0,0 +1,119 @@
+package i18n
+
+import (
+	"fmt"
+	"time"
+)
+
+// DateFormatter formats dates and times according to locale rules.
+type DateFormatter struct {
+	locale *Locale
+}
+
+// NewDateFormatter creates a date formatter for a locale.
+func NewDateFormatter(locale *Locale) *DateFormatter {
+	return &DateFormatter{
+		locale: locale,
+	}
+}
+
+// FormatDate formats a time according to the given layout.
+func (f *DateFormatter) FormatDate(t time.Time, layout string) string {
+	return t.Format(layout)
+}
+
+// FormatTime formats just the time portion.
+func (f *DateFormatter) FormatTime(t time.Time) string {
+	return t.Format("15:04")
+}
+
+// FormatDateTime formats both date and time.
+func (f *DateFormatter) FormatDateTime(t time.Time) string {
+	return t.Format("2006-01-02 15:04")
+}
+
+// FormatRelative formats a time relative to now (e.g., "5 minutes ago").
+// This should use translated strings from the message catalog.
+func (f *DateFormatter) FormatRelative(t time.Time) string {
+	now := time.Now()
+	duration := now.Sub(t)
+
+	// Future times
+	if duration < 0 {
+		duration = -duration
+		return formatFutureDuration(duration)
+	}
+
+	// Past times
+	return formatPastDuration(duration)
+}
+
+// formatPastDuration formats a duration as "X ago".
+func formatPastDuration(d time.Duration) string {
+	seconds := int(d.Seconds())
+	minutes := seconds / 60
+	hours := minutes / 60
+	days := hours / 24
+
+	switch {
+	case seconds < 60:
+		return "just now"
+	case minutes == 1:
+		return "1 minute ago"
+	case minutes < 60:
+		return fmt.Sprintf("%d minutes ago", minutes)
+	case hours == 1:
+		return "1 hour ago"
+	case hours < 24:
+		return fmt.Sprintf("%d hours ago", hours)
+	case days == 1:
+		return "1 day ago"
+	case days < 7:
+		return fmt.Sprintf("%d days ago", days)
+	case days < 30:
+		weeks := days / 7
+		if weeks == 1 {
+			return "1 week ago"
+		}
+		return fmt.Sprintf("%d weeks ago", weeks)
+	case days < 365:
+		months := days / 30
+		if months == 1 {
+			return "1 month ago"
+		}
+		return fmt.Sprintf("%d months ago", months)
+	default:
+		years := days / 365
+		if years == 1 {
+			return "1 year ago"
+		}
+		return fmt.Sprintf("%d years ago", years)
+	}
+}
+
+// formatFutureDuration formats a duration as "in X".
+func formatFutureDuration(d time.Duration) string {
+	seconds := int(d.Seconds())
+	minutes := seconds / 60
+	hours := minutes / 60
+	days := hours / 24
+
+	switch {
+	case seconds < 60:
+		return "in a moment"
+	case minutes == 1:
+		return "in 1 minute"
+	case minutes < 60:
+		return fmt.Sprintf("in %d minutes", minutes)
+	case hours == 1:
+		return "in 1 hour"
+	case hours < 24:
+		return fmt.Sprintf("in %d hours", hours)
+	case days == 1:
+		return "in 1 day"
+	case days < 7:
+		return fmt.Sprintf("in %d days", days)
+	default:
+		return "in the future"
+	}
+}

i18n/detector.go 🔗

@@ -0,0 +1,80 @@
+package i18n
+
+import (
+	"os"
+	"strings"
+
+	"github.com/floatpane/matcha/config"
+)
+
+// DetectLanguage determines the language to use based on config and environment.
+func DetectLanguage(cfg *config.Config) string {
+	// 1. Check config first
+	if lang := detectFromConfig(cfg); lang != "" {
+		return normalizeLanguageCode(lang)
+	}
+
+	// 2. Check environment variables
+	if lang := detectFromEnv(); lang != "" {
+		return normalizeLanguageCode(lang)
+	}
+
+	// 3. Default to English
+	return "en"
+}
+
+// detectFromConfig gets language from configuration.
+func detectFromConfig(cfg *config.Config) string {
+	if cfg == nil {
+		return ""
+	}
+	return cfg.GetLanguage()
+}
+
+// detectFromEnv gets language from environment variables.
+func detectFromEnv() string {
+	// Check standard language environment variables
+	for _, envVar := range []string{"LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"} {
+		if lang := os.Getenv(envVar); lang != "" {
+			return lang
+		}
+	}
+	return ""
+}
+
+// normalizeLanguageCode converts various language code formats to a standard form.
+// Examples:
+//   - "en_US.UTF-8" -> "en"
+//   - "en-US" -> "en"
+//   - "pt_BR" -> "pt"
+func normalizeLanguageCode(code string) string {
+	if code == "" {
+		return ""
+	}
+
+	// Remove encoding (e.g., ".UTF-8")
+	if idx := strings.Index(code, "."); idx != -1 {
+		code = code[:idx]
+	}
+
+	// Replace underscore with hyphen
+	code = strings.ReplaceAll(code, "_", "-")
+
+	// Split on hyphen and take base language
+	parts := strings.Split(code, "-")
+	if len(parts) > 0 {
+		base := strings.ToLower(parts[0])
+
+		// Validate it's a known language
+		if HasLanguage(base) {
+			return base
+		}
+	}
+
+	return code
+}
+
+// isValidLanguage checks if a language code is registered.
+func isValidLanguage(code string) bool {
+	return HasLanguage(code)
+}

i18n/embed.go 🔗

@@ -0,0 +1,8 @@
+package i18n
+
+import "embed"
+
+// localeFS embeds all translation files from the locales directory.
+//
+//go:embed locales/*.json
+var localeFS embed.FS

i18n/errors.go 🔗

@@ -0,0 +1,23 @@
+package i18n
+
+import "errors"
+
+var (
+	// ErrLanguageNotFound is returned when a requested language is not available.
+	ErrLanguageNotFound = errors.New("language not found")
+
+	// ErrMessageNotFound is returned when a translation key does not exist.
+	ErrMessageNotFound = errors.New("message not found")
+
+	// ErrInvalidLocale is returned when a locale code is malformed.
+	ErrInvalidLocale = errors.New("invalid locale code")
+
+	// ErrLoadFailed is returned when translation files fail to load.
+	ErrLoadFailed = errors.New("failed to load translations")
+
+	// ErrParseFailed is returned when a translation file cannot be parsed.
+	ErrParseFailed = errors.New("failed to parse translation file")
+
+	// ErrNoDefaultLanguage is returned when no default language is set.
+	ErrNoDefaultLanguage = errors.New("no default language set")
+)

i18n/fallback.go 🔗

@@ -0,0 +1,66 @@
+package i18n
+
+import "strings"
+
+// FallbackChain defines a sequence of languages to try when looking up translations.
+type FallbackChain struct {
+	langs []string
+}
+
+// NewFallbackChain creates a new fallback chain with a preferred language and defaults.
+// Example: NewFallbackChain("pt-BR", "pt", "en") creates chain: pt-BR → pt → en
+func NewFallbackChain(preferred string, defaults ...string) *FallbackChain {
+	chain := &FallbackChain{
+		langs: make([]string, 0, len(defaults)+2),
+	}
+
+	// Add preferred language
+	if preferred != "" {
+		chain.langs = append(chain.langs, preferred)
+
+		// If preferred has region code (e.g., "en-US"), also add base (e.g., "en")
+		if parts := strings.Split(preferred, "-"); len(parts) > 1 {
+			base := parts[0]
+			if !contains(chain.langs, base) {
+				chain.langs = append(chain.langs, base)
+			}
+		}
+	}
+
+	// Add fallback languages
+	for _, lang := range defaults {
+		if lang != "" && !contains(chain.langs, lang) {
+			chain.langs = append(chain.langs, lang)
+		}
+	}
+
+	return chain
+}
+
+// Resolve attempts to find a message in the fallback chain.
+// Returns the message, the language it was found in, and any error.
+func (f *FallbackChain) Resolve(bundle *Bundle, key string) (*Message, string, error) {
+	for _, lang := range f.langs {
+		msg, err := bundle.GetMessage(lang, key)
+		if err == nil {
+			return msg, lang, nil
+		}
+	}
+
+	return nil, "", ErrMessageNotFound
+}
+
+// Languages returns the ordered list of languages in the fallback chain.
+func (f *FallbackChain) Languages() []string {
+	return f.langs
+}
+
+// contains checks if a slice contains a string.
+func contains(slice []string, item string) bool {
+	for _, s := range slice {
+		if s == item {
+			return true
+		}
+	}
+	return false
+}

i18n/formatter.go 🔗

@@ -0,0 +1,67 @@
+package i18n
+
+import (
+	"golang.org/x/text/language"
+	"golang.org/x/text/message"
+)
+
+// NumberFormatter formats numbers according to locale rules.
+type NumberFormatter struct {
+	locale  *Locale
+	printer *message.Printer
+}
+
+// NewNumberFormatter creates a formatter for a locale.
+func NewNumberFormatter(locale *Locale) *NumberFormatter {
+	tag := locale.Tag
+	if tag == language.Und {
+		tag = language.English
+	}
+
+	return &NumberFormatter{
+		locale:  locale,
+		printer: message.NewPrinter(tag),
+	}
+}
+
+// FormatInt formats an integer according to locale rules.
+func (f *NumberFormatter) FormatInt(n int) string {
+	return f.printer.Sprintf("%d", n)
+}
+
+// FormatInt64 formats an int64 according to locale rules.
+func (f *NumberFormatter) FormatInt64(n int64) string {
+	return f.printer.Sprintf("%d", n)
+}
+
+// FormatFloat formats a float64 with the specified precision.
+func (f *NumberFormatter) FormatFloat(n float64, precision int) string {
+	format := "%." + string(rune(precision+'0')) + "f"
+	return f.printer.Sprintf(format, n)
+}
+
+// FormatPercent formats a number as a percentage (0.5 -> "50%").
+func (f *NumberFormatter) FormatPercent(n float64) string {
+	return f.printer.Sprintf("%.0f%%", n*100)
+}
+
+// FormatFileSize formats a byte count as a human-readable size.
+func (f *NumberFormatter) FormatFileSize(bytes int64) string {
+	const unit = 1024
+	if bytes < unit {
+		return f.printer.Sprintf("%d B", bytes)
+	}
+
+	div, exp := int64(unit), 0
+	for n := bytes / unit; n >= unit; n /= unit {
+		div *= unit
+		exp++
+	}
+
+	units := []string{"KB", "MB", "GB", "TB", "PB"}
+	if exp >= len(units) {
+		exp = len(units) - 1
+	}
+
+	return f.printer.Sprintf("%.1f %s", float64(bytes)/float64(div), units[exp])
+}

i18n/init.go 🔗

@@ -0,0 +1,20 @@
+package i18n
+
+// Package i18n provides internationalization support for the matcha email client.
+//
+// Usage:
+//   import "github.com/floatpane/matcha/i18n"
+//   import _ "github.com/floatpane/matcha/i18n/languages" // Register all languages
+//
+//   func main() {
+//       // Initialize i18n
+//       if err := i18n.Init("en"); err != nil {
+//           log.Fatal(err)
+//       }
+//
+//       // Set language (optional, can also be done via config)
+//       i18n.GetManager().SetLanguage("es")
+//
+//       // Translate
+//       text := i18n.GetManager().T("composer.title")
+//   }

i18n/interpolator.go 🔗

@@ -0,0 +1,45 @@
+package i18n
+
+import (
+	"fmt"
+	"strings"
+)
+
+// Interpolate replaces placeholders in a template string with values from data.
+// Supports {key} syntax for variable interpolation.
+func Interpolate(template string, data map[string]interface{}) string {
+	if data == nil || len(data) == 0 {
+		return template
+	}
+
+	result := template
+	for key, value := range data {
+		placeholder := "{" + key + "}"
+		replacement := formatValue(value)
+		result = strings.ReplaceAll(result, placeholder, replacement)
+	}
+
+	return result
+}
+
+// formatValue converts a value to its string representation.
+func formatValue(v interface{}) string {
+	if v == nil {
+		return ""
+	}
+
+	switch val := v.(type) {
+	case string:
+		return val
+	case int:
+		return fmt.Sprintf("%d", val)
+	case int64:
+		return fmt.Sprintf("%d", val)
+	case float64:
+		return fmt.Sprintf("%g", val)
+	case bool:
+		return fmt.Sprintf("%t", val)
+	default:
+		return fmt.Sprintf("%v", val)
+	}
+}

i18n/languages/ar.go 🔗

@@ -0,0 +1,17 @@
+package languages
+
+import (
+	"github.com/floatpane/matcha/i18n"
+	"golang.org/x/text/language"
+)
+
+func init() {
+	i18n.RegisterLanguage(&i18n.Locale{
+		Tag:        language.Arabic,
+		Code:       "ar",
+		Name:       "Arabic",
+		NativeName: "العربية",
+		Direction:  "rtl",
+		PluralFunc: i18n.ArabicPlural,
+	})
+}

i18n/languages/base.go 🔗

@@ -0,0 +1,14 @@
+package languages
+
+import "github.com/floatpane/matcha/i18n"
+
+// LanguageInfo provides metadata about a language.
+type LanguageInfo struct {
+	Code       string
+	Name       string
+	NativeName string
+	Direction  string
+	PluralFunc i18n.PluralFunc
+}
+
+// All available languages are registered via init() functions in their respective files.

i18n/languages/de.go 🔗

@@ -0,0 +1,17 @@
+package languages
+
+import (
+	"github.com/floatpane/matcha/i18n"
+	"golang.org/x/text/language"
+)
+
+func init() {
+	i18n.RegisterLanguage(&i18n.Locale{
+		Tag:        language.German,
+		Code:       "de",
+		Name:       "German",
+		NativeName: "Deutsch",
+		Direction:  "ltr",
+		PluralFunc: i18n.GermanPlural,
+	})
+}

i18n/languages/en.go 🔗

@@ -0,0 +1,17 @@
+package languages
+
+import (
+	"github.com/floatpane/matcha/i18n"
+	"golang.org/x/text/language"
+)
+
+func init() {
+	i18n.RegisterLanguage(&i18n.Locale{
+		Tag:        language.English,
+		Code:       "en",
+		Name:       "English",
+		NativeName: "English",
+		Direction:  "ltr",
+		PluralFunc: i18n.EnglishPlural,
+	})
+}

i18n/languages/es.go 🔗

@@ -0,0 +1,17 @@
+package languages
+
+import (
+	"github.com/floatpane/matcha/i18n"
+	"golang.org/x/text/language"
+)
+
+func init() {
+	i18n.RegisterLanguage(&i18n.Locale{
+		Tag:        language.Spanish,
+		Code:       "es",
+		Name:       "Spanish",
+		NativeName: "Español",
+		Direction:  "ltr",
+		PluralFunc: i18n.SpanishPlural,
+	})
+}

i18n/languages/fr.go 🔗

@@ -0,0 +1,17 @@
+package languages
+
+import (
+	"github.com/floatpane/matcha/i18n"
+	"golang.org/x/text/language"
+)
+
+func init() {
+	i18n.RegisterLanguage(&i18n.Locale{
+		Tag:        language.French,
+		Code:       "fr",
+		Name:       "French",
+		NativeName: "Français",
+		Direction:  "ltr",
+		PluralFunc: i18n.FrenchPlural,
+	})
+}

i18n/languages/ja.go 🔗

@@ -0,0 +1,17 @@
+package languages
+
+import (
+	"github.com/floatpane/matcha/i18n"
+	"golang.org/x/text/language"
+)
+
+func init() {
+	i18n.RegisterLanguage(&i18n.Locale{
+		Tag:        language.Japanese,
+		Code:       "ja",
+		Name:       "Japanese",
+		NativeName: "日本語",
+		Direction:  "ltr",
+		PluralFunc: i18n.JapanesePlural,
+	})
+}

i18n/languages/pl.go 🔗

@@ -0,0 +1,17 @@
+package languages
+
+import (
+	"github.com/floatpane/matcha/i18n"
+	"golang.org/x/text/language"
+)
+
+func init() {
+	i18n.RegisterLanguage(&i18n.Locale{
+		Tag:        language.Polish,
+		Code:       "pl",
+		Name:       "Polish",
+		NativeName: "Polski",
+		Direction:  "ltr",
+		PluralFunc: i18n.PolishPlural,
+	})
+}

i18n/languages/pt.go 🔗

@@ -0,0 +1,17 @@
+package languages
+
+import (
+	"github.com/floatpane/matcha/i18n"
+	"golang.org/x/text/language"
+)
+
+func init() {
+	i18n.RegisterLanguage(&i18n.Locale{
+		Tag:        language.Portuguese,
+		Code:       "pt",
+		Name:       "Portuguese",
+		NativeName: "Português",
+		Direction:  "ltr",
+		PluralFunc: i18n.PortuguesePlural,
+	})
+}

i18n/languages/ru.go 🔗

@@ -0,0 +1,17 @@
+package languages
+
+import (
+	"github.com/floatpane/matcha/i18n"
+	"golang.org/x/text/language"
+)
+
+func init() {
+	i18n.RegisterLanguage(&i18n.Locale{
+		Tag:        language.Russian,
+		Code:       "ru",
+		Name:       "Russian",
+		NativeName: "Русский",
+		Direction:  "ltr",
+		PluralFunc: i18n.RussianPlural,
+	})
+}

i18n/languages/uk.go 🔗

@@ -0,0 +1,17 @@
+package languages
+
+import (
+	"github.com/floatpane/matcha/i18n"
+	"golang.org/x/text/language"
+)
+
+func init() {
+	i18n.RegisterLanguage(&i18n.Locale{
+		Tag:        language.Ukrainian,
+		Code:       "uk",
+		Name:       "Ukrainian",
+		NativeName: "Українська",
+		Direction:  "ltr",
+		PluralFunc: i18n.UkrainianPlural,
+	})
+}

i18n/languages/zh.go 🔗

@@ -0,0 +1,17 @@
+package languages
+
+import (
+	"github.com/floatpane/matcha/i18n"
+	"golang.org/x/text/language"
+)
+
+func init() {
+	i18n.RegisterLanguage(&i18n.Locale{
+		Tag:        language.Chinese,
+		Code:       "zh",
+		Name:       "Chinese",
+		NativeName: "中文",
+		Direction:  "ltr",
+		PluralFunc: i18n.ChinesePlural,
+	})
+}

i18n/loader.go 🔗

@@ -0,0 +1,101 @@
+package i18n
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+// LoadTranslations loads all translation files into a bundle.
+// First attempts to load from embedded files, then checks for external files.
+func LoadTranslations(bundle *Bundle) error {
+	// Load from embedded files
+	if err := loadFromEmbedded(bundle); err != nil {
+		return fmt.Errorf("%w: embedded load failed: %v", ErrLoadFailed, err)
+	}
+
+	return nil
+}
+
+// loadFromEmbedded loads translation files from the embedded filesystem.
+func loadFromEmbedded(bundle *Bundle) error {
+	entries, err := localeFS.ReadDir("locales")
+	if err != nil {
+		return err
+	}
+
+	for _, entry := range entries {
+		if entry.IsDir() {
+			continue
+		}
+
+		filename := entry.Name()
+		if !strings.HasSuffix(filename, ".json") {
+			continue
+		}
+
+		// Read file
+		data, err := localeFS.ReadFile(filepath.Join("locales", filename))
+		if err != nil {
+			continue
+		}
+
+		// Extract language code from filename (e.g., "en.json" -> "en")
+		lang := strings.TrimSuffix(filename, ".json")
+
+		// Load into bundle
+		if err := loadLanguageFile(bundle, lang, data); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// LoadFromDirectory loads translation files from a directory on disk.
+// This allows overriding embedded translations with external files.
+func LoadFromDirectory(bundle *Bundle, dir string) error {
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return fmt.Errorf("%w: %v", ErrLoadFailed, err)
+	}
+
+	for _, entry := range entries {
+		if entry.IsDir() {
+			continue
+		}
+
+		filename := entry.Name()
+		if !strings.HasSuffix(filename, ".json") {
+			continue
+		}
+
+		// Read file
+		path := filepath.Join(dir, filename)
+		data, err := os.ReadFile(path)
+		if err != nil {
+			continue
+		}
+
+		// Extract language code
+		lang := strings.TrimSuffix(filename, ".json")
+
+		// Load into bundle
+		if err := loadLanguageFile(bundle, lang, data); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// loadLanguageFile parses and loads a single language file into the bundle.
+func loadLanguageFile(bundle *Bundle, lang string, data []byte) error {
+	messages, err := ParseJSON(data)
+	if err != nil {
+		return fmt.Errorf("%w: language %s: %v", ErrParseFailed, lang, err)
+	}
+
+	return bundle.AddMessages(lang, messages)
+}

i18n/locale.go 🔗

@@ -0,0 +1,74 @@
+package i18n
+
+import (
+	"strings"
+
+	"golang.org/x/text/language"
+)
+
+// Locale represents a language/region configuration.
+type Locale struct {
+	// Tag is the BCP 47 language tag
+	Tag language.Tag
+
+	// Code is the short language code (e.g., "en", "es", "de")
+	Code string
+
+	// Name is the English name of the language
+	Name string
+
+	// NativeName is the language's name in its own language
+	NativeName string
+
+	// Direction is the text direction ("ltr" or "rtl")
+	Direction string
+
+	// PluralFunc is the plural rule function for this language
+	PluralFunc PluralFunc
+}
+
+// ParseLocale parses a language code and returns a Locale.
+// Supports formats like "en", "en-US", "en_US".
+func ParseLocale(code string) (*Locale, error) {
+	if code == "" {
+		return nil, ErrInvalidLocale
+	}
+
+	// Normalize separators
+	code = strings.ReplaceAll(code, "_", "-")
+
+	// Parse language tag
+	tag, err := language.Parse(code)
+	if err != nil {
+		return nil, ErrInvalidLocale
+	}
+
+	// Extract base language
+	base, _ := tag.Base()
+	langCode := base.String()
+
+	// Look up in registry
+	if locale, ok := GetLanguage(langCode); ok {
+		return locale, nil
+	}
+
+	// Return a basic locale if not registered
+	return &Locale{
+		Tag:        tag,
+		Code:       langCode,
+		Name:       langCode,
+		NativeName: langCode,
+		Direction:  "ltr",
+		PluralFunc: DefaultPlural,
+	}, nil
+}
+
+// String returns the string representation of the locale.
+func (l *Locale) String() string {
+	return l.Code
+}
+
+// IsRTL returns true if the locale uses right-to-left text direction.
+func (l *Locale) IsRTL() bool {
+	return l.Direction == "rtl"
+}

i18n/locales/ar.json 🔗

@@ -0,0 +1,275 @@
+{
+  "language": "ar",
+  "messages": {
+    "common": {
+      "yes": "نعم",
+      "no": "لا",
+      "cancel": "إلغاء",
+      "ok": "موافق",
+      "save": "حفظ",
+      "delete": "حذف",
+      "archive": "أرشفة",
+      "back": "رجوع",
+      "next": "التالي",
+      "previous": "السابق",
+      "loading": "جاري التحميل...",
+      "error": "خطأ",
+      "success": "نجاح"
+    },
+    "composer": {
+      "title": "إنشاء بريد إلكتروني جديد",
+      "from": "من",
+      "to_placeholder": "أدخل عناوين البريد الإلكتروني للمستلمين.",
+      "cc_placeholder": "مستلمو نسخة كربونية.",
+      "bcc_placeholder": "مستلمو نسخة كربونية مخفية.",
+      "subject_placeholder": "الموضوع",
+      "body_placeholder": "اكتب رسالتك...",
+      "signature": "التوقيع",
+      "signature_placeholder": "توقيع بريدك الإلكتروني.",
+      "attachments": "المرفقات",
+      "attachments_none": "لا يوجد",
+      "enter_to_add": "اضغط Enter للإضافة",
+      "encrypt_smime": "تشفير البريد الإلكتروني (S/MIME)",
+      "send": "إرسال",
+      "switchable": "قابل للتبديل",
+      "enter_to_switch": "اضغط Enter للتبديل",
+      "no_account": "لم يتم تكوين حساب",
+      "send_confirm": "اضغط Enter لإرسال البريد الإلكتروني.",
+      "help": "Markdown/HTML • tab/shift+tab: التنقل • ctrl+e: $EDITOR • esc: حفظ المسودة والخروج",
+      "exit_confirm": "هل أنت متأكد أنك تريد الخروج؟ سيتم حفظ هذه المسودة",
+      "sending": "جاري إرسال البريد الإلكتروني...",
+      "sent": "تم إرسال البريد الإلكتروني بنجاح",
+      "draft_saved": "تم حفظ المسودة"
+    },
+    "inbox": {
+      "title": "صندوق الوارد",
+      "all_accounts": "جميع الحسابات",
+      "sent": "المرسلة",
+      "trash": "سلة المهملات",
+      "archive": "الأرشيف",
+      "empty": "لا توجد رسائل",
+      "loading": "جاري تحميل الرسائل...",
+      "refreshing": "جاري التحديث...",
+      "visual_mode": "الوضع المرئي",
+      "delete": "حذف",
+      "archive": "أرشفة",
+      "refresh": "تحديث",
+      "reply": "رد",
+      "forward": "إعادة توجيه",
+      "move": "نقل",
+      "mark_read": "وضع علامة كمقروء",
+      "mark_unread": "وضع علامة كغير مقروء",
+      "help_visual": "v: الوضع المرئي • d: حذف • a: أرشفة",
+      "help_navigation": "j/k: التنقل • enter: فتح • r: تحديث"
+    },
+    "choice": {
+      "what_to_do": "ماذا تريد أن تفعل؟",
+      "compose": "إنشاء بريد إلكتروني",
+      "inbox": "عرض صندوق الوارد",
+      "calendar": "عرض التقويم",
+      "settings": "الإعدادات",
+      "marketplace": "متجر الإضافات",
+      "drafts": "المسودات",
+      "help": "استخدم ↑/↓ للتنقل، enter للاختيار، وctrl+c للخروج.",
+      "unknown": "غير معروف",
+      "update_available": "تحديث متاح: {latest} (المثبت: {current}) — قم بتشغيل `matcha update` للترقية"
+    },
+    "folder_inbox": {
+      "folders_title": "المجلدات",
+      "move_to_folder": "نقل إلى المجلد:",
+      "move_single": "نقل البريد الإلكتروني إلى المجلد:",
+      "move_multiple": {
+        "one": "نقل بريد إلكتروني {count} إلى المجلد:",
+        "few": "نقل {count} رسائل بريد إلكتروني إلى المجلد:",
+        "many": "نقل {count} رسالة بريد إلكتروني إلى المجلد:",
+        "other": "نقل {count} رسالة بريد إلكتروني إلى المجلد:"
+      },
+      "help": "j/k: التنقل  enter: نقل  esc: إلغاء",
+      "help_folders": "tab: المجلد التالي • shift+tab: المجلد السابق • m: نقل"
+    },
+    "login": {
+      "title": "حسابات البريد الإلكتروني",
+      "add_account": "إضافة حساب",
+      "edit_account": "تعديل الحساب",
+      "description": "أدخل بيانات اعتماد حساب البريد الإلكتروني الخاص بك.",
+      "protocol_label": "البروتوكول",
+      "protocol_placeholder": "البروتوكول (imap أو jmap أو pop3)",
+      "email_label": "البريد الإلكتروني",
+      "email_placeholder": "your.email@example.com",
+      "password_label": "كلمة المرور",
+      "password_placeholder": "كلمة المرور / كلمة مرور التطبيق",
+      "display_name_label": "الاسم المعروض",
+      "display_name_placeholder": "اسمك",
+      "imap_server_label": "خادم IMAP",
+      "smtp_server_label": "خادم SMTP",
+      "port_label": "المنفذ",
+      "save": "حفظ الحساب",
+      "delete": "حذف الحساب",
+      "delete_confirm": "حذف هذا الحساب؟",
+      "tip_protocol": "اختر البروتوكول: imap (افتراضي)، jmap، أو pop3.",
+      "tip_app_password": "بالنسبة لـ Gmail، استخدم كلمة مرور التطبيق بدلاً من كلمة المرور العادية."
+    },
+    "settings": {
+      "title": "الإعدادات",
+      "category_general": "عام",
+      "category_accounts": "الحسابات",
+      "category_theme": "المظهر",
+      "category_mailing_lists": "القوائم البريدية",
+      "category_encryption": "تشفير التطبيق",
+      "help_menu": "↑/↓: التنقل • يمين/enter: اختيار • esc: رجوع",
+      "help_content": "esc: العودة للقائمة"
+    },
+    "settings_accounts": {
+      "title": "إعدادات الحسابات",
+      "no_accounts": "لم يتم تكوين حسابات.",
+      "add_account": "إضافة حساب جديد",
+      "help": "↑/↓: التنقل • enter: تعديل إعدادات التشفير • e: تعديل الخادم • d: حذف"
+    },
+    "settings_theme": {
+      "title": "المظهر",
+      "current": "نشط",
+      "help": "↑/↓: التنقل • enter/مسافة: تطبيق المظهر"
+    },
+    "settings_mailing_lists": {
+      "title": "القوائم البريدية",
+      "no_lists": "لم يتم تكوين قوائم بريدية.",
+      "add_list": "إضافة قائمة بريدية جديدة",
+      "delete_confirm": "حذف القائمة البريدية؟",
+      "address_count": {
+        "one": "عنوان {count}",
+        "few": "{count} عناوين",
+        "many": "{count} عنواناً",
+        "other": "{count} عنوان"
+      },
+      "help": "↑/↓: التنقل • enter: اختيار • e: تعديل • d: حذف"
+    },
+    "settings_general": {
+      "title": "الإعدادات العامة",
+      "disable_images": "تعطيل عرض الصور",
+      "hide_tips": "إخفاء النصائح السياقية",
+      "disable_notifications": "تعطيل الإشعارات",
+      "date_format": "تنسيق التاريخ",
+      "language": "اللغة",
+      "signature": "تعديل التوقيع",
+      "signature_configured": "مكوّن",
+      "signature_not_configured": "غير مكوّن",
+      "on": "تشغيل",
+      "off": "إيقاف",
+      "restart_required": "يتطلب إعادة التشغيل لتطبيق تغيير اللغة"
+    },
+    "settings_encryption": {
+      "title": "تشفير التطبيق",
+      "enabled": "التشفير مفعّل حالياً.",
+      "disabled": "عيّن كلمة مرور لتشفير جميع البيانات.",
+      "password_label": "كلمة المرور:",
+      "confirm_label": "تأكيد كلمة المرور:",
+      "enable_button": "تفعيل التشفير",
+      "disable_button": "اضغط enter لتعطيل التشفير",
+      "disable_confirm": "تعطيل التشفير؟",
+      "disable_warning": "سيتم تخزين جميع البيانات بدون تشفير.",
+      "encrypting": "جاري تشفير البيانات...",
+      "error_empty": "لا يمكن أن تكون كلمة المرور فارغة",
+      "error_mismatch": "كلمات المرور غير متطابقة",
+      "help": "tab: التالي • enter: حفظ"
+    },
+    "password_prompt": {
+      "title": "Matcha مقفل",
+      "enter_password": "أدخل كلمة المرور",
+      "error_empty": "لا يمكن أن تكون كلمة المرور فارغة",
+      "error_incorrect": "كلمة مرور غير صحيحة",
+      "help": "enter: فتح القفل • ctrl+c: خروج"
+    },
+    "email_view": {
+      "from": "من",
+      "to": "إلى",
+      "cc": "نسخة",
+      "bcc": "نسخة مخفية",
+      "subject": "الموضوع",
+      "date": "التاريخ",
+      "attachments": "المرفقات",
+      "download": "تنزيل",
+      "save": "حفظ",
+      "reply": "رد",
+      "reply_all": "رد على الكل",
+      "forward": "إعادة توجيه",
+      "delete": "حذف",
+      "archive": "أرشفة",
+      "help": "r: رد • f: إعادة توجيه • d: حذف • a: أرشفة • esc: رجوع"
+    },
+    "calendar": {
+      "title": "التقويم",
+      "meeting": "اجتماع",
+      "event": "حدث",
+      "accept": "قبول",
+      "decline": "رفض",
+      "tentative": "مؤقت",
+      "rsvp_sent": "تم إرسال RSVP: {response}"
+    },
+    "marketplace": {
+      "title": "متجر الإضافات",
+      "installing": "جاري التثبيت...",
+      "installed": "مثبت",
+      "install": "تثبيت",
+      "error": "فشل التثبيت",
+      "help": "j/k: التنقل • enter: تثبيت • esc: رجوع"
+    },
+    "time": {
+      "just_now": "الآن",
+      "minute_ago": {
+        "one": "منذ دقيقة واحدة",
+        "few": "منذ دقيقتين",
+        "many": "منذ {count} دقيقة",
+        "other": "منذ {count} دقيقة"
+      },
+      "hour_ago": {
+        "one": "منذ ساعة واحدة",
+        "few": "منذ ساعتين",
+        "many": "منذ {count} ساعة",
+        "other": "منذ {count} ساعة"
+      },
+      "day_ago": {
+        "one": "منذ يوم واحد",
+        "few": "منذ يومين",
+        "many": "منذ {count} يوماً",
+        "other": "منذ {count} يوم"
+      },
+      "week_ago": {
+        "one": "منذ أسبوع واحد",
+        "few": "منذ أسبوعين",
+        "many": "منذ {count} أسبوعاً",
+        "other": "منذ {count} أسبوع"
+      },
+      "month_ago": {
+        "one": "منذ شهر واحد",
+        "few": "منذ شهرين",
+        "many": "منذ {count} شهراً",
+        "other": "منذ {count} شهر"
+      },
+      "year_ago": {
+        "one": "منذ سنة واحدة",
+        "few": "منذ سنتين",
+        "many": "منذ {count} سنة",
+        "other": "منذ {count} سنة"
+      },
+      "in_moment": "بعد لحظة",
+      "in_minute": {
+        "one": "بعد دقيقة واحدة",
+        "few": "بعد دقيقتين",
+        "many": "بعد {count} دقيقة",
+        "other": "بعد {count} دقيقة"
+      },
+      "in_hour": {
+        "one": "بعد ساعة واحدة",
+        "few": "بعد ساعتين",
+        "many": "بعد {count} ساعة",
+        "other": "بعد {count} ساعة"
+      },
+      "in_day": {
+        "one": "بعد يوم واحد",
+        "few": "بعد يومين",
+        "many": "بعد {count} يوماً",
+        "other": "بعد {count} يوم"
+      }
+    }
+  }
+}

i18n/locales/de.json 🔗

@@ -0,0 +1,253 @@
+{
+  "language": "de",
+  "messages": {
+    "common": {
+      "yes": "Ja",
+      "no": "Nein",
+      "cancel": "Abbrechen",
+      "ok": "OK",
+      "save": "Speichern",
+      "delete": "Löschen",
+      "archive": "Archivieren",
+      "back": "Zurück",
+      "next": "Weiter",
+      "previous": "Vorherige",
+      "loading": "Lädt...",
+      "error": "Fehler",
+      "success": "Erfolg"
+    },
+    "composer": {
+      "title": "Neue E-Mail Verfassen",
+      "from": "Von",
+      "to_placeholder": "E-Mail-Adressen der Empfänger eingeben.",
+      "cc_placeholder": "Kopie-Empfänger.",
+      "bcc_placeholder": "Blindkopie-Empfänger.",
+      "subject_placeholder": "Betreff",
+      "body_placeholder": "Verfassen Sie Ihre Nachricht...",
+      "signature": "Signatur",
+      "signature_placeholder": "Ihre E-Mail-Signatur.",
+      "attachments": "Anhänge",
+      "attachments_none": "Keine",
+      "enter_to_add": "Enter zum Hinzufügen",
+      "encrypt_smime": "E-Mail Verschlüsseln (S/MIME)",
+      "send": "Senden",
+      "switchable": "wechselbar",
+      "enter_to_switch": "Enter zum Wechseln",
+      "no_account": "kein Konto konfiguriert",
+      "send_confirm": "Drücken Sie Enter, um die E-Mail zu senden.",
+      "help": "Markdown/HTML • tab/shift+tab: navigieren • ctrl+e: $EDITOR • esc: Entwurf speichern & beenden",
+      "exit_confirm": "Sind Sie sicher, dass Sie beenden möchten? Dieser Entwurf wird gespeichert",
+      "sending": "E-Mail wird gesendet...",
+      "sent": "E-Mail erfolgreich gesendet",
+      "draft_saved": "Entwurf gespeichert"
+    },
+    "inbox": {
+      "title": "Posteingang",
+      "all_accounts": "Alle Konten",
+      "sent": "Gesendet",
+      "trash": "Papierkorb",
+      "archive": "Archiv",
+      "empty": "Keine E-Mails",
+      "loading": "E-Mails werden geladen...",
+      "refreshing": "Wird aktualisiert...",
+      "visual_mode": "visueller Modus",
+      "delete": "löschen",
+      "archive": "archivieren",
+      "refresh": "aktualisieren",
+      "reply": "antworten",
+      "forward": "weiterleiten",
+      "move": "verschieben",
+      "mark_read": "als gelesen markieren",
+      "mark_unread": "als ungelesen markieren",
+      "help_visual": "v: visueller Modus • d: löschen • a: archivieren",
+      "help_navigation": "j/k: navigieren • enter: öffnen • r: aktualisieren"
+    },
+    "choice": {
+      "what_to_do": "Was möchten Sie tun?",
+      "compose": "E-Mail Verfassen",
+      "inbox": "Posteingang Anzeigen",
+      "calendar": "Kalender Anzeigen",
+      "settings": "Einstellungen",
+      "marketplace": "Plugin-Marktplatz",
+      "drafts": "Entwürfe",
+      "help": "Verwenden Sie ↑/↓ zum Navigieren, Enter zum Auswählen und ctrl+c zum Beenden.",
+      "unknown": "unbekannt",
+      "update_available": "Update verfügbar: {latest} (installiert: {current}) — führen Sie `matcha update` aus, um zu aktualisieren"
+    },
+    "folder_inbox": {
+      "folders_title": "Ordner",
+      "move_to_folder": "In Ordner verschieben:",
+      "move_single": "E-Mail in Ordner verschieben:",
+      "move_multiple": {
+        "one": "{count} E-Mail in Ordner verschieben:",
+        "other": "{count} E-Mails in Ordner verschieben:"
+      },
+      "help": "j/k: navigieren  enter: verschieben  esc: abbrechen",
+      "help_folders": "tab: nächster Ordner • shift+tab: vorheriger Ordner • m: verschieben"
+    },
+    "login": {
+      "title": "E-Mail-Konten",
+      "add_account": "Konto Hinzufügen",
+      "edit_account": "Konto Bearbeiten",
+      "description": "Geben Sie Ihre E-Mail-Kontodaten ein.",
+      "protocol_label": "Protokoll",
+      "protocol_placeholder": "Protokoll (imap, jmap oder pop3)",
+      "email_label": "E-Mail",
+      "email_placeholder": "ihre.email@beispiel.de",
+      "password_label": "Passwort",
+      "password_placeholder": "Passwort / App-Passwort",
+      "display_name_label": "Anzeigename",
+      "display_name_placeholder": "Ihr Name",
+      "imap_server_label": "IMAP-Server",
+      "smtp_server_label": "SMTP-Server",
+      "port_label": "Port",
+      "save": "Konto Speichern",
+      "delete": "Konto Löschen",
+      "delete_confirm": "Dieses Konto löschen?",
+      "tip_protocol": "Wählen Sie das Protokoll: imap (Standard), jmap oder pop3.",
+      "tip_app_password": "Für Gmail verwenden Sie ein App-Passwort anstelle Ihres regulären Passworts."
+    },
+    "settings": {
+      "title": "Einstellungen",
+      "category_general": "Allgemein",
+      "category_accounts": "Konten",
+      "category_theme": "Design",
+      "category_mailing_lists": "Mailinglisten",
+      "category_encryption": "App-Verschlüsselung",
+      "help_menu": "↑/↓: navigieren • rechts/enter: auswählen • esc: zurück",
+      "help_content": "esc: zurück zum Menü"
+    },
+    "settings_accounts": {
+      "title": "Kontoeinstellungen",
+      "no_accounts": "Keine Konten konfiguriert.",
+      "add_account": "Neues Konto Hinzufügen",
+      "help": "↑/↓: navigieren • enter: Krypto-Konfig. bearbeiten • e: Server bearbeiten • d: löschen"
+    },
+    "settings_theme": {
+      "title": "Design",
+      "current": "aktiv",
+      "help": "↑/↓: navigieren • enter/Leertaste: Design anwenden"
+    },
+    "settings_mailing_lists": {
+      "title": "Mailinglisten",
+      "no_lists": "Keine Mailinglisten konfiguriert.",
+      "add_list": "Neue Mailingliste Hinzufügen",
+      "delete_confirm": "Mailingliste löschen?",
+      "address_count": {
+        "one": "{count} Adresse",
+        "other": "{count} Adressen"
+      },
+      "help": "↑/↓: navigieren • enter: auswählen • e: bearbeiten • d: löschen"
+    },
+    "settings_general": {
+      "title": "Allgemeine Einstellungen",
+      "disable_images": "Bildanzeige Deaktivieren",
+      "hide_tips": "Kontextuelle Tipps Ausblenden",
+      "disable_notifications": "Benachrichtigungen Deaktivieren",
+      "date_format": "Datumsformat",
+      "language": "Sprache",
+      "signature": "Signatur Bearbeiten",
+      "signature_configured": "konfiguriert",
+      "signature_not_configured": "nicht konfiguriert",
+      "on": "AN",
+      "off": "AUS",
+      "restart_required": "Neustart erforderlich, um die Sprachänderung anzuwenden"
+    },
+    "settings_encryption": {
+      "title": "App-Verschlüsselung",
+      "enabled": "Verschlüsselung ist derzeit aktiviert.",
+      "disabled": "Legen Sie ein Passwort fest, um alle Daten zu verschlüsseln.",
+      "password_label": "Passwort:",
+      "confirm_label": "Passwort Bestätigen:",
+      "enable_button": "Verschlüsselung Aktivieren",
+      "disable_button": "Drücken Sie Enter, um die Verschlüsselung zu deaktivieren",
+      "disable_confirm": "Verschlüsselung deaktivieren?",
+      "disable_warning": "Alle Daten werden unverschlüsselt gespeichert.",
+      "encrypting": "Daten werden verschlüsselt...",
+      "error_empty": "Passwort darf nicht leer sein",
+      "error_mismatch": "Passwörter stimmen nicht überein",
+      "help": "tab: nächstes • enter: speichern"
+    },
+    "password_prompt": {
+      "title": "Matcha ist gesperrt",
+      "enter_password": "Geben Sie Ihr Passwort ein",
+      "error_empty": "Passwort darf nicht leer sein",
+      "error_incorrect": "Falsches Passwort",
+      "help": "enter: entsperren • ctrl+c: beenden"
+    },
+    "email_view": {
+      "from": "Von",
+      "to": "An",
+      "cc": "Cc",
+      "bcc": "Bcc",
+      "subject": "Betreff",
+      "date": "Datum",
+      "attachments": "Anhänge",
+      "download": "Herunterladen",
+      "save": "Speichern",
+      "reply": "Antworten",
+      "reply_all": "Allen Antworten",
+      "forward": "Weiterleiten",
+      "delete": "Löschen",
+      "archive": "Archivieren",
+      "help": "r: antworten • f: weiterleiten • d: löschen • a: archivieren • esc: zurück"
+    },
+    "calendar": {
+      "title": "Kalender",
+      "meeting": "Besprechung",
+      "event": "Ereignis",
+      "accept": "Annehmen",
+      "decline": "Ablehnen",
+      "tentative": "Mit Vorbehalt",
+      "rsvp_sent": "RSVP gesendet: {response}"
+    },
+    "marketplace": {
+      "title": "Plugin-Marktplatz",
+      "installing": "Wird installiert...",
+      "installed": "Installiert",
+      "install": "Installieren",
+      "error": "Installation fehlgeschlagen",
+      "help": "j/k: navigieren • enter: installieren • esc: zurück"
+    },
+    "time": {
+      "just_now": "gerade eben",
+      "minute_ago": {
+        "one": "vor 1 Minute",
+        "other": "vor {count} Minuten"
+      },
+      "hour_ago": {
+        "one": "vor 1 Stunde",
+        "other": "vor {count} Stunden"
+      },
+      "day_ago": {
+        "one": "vor 1 Tag",
+        "other": "vor {count} Tagen"
+      },
+      "week_ago": {
+        "one": "vor 1 Woche",
+        "other": "vor {count} Wochen"
+      },
+      "month_ago": {
+        "one": "vor 1 Monat",
+        "other": "vor {count} Monaten"
+      },
+      "year_ago": {
+        "one": "vor 1 Jahr",
+        "other": "vor {count} Jahren"
+      },
+      "in_moment": "in einem Moment",
+      "in_minute": {
+        "one": "in 1 Minute",
+        "other": "in {count} Minuten"
+      },
+      "in_hour": {
+        "one": "in 1 Stunde",
+        "other": "in {count} Stunden"
+      },
+      "in_day": {
+        "one": "in 1 Tag",
+        "other": "in {count} Tagen"
+      }
+    }
+  }
+}

i18n/locales/en.json 🔗

@@ -0,0 +1,253 @@
+{
+  "language": "en",
+  "messages": {
+    "common": {
+      "yes": "Yes",
+      "no": "No",
+      "cancel": "Cancel",
+      "ok": "OK",
+      "save": "Save",
+      "delete": "Delete",
+      "archive": "Archive",
+      "back": "Back",
+      "next": "Next",
+      "previous": "Previous",
+      "loading": "Loading...",
+      "error": "Error",
+      "success": "Success"
+    },
+    "composer": {
+      "title": "Compose New Email",
+      "from": "From",
+      "to_placeholder": "Enter recipient email addresses.",
+      "cc_placeholder": "Carbon copy recipients.",
+      "bcc_placeholder": "Blind carbon copy recipients.",
+      "subject_placeholder": "Subject",
+      "body_placeholder": "Compose your message...",
+      "signature": "Signature",
+      "signature_placeholder": "Your email signature.",
+      "attachments": "Attachments",
+      "attachments_none": "None",
+      "enter_to_add": "Enter to add",
+      "encrypt_smime": "Encrypt Email (S/MIME)",
+      "send": "Send",
+      "switchable": "switchable",
+      "enter_to_switch": "Enter to switch",
+      "no_account": "no account configured",
+      "send_confirm": "Press Enter to send the email.",
+      "help": "Markdown/HTML • tab/shift+tab: navigate • ctrl+e: $EDITOR • esc: save draft & exit",
+      "exit_confirm": "Are you sure you want to exit? This draft will be saved",
+      "sending": "Sending email...",
+      "sent": "Email sent successfully",
+      "draft_saved": "Draft saved"
+    },
+    "inbox": {
+      "title": "Inbox",
+      "all_accounts": "All Accounts",
+      "sent": "Sent",
+      "trash": "Trash",
+      "archive": "Archive",
+      "empty": "No emails",
+      "loading": "Loading emails...",
+      "refreshing": "Refreshing...",
+      "visual_mode": "visual mode",
+      "delete": "delete",
+      "archive": "archive",
+      "refresh": "refresh",
+      "reply": "reply",
+      "forward": "forward",
+      "move": "move",
+      "mark_read": "mark as read",
+      "mark_unread": "mark as unread",
+      "help_visual": "v: visual mode • d: delete • a: archive",
+      "help_navigation": "j/k: navigate • enter: open • r: refresh"
+    },
+    "choice": {
+      "what_to_do": "What would you like to do?",
+      "compose": "Compose Email",
+      "inbox": "View Inbox",
+      "calendar": "View Calendar",
+      "settings": "Settings",
+      "marketplace": "Plugin Marketplace",
+      "drafts": "Drafts",
+      "help": "Use ↑/↓ to navigate, enter to select, and ctrl+c to quit.",
+      "unknown": "unknown",
+      "update_available": "Update available: {latest} (installed: {current}) — run `matcha update` to upgrade"
+    },
+    "folder_inbox": {
+      "folders_title": "Folders",
+      "move_to_folder": "Move to folder:",
+      "move_single": "Move email to folder:",
+      "move_multiple": {
+        "one": "Move {count} email to folder:",
+        "other": "Move {count} emails to folder:"
+      },
+      "help": "j/k: navigate  enter: move  esc: cancel",
+      "help_folders": "tab: next folder • shift+tab: prev folder • m: move"
+    },
+    "login": {
+      "title": "Email Accounts",
+      "add_account": "Add Account",
+      "edit_account": "Edit Account",
+      "description": "Enter your email account credentials.",
+      "protocol_label": "Protocol",
+      "protocol_placeholder": "Protocol (imap, jmap, or pop3)",
+      "email_label": "Email",
+      "email_placeholder": "your.email@example.com",
+      "password_label": "Password",
+      "password_placeholder": "Password / App Password",
+      "display_name_label": "Display Name",
+      "display_name_placeholder": "Your Name",
+      "imap_server_label": "IMAP Server",
+      "smtp_server_label": "SMTP Server",
+      "port_label": "Port",
+      "save": "Save Account",
+      "delete": "Delete Account",
+      "delete_confirm": "Delete this account?",
+      "tip_protocol": "Choose the protocol: imap (default), jmap, or pop3.",
+      "tip_app_password": "For Gmail, use an App Password instead of your regular password."
+    },
+    "settings": {
+      "title": "Settings",
+      "category_general": "General",
+      "category_accounts": "Accounts",
+      "category_theme": "Theme",
+      "category_mailing_lists": "Mailing Lists",
+      "category_encryption": "App Encryption",
+      "help_menu": "↑/↓: navigate • right/enter: select • esc: go back",
+      "help_content": "esc: back to menu"
+    },
+    "settings_accounts": {
+      "title": "Account Settings",
+      "no_accounts": "No accounts configured.",
+      "add_account": "Add New Account",
+      "help": "↑/↓: navigate • enter: edit crypto config • e: edit server • d: delete"
+    },
+    "settings_theme": {
+      "title": "Theme",
+      "current": "active",
+      "help": "↑/↓: navigate • enter/space: apply theme"
+    },
+    "settings_mailing_lists": {
+      "title": "Mailing Lists",
+      "no_lists": "No mailing lists configured.",
+      "add_list": "Add New Mailing List",
+      "delete_confirm": "Delete mailing list?",
+      "address_count": {
+        "one": "{count} address",
+        "other": "{count} addresses"
+      },
+      "help": "↑/↓: navigate • enter: select • e: edit • d: delete"
+    },
+    "settings_general": {
+      "title": "General Settings",
+      "disable_images": "Disable Image Display",
+      "hide_tips": "Hide Contextual Tips",
+      "disable_notifications": "Disable Notifications",
+      "date_format": "Date Format",
+      "language": "Language",
+      "signature": "Edit Signature",
+      "signature_configured": "configured",
+      "signature_not_configured": "not configured",
+      "on": "ON",
+      "off": "OFF",
+      "restart_required": "Restart required to apply language change"
+    },
+    "settings_encryption": {
+      "title": "App Encryption",
+      "enabled": "Encryption is currently enabled.",
+      "disabled": "Set a password to encrypt all data.",
+      "password_label": "Password:",
+      "confirm_label": "Confirm Password:",
+      "enable_button": "Enable Encryption",
+      "disable_button": "Press enter to disable encryption",
+      "disable_confirm": "Disable encryption?",
+      "disable_warning": "All data will be stored unencrypted.",
+      "encrypting": "Encrypting data...",
+      "error_empty": "Password cannot be empty",
+      "error_mismatch": "Passwords do not match",
+      "help": "tab: next • enter: save"
+    },
+    "password_prompt": {
+      "title": "Matcha is locked",
+      "enter_password": "Enter your password",
+      "error_empty": "Password cannot be empty",
+      "error_incorrect": "Incorrect password",
+      "help": "enter: unlock • ctrl+c: quit"
+    },
+    "email_view": {
+      "from": "From",
+      "to": "To",
+      "cc": "Cc",
+      "bcc": "Bcc",
+      "subject": "Subject",
+      "date": "Date",
+      "attachments": "Attachments",
+      "download": "Download",
+      "save": "Save",
+      "reply": "Reply",
+      "reply_all": "Reply All",
+      "forward": "Forward",
+      "delete": "Delete",
+      "archive": "Archive",
+      "help": "r: reply • f: forward • d: delete • a: archive • esc: back"
+    },
+    "calendar": {
+      "title": "Calendar",
+      "meeting": "Meeting",
+      "event": "Event",
+      "accept": "Accept",
+      "decline": "Decline",
+      "tentative": "Tentative",
+      "rsvp_sent": "RSVP sent: {response}"
+    },
+    "marketplace": {
+      "title": "Plugin Marketplace",
+      "installing": "Installing...",
+      "installed": "Installed",
+      "install": "Install",
+      "error": "Installation failed",
+      "help": "j/k: navigate • enter: install • esc: back"
+    },
+    "time": {
+      "just_now": "just now",
+      "minute_ago": {
+        "one": "1 minute ago",
+        "other": "{count} minutes ago"
+      },
+      "hour_ago": {
+        "one": "1 hour ago",
+        "other": "{count} hours ago"
+      },
+      "day_ago": {
+        "one": "1 day ago",
+        "other": "{count} days ago"
+      },
+      "week_ago": {
+        "one": "1 week ago",
+        "other": "{count} weeks ago"
+      },
+      "month_ago": {
+        "one": "1 month ago",
+        "other": "{count} months ago"
+      },
+      "year_ago": {
+        "one": "1 year ago",
+        "other": "{count} years ago"
+      },
+      "in_moment": "in a moment",
+      "in_minute": {
+        "one": "in 1 minute",
+        "other": "in {count} minutes"
+      },
+      "in_hour": {
+        "one": "in 1 hour",
+        "other": "in {count} hours"
+      },
+      "in_day": {
+        "one": "in 1 day",
+        "other": "in {count} days"
+      }
+    }
+  }
+}

i18n/locales/es.json 🔗

@@ -0,0 +1,253 @@
+{
+  "language": "es",
+  "messages": {
+    "common": {
+      "yes": "Sí",
+      "no": "No",
+      "cancel": "Cancelar",
+      "ok": "Aceptar",
+      "save": "Guardar",
+      "delete": "Eliminar",
+      "archive": "Archivar",
+      "back": "Atrás",
+      "next": "Siguiente",
+      "previous": "Anterior",
+      "loading": "Cargando...",
+      "error": "Error",
+      "success": "Éxito"
+    },
+    "composer": {
+      "title": "Redactar Nuevo Correo",
+      "from": "De",
+      "to_placeholder": "Ingrese direcciones de correo de los destinatarios.",
+      "cc_placeholder": "Destinatarios con copia.",
+      "bcc_placeholder": "Destinatarios con copia oculta.",
+      "subject_placeholder": "Asunto",
+      "body_placeholder": "Redacte su mensaje...",
+      "signature": "Firma",
+      "signature_placeholder": "Su firma de correo.",
+      "attachments": "Archivos adjuntos",
+      "attachments_none": "Ninguno",
+      "enter_to_add": "Enter para agregar",
+      "encrypt_smime": "Cifrar Correo (S/MIME)",
+      "send": "Enviar",
+      "switchable": "intercambiable",
+      "enter_to_switch": "Enter para cambiar",
+      "no_account": "ninguna cuenta configurada",
+      "send_confirm": "Presione Enter para enviar el correo.",
+      "help": "Markdown/HTML • tab/shift+tab: navegar • ctrl+e: $EDITOR • esc: guardar borrador y salir",
+      "exit_confirm": "¿Está seguro de que desea salir? Este borrador se guardará",
+      "sending": "Enviando correo...",
+      "sent": "Correo enviado exitosamente",
+      "draft_saved": "Borrador guardado"
+    },
+    "inbox": {
+      "title": "Bandeja de entrada",
+      "all_accounts": "Todas las Cuentas",
+      "sent": "Enviados",
+      "trash": "Papelera",
+      "archive": "Archivo",
+      "empty": "Sin correos",
+      "loading": "Cargando correos...",
+      "refreshing": "Actualizando...",
+      "visual_mode": "modo visual",
+      "delete": "eliminar",
+      "archive": "archivar",
+      "refresh": "actualizar",
+      "reply": "responder",
+      "forward": "reenviar",
+      "move": "mover",
+      "mark_read": "marcar como leído",
+      "mark_unread": "marcar como no leído",
+      "help_visual": "v: modo visual • d: eliminar • a: archivar",
+      "help_navigation": "j/k: navegar • enter: abrir • r: actualizar"
+    },
+    "choice": {
+      "what_to_do": "¿Qué le gustaría hacer?",
+      "compose": "Redactar Correo",
+      "inbox": "Ver Bandeja de Entrada",
+      "calendar": "Ver Calendario",
+      "settings": "Configuración",
+      "marketplace": "Tienda de Plugins",
+      "drafts": "Borradores",
+      "help": "Use ↑/↓ para navegar, enter para seleccionar, y ctrl+c para salir.",
+      "unknown": "desconocido",
+      "update_available": "Actualización disponible: {latest} (instalada: {current}) — ejecute `matcha update` para actualizar"
+    },
+    "folder_inbox": {
+      "folders_title": "Carpetas",
+      "move_to_folder": "Mover a carpeta:",
+      "move_single": "Mover correo a carpeta:",
+      "move_multiple": {
+        "one": "Mover {count} correo a carpeta:",
+        "other": "Mover {count} correos a carpeta:"
+      },
+      "help": "j/k: navegar  enter: mover  esc: cancelar",
+      "help_folders": "tab: siguiente carpeta • shift+tab: carpeta anterior • m: mover"
+    },
+    "login": {
+      "title": "Cuentas de Correo",
+      "add_account": "Agregar Cuenta",
+      "edit_account": "Editar Cuenta",
+      "description": "Ingrese las credenciales de su cuenta de correo.",
+      "protocol_label": "Protocolo",
+      "protocol_placeholder": "Protocolo (imap, jmap, o pop3)",
+      "email_label": "Correo",
+      "email_placeholder": "su.correo@ejemplo.com",
+      "password_label": "Contraseña",
+      "password_placeholder": "Contraseña / Contraseña de Aplicación",
+      "display_name_label": "Nombre a Mostrar",
+      "display_name_placeholder": "Su Nombre",
+      "imap_server_label": "Servidor IMAP",
+      "smtp_server_label": "Servidor SMTP",
+      "port_label": "Puerto",
+      "save": "Guardar Cuenta",
+      "delete": "Eliminar Cuenta",
+      "delete_confirm": "¿Eliminar esta cuenta?",
+      "tip_protocol": "Elija el protocolo: imap (predeterminado), jmap, o pop3.",
+      "tip_app_password": "Para Gmail, use una Contraseña de Aplicación en lugar de su contraseña normal."
+    },
+    "settings": {
+      "title": "Configuración",
+      "category_general": "General",
+      "category_accounts": "Cuentas",
+      "category_theme": "Tema",
+      "category_mailing_lists": "Listas de Correo",
+      "category_encryption": "Cifrado de Aplicación",
+      "help_menu": "↑/↓: navegar • derecha/enter: seleccionar • esc: volver",
+      "help_content": "esc: volver al menú"
+    },
+    "settings_accounts": {
+      "title": "Configuración de Cuentas",
+      "no_accounts": "No hay cuentas configuradas.",
+      "add_account": "Agregar Nueva Cuenta",
+      "help": "↑/↓: navegar • enter: editar config. de cifrado • e: editar servidor • d: eliminar"
+    },
+    "settings_theme": {
+      "title": "Tema",
+      "current": "activo",
+      "help": "↑/↓: navegar • enter/espacio: aplicar tema"
+    },
+    "settings_mailing_lists": {
+      "title": "Listas de Correo",
+      "no_lists": "No hay listas de correo configuradas.",
+      "add_list": "Agregar Nueva Lista de Correo",
+      "delete_confirm": "¿Eliminar lista de correo?",
+      "address_count": {
+        "one": "{count} dirección",
+        "other": "{count} direcciones"
+      },
+      "help": "↑/↓: navegar • enter: seleccionar • e: editar • d: eliminar"
+    },
+    "settings_general": {
+      "title": "Configuración General",
+      "disable_images": "Deshabilitar Visualización de Imágenes",
+      "hide_tips": "Ocultar Consejos Contextuales",
+      "disable_notifications": "Deshabilitar Notificaciones",
+      "date_format": "Formato de Fecha",
+      "language": "Idioma",
+      "signature": "Editar Firma",
+      "signature_configured": "configurada",
+      "signature_not_configured": "no configurada",
+      "on": "ACTIVADO",
+      "off": "DESACTIVADO",
+      "restart_required": "Se requiere reiniciar para aplicar el cambio de idioma"
+    },
+    "settings_encryption": {
+      "title": "Cifrado de Aplicación",
+      "enabled": "El cifrado está actualmente habilitado.",
+      "disabled": "Establezca una contraseña para cifrar todos los datos.",
+      "password_label": "Contraseña:",
+      "confirm_label": "Confirmar Contraseña:",
+      "enable_button": "Habilitar Cifrado",
+      "disable_button": "Presione enter para deshabilitar el cifrado",
+      "disable_confirm": "¿Deshabilitar cifrado?",
+      "disable_warning": "Todos los datos se almacenarán sin cifrar.",
+      "encrypting": "Cifrando datos...",
+      "error_empty": "La contraseña no puede estar vacía",
+      "error_mismatch": "Las contraseñas no coinciden",
+      "help": "tab: siguiente • enter: guardar"
+    },
+    "password_prompt": {
+      "title": "Matcha está bloqueado",
+      "enter_password": "Ingrese su contraseña",
+      "error_empty": "La contraseña no puede estar vacía",
+      "error_incorrect": "Contraseña incorrecta",
+      "help": "enter: desbloquear • ctrl+c: salir"
+    },
+    "email_view": {
+      "from": "De",
+      "to": "Para",
+      "cc": "Cc",
+      "bcc": "Cco",
+      "subject": "Asunto",
+      "date": "Fecha",
+      "attachments": "Archivos Adjuntos",
+      "download": "Descargar",
+      "save": "Guardar",
+      "reply": "Responder",
+      "reply_all": "Responder a Todos",
+      "forward": "Reenviar",
+      "delete": "Eliminar",
+      "archive": "Archivar",
+      "help": "r: responder • f: reenviar • d: eliminar • a: archivar • esc: atrás"
+    },
+    "calendar": {
+      "title": "Calendario",
+      "meeting": "Reunión",
+      "event": "Evento",
+      "accept": "Aceptar",
+      "decline": "Rechazar",
+      "tentative": "Provisional",
+      "rsvp_sent": "RSVP enviado: {response}"
+    },
+    "marketplace": {
+      "title": "Tienda de Plugins",
+      "installing": "Instalando...",
+      "installed": "Instalado",
+      "install": "Instalar",
+      "error": "Falló la instalación",
+      "help": "j/k: navegar • enter: instalar • esc: atrás"
+    },
+    "time": {
+      "just_now": "justo ahora",
+      "minute_ago": {
+        "one": "hace 1 minuto",
+        "other": "hace {count} minutos"
+      },
+      "hour_ago": {
+        "one": "hace 1 hora",
+        "other": "hace {count} horas"
+      },
+      "day_ago": {
+        "one": "hace 1 día",
+        "other": "hace {count} días"
+      },
+      "week_ago": {
+        "one": "hace 1 semana",
+        "other": "hace {count} semanas"
+      },
+      "month_ago": {
+        "one": "hace 1 mes",
+        "other": "hace {count} meses"
+      },
+      "year_ago": {
+        "one": "hace 1 año",
+        "other": "hace {count} años"
+      },
+      "in_moment": "en un momento",
+      "in_minute": {
+        "one": "en 1 minuto",
+        "other": "en {count} minutos"
+      },
+      "in_hour": {
+        "one": "en 1 hora",
+        "other": "en {count} horas"
+      },
+      "in_day": {
+        "one": "en 1 día",
+        "other": "en {count} días"
+      }
+    }
+  }
+}

i18n/locales/fr.json 🔗

@@ -0,0 +1,253 @@
+{
+  "language": "fr",
+  "messages": {
+    "common": {
+      "yes": "Oui",
+      "no": "Non",
+      "cancel": "Annuler",
+      "ok": "OK",
+      "save": "Enregistrer",
+      "delete": "Supprimer",
+      "archive": "Archiver",
+      "back": "Retour",
+      "next": "Suivant",
+      "previous": "Précédent",
+      "loading": "Chargement...",
+      "error": "Erreur",
+      "success": "Succès"
+    },
+    "composer": {
+      "title": "Rédiger un Nouveau Message",
+      "from": "De",
+      "to_placeholder": "Entrez les adresses e-mail des destinataires.",
+      "cc_placeholder": "Destinataires en copie.",
+      "bcc_placeholder": "Destinataires en copie cachée.",
+      "subject_placeholder": "Objet",
+      "body_placeholder": "Rédigez votre message...",
+      "signature": "Signature",
+      "signature_placeholder": "Votre signature e-mail.",
+      "attachments": "Pièces jointes",
+      "attachments_none": "Aucune",
+      "enter_to_add": "Entrée pour ajouter",
+      "encrypt_smime": "Chiffrer l'E-mail (S/MIME)",
+      "send": "Envoyer",
+      "switchable": "interchangeable",
+      "enter_to_switch": "Entrée pour changer",
+      "no_account": "aucun compte configuré",
+      "send_confirm": "Appuyez sur Entrée pour envoyer l'e-mail.",
+      "help": "Markdown/HTML • tab/shift+tab: naviguer • ctrl+e: $EDITOR • esc: sauvegarder brouillon & quitter",
+      "exit_confirm": "Êtes-vous sûr de vouloir quitter ? Ce brouillon sera sauvegardé",
+      "sending": "Envoi de l'e-mail...",
+      "sent": "E-mail envoyé avec succès",
+      "draft_saved": "Brouillon sauvegardé"
+    },
+    "inbox": {
+      "title": "Boîte de réception",
+      "all_accounts": "Tous les Comptes",
+      "sent": "Envoyés",
+      "trash": "Corbeille",
+      "archive": "Archives",
+      "empty": "Aucun e-mail",
+      "loading": "Chargement des e-mails...",
+      "refreshing": "Actualisation...",
+      "visual_mode": "mode visuel",
+      "delete": "supprimer",
+      "archive": "archiver",
+      "refresh": "actualiser",
+      "reply": "répondre",
+      "forward": "transférer",
+      "move": "déplacer",
+      "mark_read": "marquer comme lu",
+      "mark_unread": "marquer comme non lu",
+      "help_visual": "v: mode visuel • d: supprimer • a: archiver",
+      "help_navigation": "j/k: naviguer • entrée: ouvrir • r: actualiser"
+    },
+    "choice": {
+      "what_to_do": "Que souhaitez-vous faire ?",
+      "compose": "Rédiger un E-mail",
+      "inbox": "Voir la Boîte de Réception",
+      "calendar": "Voir le Calendrier",
+      "settings": "Paramètres",
+      "marketplace": "Marketplace de Plugins",
+      "drafts": "Brouillons",
+      "help": "Utilisez ↑/↓ pour naviguer, entrée pour sélectionner, et ctrl+c pour quitter.",
+      "unknown": "inconnu",
+      "update_available": "Mise à jour disponible : {latest} (installée : {current}) — exécutez `matcha update` pour mettre à jour"
+    },
+    "folder_inbox": {
+      "folders_title": "Dossiers",
+      "move_to_folder": "Déplacer vers le dossier :",
+      "move_single": "Déplacer l'e-mail vers le dossier :",
+      "move_multiple": {
+        "one": "Déplacer {count} e-mail vers le dossier :",
+        "other": "Déplacer {count} e-mails vers le dossier :"
+      },
+      "help": "j/k: naviguer  entrée: déplacer  esc: annuler",
+      "help_folders": "tab: dossier suivant • shift+tab: dossier précédent • m: déplacer"
+    },
+    "login": {
+      "title": "Comptes E-mail",
+      "add_account": "Ajouter un Compte",
+      "edit_account": "Modifier le Compte",
+      "description": "Entrez les identifiants de votre compte e-mail.",
+      "protocol_label": "Protocole",
+      "protocol_placeholder": "Protocole (imap, jmap ou pop3)",
+      "email_label": "E-mail",
+      "email_placeholder": "votre.email@exemple.fr",
+      "password_label": "Mot de passe",
+      "password_placeholder": "Mot de passe / Mot de passe d'application",
+      "display_name_label": "Nom d'Affichage",
+      "display_name_placeholder": "Votre Nom",
+      "imap_server_label": "Serveur IMAP",
+      "smtp_server_label": "Serveur SMTP",
+      "port_label": "Port",
+      "save": "Enregistrer le Compte",
+      "delete": "Supprimer le Compte",
+      "delete_confirm": "Supprimer ce compte ?",
+      "tip_protocol": "Choisissez le protocole : imap (par défaut), jmap ou pop3.",
+      "tip_app_password": "Pour Gmail, utilisez un mot de passe d'application au lieu de votre mot de passe habituel."
+    },
+    "settings": {
+      "title": "Paramètres",
+      "category_general": "Général",
+      "category_accounts": "Comptes",
+      "category_theme": "Thème",
+      "category_mailing_lists": "Listes de Diffusion",
+      "category_encryption": "Chiffrement de l'Application",
+      "help_menu": "↑/↓: naviguer • droite/entrée: sélectionner • esc: retour",
+      "help_content": "esc: retour au menu"
+    },
+    "settings_accounts": {
+      "title": "Paramètres des Comptes",
+      "no_accounts": "Aucun compte configuré.",
+      "add_account": "Ajouter un Nouveau Compte",
+      "help": "↑/↓: naviguer • entrée: modifier config. crypto • e: modifier serveur • d: supprimer"
+    },
+    "settings_theme": {
+      "title": "Thème",
+      "current": "actif",
+      "help": "↑/↓: naviguer • entrée/espace: appliquer le thème"
+    },
+    "settings_mailing_lists": {
+      "title": "Listes de Diffusion",
+      "no_lists": "Aucune liste de diffusion configurée.",
+      "add_list": "Ajouter une Nouvelle Liste de Diffusion",
+      "delete_confirm": "Supprimer la liste de diffusion ?",
+      "address_count": {
+        "one": "{count} adresse",
+        "other": "{count} adresses"
+      },
+      "help": "↑/↓: naviguer • entrée: sélectionner • e: modifier • d: supprimer"
+    },
+    "settings_general": {
+      "title": "Paramètres Généraux",
+      "disable_images": "Désactiver l'Affichage des Images",
+      "hide_tips": "Masquer les Conseils Contextuels",
+      "disable_notifications": "Désactiver les Notifications",
+      "date_format": "Format de Date",
+      "language": "Langue",
+      "signature": "Modifier la Signature",
+      "signature_configured": "configurée",
+      "signature_not_configured": "non configurée",
+      "on": "ACTIVÉ",
+      "off": "DÉSACTIVÉ",
+      "restart_required": "Redémarrage requis pour appliquer le changement de langue"
+    },
+    "settings_encryption": {
+      "title": "Chiffrement de l'Application",
+      "enabled": "Le chiffrement est actuellement activé.",
+      "disabled": "Définissez un mot de passe pour chiffrer toutes les données.",
+      "password_label": "Mot de passe :",
+      "confirm_label": "Confirmer le Mot de Passe :",
+      "enable_button": "Activer le Chiffrement",
+      "disable_button": "Appuyez sur entrée pour désactiver le chiffrement",
+      "disable_confirm": "Désactiver le chiffrement ?",
+      "disable_warning": "Toutes les données seront stockées non chiffrées.",
+      "encrypting": "Chiffrement des données...",
+      "error_empty": "Le mot de passe ne peut pas être vide",
+      "error_mismatch": "Les mots de passe ne correspondent pas",
+      "help": "tab: suivant • entrée: enregistrer"
+    },
+    "password_prompt": {
+      "title": "Matcha est verrouillé",
+      "enter_password": "Entrez votre mot de passe",
+      "error_empty": "Le mot de passe ne peut pas être vide",
+      "error_incorrect": "Mot de passe incorrect",
+      "help": "entrée: déverrouiller • ctrl+c: quitter"
+    },
+    "email_view": {
+      "from": "De",
+      "to": "À",
+      "cc": "Cc",
+      "bcc": "Cci",
+      "subject": "Objet",
+      "date": "Date",
+      "attachments": "Pièces Jointes",
+      "download": "Télécharger",
+      "save": "Enregistrer",
+      "reply": "Répondre",
+      "reply_all": "Répondre à Tous",
+      "forward": "Transférer",
+      "delete": "Supprimer",
+      "archive": "Archiver",
+      "help": "r: répondre • f: transférer • d: supprimer • a: archiver • esc: retour"
+    },
+    "calendar": {
+      "title": "Calendrier",
+      "meeting": "Réunion",
+      "event": "Événement",
+      "accept": "Accepter",
+      "decline": "Refuser",
+      "tentative": "Provisoire",
+      "rsvp_sent": "RSVP envoyé : {response}"
+    },
+    "marketplace": {
+      "title": "Marketplace de Plugins",
+      "installing": "Installation...",
+      "installed": "Installé",
+      "install": "Installer",
+      "error": "Échec de l'installation",
+      "help": "j/k: naviguer • entrée: installer • esc: retour"
+    },
+    "time": {
+      "just_now": "à l'instant",
+      "minute_ago": {
+        "one": "il y a 1 minute",
+        "other": "il y a {count} minutes"
+      },
+      "hour_ago": {
+        "one": "il y a 1 heure",
+        "other": "il y a {count} heures"
+      },
+      "day_ago": {
+        "one": "il y a 1 jour",
+        "other": "il y a {count} jours"
+      },
+      "week_ago": {
+        "one": "il y a 1 semaine",
+        "other": "il y a {count} semaines"
+      },
+      "month_ago": {
+        "one": "il y a 1 mois",
+        "other": "il y a {count} mois"
+      },
+      "year_ago": {
+        "one": "il y a 1 an",
+        "other": "il y a {count} ans"
+      },
+      "in_moment": "dans un instant",
+      "in_minute": {
+        "one": "dans 1 minute",
+        "other": "dans {count} minutes"
+      },
+      "in_hour": {
+        "one": "dans 1 heure",
+        "other": "dans {count} heures"
+      },
+      "in_day": {
+        "one": "dans 1 jour",
+        "other": "dans {count} jours"
+      }
+    }
+  }
+}

i18n/locales/ja.json 🔗

@@ -0,0 +1,242 @@
+{
+  "language": "ja",
+  "messages": {
+    "common": {
+      "yes": "はい",
+      "no": "いいえ",
+      "cancel": "キャンセル",
+      "ok": "OK",
+      "save": "保存",
+      "delete": "削除",
+      "archive": "アーカイブ",
+      "back": "戻る",
+      "next": "次へ",
+      "previous": "前へ",
+      "loading": "読み込み中...",
+      "error": "エラー",
+      "success": "成功"
+    },
+    "composer": {
+      "title": "新規メール作成",
+      "from": "差出人",
+      "to_placeholder": "受信者のメールアドレスを入力してください。",
+      "cc_placeholder": "CCの受信者。",
+      "bcc_placeholder": "BCCの受信者。",
+      "subject_placeholder": "件名",
+      "body_placeholder": "メッセージを作成...",
+      "signature": "署名",
+      "signature_placeholder": "メール署名。",
+      "attachments": "添付ファイル",
+      "attachments_none": "なし",
+      "enter_to_add": "Enterで追加",
+      "encrypt_smime": "メールを暗号化 (S/MIME)",
+      "send": "送信",
+      "switchable": "切替可能",
+      "enter_to_switch": "Enterで切替",
+      "no_account": "アカウントが設定されていません",
+      "send_confirm": "Enterキーを押してメールを送信します。",
+      "help": "Markdown/HTML • tab/shift+tab: 移動 • ctrl+e: $EDITOR • esc: 下書きを保存して終了",
+      "exit_confirm": "終了してもよろしいですか?この下書きは保存されます",
+      "sending": "メール送信中...",
+      "sent": "メールが正常に送信されました",
+      "draft_saved": "下書きを保存しました"
+    },
+    "inbox": {
+      "title": "受信トレイ",
+      "all_accounts": "すべてのアカウント",
+      "sent": "送信済み",
+      "trash": "ゴミ箱",
+      "archive": "アーカイブ",
+      "empty": "メールがありません",
+      "loading": "メールを読み込み中...",
+      "refreshing": "更新中...",
+      "visual_mode": "ビジュアルモード",
+      "delete": "削除",
+      "archive": "アーカイブ",
+      "refresh": "更新",
+      "reply": "返信",
+      "forward": "転送",
+      "move": "移動",
+      "mark_read": "既読にする",
+      "mark_unread": "未読にする",
+      "help_visual": "v: ビジュアルモード • d: 削除 • a: アーカイブ",
+      "help_navigation": "j/k: 移動 • enter: 開く • r: 更新"
+    },
+    "choice": {
+      "what_to_do": "何をしますか?",
+      "compose": "メール作成",
+      "inbox": "受信トレイを表示",
+      "calendar": "カレンダーを表示",
+      "settings": "設定",
+      "marketplace": "プラグインマーケットプレイス",
+      "drafts": "下書き",
+      "help": "↑/↓で移動、Enterで選択、ctrl+cで終了します。",
+      "unknown": "不明",
+      "update_available": "アップデート利用可能: {latest} (インストール済み: {current}) — `matcha update`を実行してアップグレード"
+    },
+    "folder_inbox": {
+      "folders_title": "フォルダ",
+      "move_to_folder": "フォルダに移動:",
+      "move_single": "メールをフォルダに移動:",
+      "move_multiple": {
+        "other": "{count}件のメールをフォルダに移動:"
+      },
+      "help": "j/k: 移動  enter: 移動  esc: キャンセル",
+      "help_folders": "tab: 次のフォルダ • shift+tab: 前のフォルダ • m: 移動"
+    },
+    "login": {
+      "title": "メールアカウント",
+      "add_account": "アカウントを追加",
+      "edit_account": "アカウントを編集",
+      "description": "メールアカウントの認証情報を入力してください。",
+      "protocol_label": "プロトコル",
+      "protocol_placeholder": "プロトコル (imap, jmap, または pop3)",
+      "email_label": "メール",
+      "email_placeholder": "your.email@example.com",
+      "password_label": "パスワード",
+      "password_placeholder": "パスワード / アプリパスワード",
+      "display_name_label": "表示名",
+      "display_name_placeholder": "あなたの名前",
+      "imap_server_label": "IMAPサーバー",
+      "smtp_server_label": "SMTPサーバー",
+      "port_label": "ポート",
+      "save": "アカウントを保存",
+      "delete": "アカウントを削除",
+      "delete_confirm": "このアカウントを削除しますか?",
+      "tip_protocol": "プロトコルを選択: imap (デフォルト)、jmap、または pop3。",
+      "tip_app_password": "Gmailの場合、通常のパスワードではなくアプリパスワードを使用してください。"
+    },
+    "settings": {
+      "title": "設定",
+      "category_general": "一般",
+      "category_accounts": "アカウント",
+      "category_theme": "テーマ",
+      "category_mailing_lists": "メーリングリスト",
+      "category_encryption": "アプリの暗号化",
+      "help_menu": "↑/↓: 移動 • 右/enter: 選択 • esc: 戻る",
+      "help_content": "esc: メニューに戻る"
+    },
+    "settings_accounts": {
+      "title": "アカウント設定",
+      "no_accounts": "アカウントが設定されていません。",
+      "add_account": "新しいアカウントを追加",
+      "help": "↑/↓: 移動 • enter: 暗号化設定を編集 • e: サーバーを編集 • d: 削除"
+    },
+    "settings_theme": {
+      "title": "テーマ",
+      "current": "アクティブ",
+      "help": "↑/↓: 移動 • enter/スペース: テーマを適用"
+    },
+    "settings_mailing_lists": {
+      "title": "メーリングリスト",
+      "no_lists": "メーリングリストが設定されていません。",
+      "add_list": "新しいメーリングリストを追加",
+      "delete_confirm": "メーリングリストを削除しますか?",
+      "address_count": {
+        "other": "{count}個のアドレス"
+      },
+      "help": "↑/↓: 移動 • enter: 選択 • e: 編集 • d: 削除"
+    },
+    "settings_general": {
+      "title": "一般設定",
+      "disable_images": "画像表示を無効化",
+      "hide_tips": "コンテキストヒントを非表示",
+      "disable_notifications": "通知を無効化",
+      "date_format": "日付形式",
+      "language": "言語",
+      "signature": "署名を編集",
+      "signature_configured": "設定済み",
+      "signature_not_configured": "未設定",
+      "on": "オン",
+      "off": "オフ",
+      "restart_required": "言語変更を適用するには再起動が必要です"
+    },
+    "settings_encryption": {
+      "title": "アプリの暗号化",
+      "enabled": "暗号化は現在有効です。",
+      "disabled": "すべてのデータを暗号化するにはパスワードを設定してください。",
+      "password_label": "パスワード:",
+      "confirm_label": "パスワード確認:",
+      "enable_button": "暗号化を有効にする",
+      "disable_button": "Enterを押して暗号化を無効にする",
+      "disable_confirm": "暗号化を無効にしますか?",
+      "disable_warning": "すべてのデータは暗号化されずに保存されます。",
+      "encrypting": "データを暗号化中...",
+      "error_empty": "パスワードを空にすることはできません",
+      "error_mismatch": "パスワードが一致しません",
+      "help": "tab: 次へ • enter: 保存"
+    },
+    "password_prompt": {
+      "title": "Matchaはロックされています",
+      "enter_password": "パスワードを入力してください",
+      "error_empty": "パスワードを空にすることはできません",
+      "error_incorrect": "パスワードが正しくありません",
+      "help": "enter: ロック解除 • ctrl+c: 終了"
+    },
+    "email_view": {
+      "from": "差出人",
+      "to": "宛先",
+      "cc": "CC",
+      "bcc": "BCC",
+      "subject": "件名",
+      "date": "日付",
+      "attachments": "添付ファイル",
+      "download": "ダウンロード",
+      "save": "保存",
+      "reply": "返信",
+      "reply_all": "全員に返信",
+      "forward": "転送",
+      "delete": "削除",
+      "archive": "アーカイブ",
+      "help": "r: 返信 • f: 転送 • d: 削除 • a: アーカイブ • esc: 戻る"
+    },
+    "calendar": {
+      "title": "カレンダー",
+      "meeting": "会議",
+      "event": "イベント",
+      "accept": "承諾",
+      "decline": "辞退",
+      "tentative": "仮承諾",
+      "rsvp_sent": "RSVP送信済み: {response}"
+    },
+    "marketplace": {
+      "title": "プラグインマーケットプレイス",
+      "installing": "インストール中...",
+      "installed": "インストール済み",
+      "install": "インストール",
+      "error": "インストール失敗",
+      "help": "j/k: 移動 • enter: インストール • esc: 戻る"
+    },
+    "time": {
+      "just_now": "たった今",
+      "minute_ago": {
+        "other": "{count}分前"
+      },
+      "hour_ago": {
+        "other": "{count}時間前"
+      },
+      "day_ago": {
+        "other": "{count}日前"
+      },
+      "week_ago": {
+        "other": "{count}週間前"
+      },
+      "month_ago": {
+        "other": "{count}ヶ月前"
+      },
+      "year_ago": {
+        "other": "{count}年前"
+      },
+      "in_moment": "まもなく",
+      "in_minute": {
+        "other": "{count}分後"
+      },
+      "in_hour": {
+        "other": "{count}時間後"
+      },
+      "in_day": {
+        "other": "{count}日後"
+      }
+    }
+  }
+}

i18n/locales/pl.json 🔗

@@ -0,0 +1,275 @@
+{
+  "language": "pl",
+  "messages": {
+    "common": {
+      "yes": "Tak",
+      "no": "Nie",
+      "cancel": "Anuluj",
+      "ok": "OK",
+      "save": "Zapisz",
+      "delete": "Usuń",
+      "archive": "Archiwizuj",
+      "back": "Wstecz",
+      "next": "Dalej",
+      "previous": "Poprzedni",
+      "loading": "Ładowanie...",
+      "error": "Błąd",
+      "success": "Sukces"
+    },
+    "composer": {
+      "title": "Napisz Nową Wiadomość",
+      "from": "Od",
+      "to_placeholder": "Wprowadź adresy e-mail odbiorców.",
+      "cc_placeholder": "Odbiorcy kopii.",
+      "bcc_placeholder": "Odbiorcy ukrytej kopii.",
+      "subject_placeholder": "Temat",
+      "body_placeholder": "Napisz swoją wiadomość...",
+      "signature": "Podpis",
+      "signature_placeholder": "Twój podpis e-mail.",
+      "attachments": "Załączniki",
+      "attachments_none": "Brak",
+      "enter_to_add": "Enter aby dodać",
+      "encrypt_smime": "Zaszyfruj Wiadomość (S/MIME)",
+      "send": "Wyślij",
+      "switchable": "przełączalny",
+      "enter_to_switch": "Enter aby przełączyć",
+      "no_account": "brak skonfigurowanego konta",
+      "send_confirm": "Naciśnij Enter, aby wysłać wiadomość.",
+      "help": "Markdown/HTML • tab/shift+tab: nawigacja • ctrl+e: $EDITOR • esc: zapisz szkic i wyjdź",
+      "exit_confirm": "Czy na pewno chcesz wyjść? Ten szkic zostanie zapisany",
+      "sending": "Wysyłanie wiadomości...",
+      "sent": "Wiadomość wysłana pomyślnie",
+      "draft_saved": "Szkic zapisany"
+    },
+    "inbox": {
+      "title": "Skrzynka odbiorcza",
+      "all_accounts": "Wszystkie Konta",
+      "sent": "Wysłane",
+      "trash": "Kosz",
+      "archive": "Archiwum",
+      "empty": "Brak wiadomości",
+      "loading": "Ładowanie wiadomości...",
+      "refreshing": "Odświeżanie...",
+      "visual_mode": "tryb wizualny",
+      "delete": "usuń",
+      "archive": "archiwizuj",
+      "refresh": "odśwież",
+      "reply": "odpowiedz",
+      "forward": "przekaż",
+      "move": "przenieś",
+      "mark_read": "oznacz jako przeczytane",
+      "mark_unread": "oznacz jako nieprzeczytane",
+      "help_visual": "v: tryb wizualny • d: usuń • a: archiwizuj",
+      "help_navigation": "j/k: nawigacja • enter: otwórz • r: odśwież"
+    },
+    "choice": {
+      "what_to_do": "Co chciałbyś zrobić?",
+      "compose": "Napisz Wiadomość",
+      "inbox": "Zobacz Skrzynkę Odbiorczą",
+      "calendar": "Zobacz Kalendarz",
+      "settings": "Ustawienia",
+      "marketplace": "Sklep z Wtyczkami",
+      "drafts": "Szkice",
+      "help": "Użyj ↑/↓ do nawigacji, enter do wyboru i ctrl+c aby wyjść.",
+      "unknown": "nieznany",
+      "update_available": "Dostępna aktualizacja: {latest} (zainstalowana: {current}) — uruchom `matcha update` aby zaktualizować"
+    },
+    "folder_inbox": {
+      "folders_title": "Foldery",
+      "move_to_folder": "Przenieś do folderu:",
+      "move_single": "Przenieś wiadomość do folderu:",
+      "move_multiple": {
+        "one": "Przenieś {count} wiadomość do folderu:",
+        "few": "Przenieś {count} wiadomości do folderu:",
+        "many": "Przenieś {count} wiadomości do folderu:",
+        "other": "Przenieś {count} wiadomości do folderu:"
+      },
+      "help": "j/k: nawigacja  enter: przenieś  esc: anuluj",
+      "help_folders": "tab: następny folder • shift+tab: poprzedni folder • m: przenieś"
+    },
+    "login": {
+      "title": "Konta E-mail",
+      "add_account": "Dodaj Konto",
+      "edit_account": "Edytuj Konto",
+      "description": "Wprowadź dane logowania do swojego konta e-mail.",
+      "protocol_label": "Protokół",
+      "protocol_placeholder": "Protokół (imap, jmap lub pop3)",
+      "email_label": "E-mail",
+      "email_placeholder": "twoj.email@przyklad.pl",
+      "password_label": "Hasło",
+      "password_placeholder": "Hasło / Hasło Aplikacji",
+      "display_name_label": "Wyświetlana Nazwa",
+      "display_name_placeholder": "Twoje Imię",
+      "imap_server_label": "Serwer IMAP",
+      "smtp_server_label": "Serwer SMTP",
+      "port_label": "Port",
+      "save": "Zapisz Konto",
+      "delete": "Usuń Konto",
+      "delete_confirm": "Usunąć to konto?",
+      "tip_protocol": "Wybierz protokół: imap (domyślny), jmap lub pop3.",
+      "tip_app_password": "Dla Gmaila użyj Hasła Aplikacji zamiast zwykłego hasła."
+    },
+    "settings": {
+      "title": "Ustawienia",
+      "category_general": "Ogólne",
+      "category_accounts": "Konta",
+      "category_theme": "Motyw",
+      "category_mailing_lists": "Listy Mailingowe",
+      "category_encryption": "Szyfrowanie Aplikacji",
+      "help_menu": "↑/↓: nawigacja • prawo/enter: wybierz • esc: wstecz",
+      "help_content": "esc: powrót do menu"
+    },
+    "settings_accounts": {
+      "title": "Ustawienia Kont",
+      "no_accounts": "Brak skonfigurowanych kont.",
+      "add_account": "Dodaj Nowe Konto",
+      "help": "↑/↓: nawigacja • enter: edytuj konfigurację szyfrowania • e: edytuj serwer • d: usuń"
+    },
+    "settings_theme": {
+      "title": "Motyw",
+      "current": "aktywny",
+      "help": "↑/↓: nawigacja • enter/spacja: zastosuj motyw"
+    },
+    "settings_mailing_lists": {
+      "title": "Listy Mailingowe",
+      "no_lists": "Brak skonfigurowanych list mailingowych.",
+      "add_list": "Dodaj Nową Listę Mailingową",
+      "delete_confirm": "Usunąć listę mailingową?",
+      "address_count": {
+        "one": "{count} adres",
+        "few": "{count} adresy",
+        "many": "{count} adresów",
+        "other": "{count} adresu"
+      },
+      "help": "↑/↓: nawigacja • enter: wybierz • e: edytuj • d: usuń"
+    },
+    "settings_general": {
+      "title": "Ustawienia Ogólne",
+      "disable_images": "Wyłącz Wyświetlanie Obrazów",
+      "hide_tips": "Ukryj Wskazówki Kontekstowe",
+      "disable_notifications": "Wyłącz Powiadomienia",
+      "date_format": "Format Daty",
+      "language": "Język",
+      "signature": "Edytuj Podpis",
+      "signature_configured": "skonfigurowany",
+      "signature_not_configured": "nieskonfigurowany",
+      "on": "WŁ",
+      "off": "WYŁ",
+      "restart_required": "Wymagane ponowne uruchomienie w celu zastosowania zmiany języka"
+    },
+    "settings_encryption": {
+      "title": "Szyfrowanie Aplikacji",
+      "enabled": "Szyfrowanie jest obecnie włączone.",
+      "disabled": "Ustaw hasło, aby zaszyfrować wszystkie dane.",
+      "password_label": "Hasło:",
+      "confirm_label": "Potwierdź Hasło:",
+      "enable_button": "Włącz Szyfrowanie",
+      "disable_button": "Naciśnij enter, aby wyłączyć szyfrowanie",
+      "disable_confirm": "Wyłączyć szyfrowanie?",
+      "disable_warning": "Wszystkie dane będą przechowywane bez szyfrowania.",
+      "encrypting": "Szyfrowanie danych...",
+      "error_empty": "Hasło nie może być puste",
+      "error_mismatch": "Hasła nie pasują do siebie",
+      "help": "tab: następny • enter: zapisz"
+    },
+    "password_prompt": {
+      "title": "Matcha jest zablokowana",
+      "enter_password": "Wprowadź swoje hasło",
+      "error_empty": "Hasło nie może być puste",
+      "error_incorrect": "Nieprawidłowe hasło",
+      "help": "enter: odblokuj • ctrl+c: wyjdź"
+    },
+    "email_view": {
+      "from": "Od",
+      "to": "Do",
+      "cc": "DW",
+      "bcc": "UDW",
+      "subject": "Temat",
+      "date": "Data",
+      "attachments": "Załączniki",
+      "download": "Pobierz",
+      "save": "Zapisz",
+      "reply": "Odpowiedz",
+      "reply_all": "Odpowiedz Wszystkim",
+      "forward": "Przekaż",
+      "delete": "Usuń",
+      "archive": "Archiwizuj",
+      "help": "r: odpowiedz • f: przekaż • d: usuń • a: archiwizuj • esc: wstecz"
+    },
+    "calendar": {
+      "title": "Kalendarz",
+      "meeting": "Spotkanie",
+      "event": "Wydarzenie",
+      "accept": "Akceptuj",
+      "decline": "Odrzuć",
+      "tentative": "Wstępnie",
+      "rsvp_sent": "RSVP wysłane: {response}"
+    },
+    "marketplace": {
+      "title": "Sklep z Wtyczkami",
+      "installing": "Instalowanie...",
+      "installed": "Zainstalowane",
+      "install": "Instaluj",
+      "error": "Instalacja nieudana",
+      "help": "j/k: nawigacja • enter: instaluj • esc: wstecz"
+    },
+    "time": {
+      "just_now": "właśnie teraz",
+      "minute_ago": {
+        "one": "1 minutę temu",
+        "few": "{count} minuty temu",
+        "many": "{count} minut temu",
+        "other": "{count} minuty temu"
+      },
+      "hour_ago": {
+        "one": "1 godzinę temu",
+        "few": "{count} godziny temu",
+        "many": "{count} godzin temu",
+        "other": "{count} godziny temu"
+      },
+      "day_ago": {
+        "one": "1 dzień temu",
+        "few": "{count} dni temu",
+        "many": "{count} dni temu",
+        "other": "{count} dnia temu"
+      },
+      "week_ago": {
+        "one": "1 tydzień temu",
+        "few": "{count} tygodnie temu",
+        "many": "{count} tygodni temu",
+        "other": "{count} tygodnia temu"
+      },
+      "month_ago": {
+        "one": "1 miesiąc temu",
+        "few": "{count} miesiące temu",
+        "many": "{count} miesięcy temu",
+        "other": "{count} miesiąca temu"
+      },
+      "year_ago": {
+        "one": "1 rok temu",
+        "few": "{count} lata temu",
+        "many": "{count} lat temu",
+        "other": "{count} roku temu"
+      },
+      "in_moment": "za chwilę",
+      "in_minute": {
+        "one": "za 1 minutę",
+        "few": "za {count} minuty",
+        "many": "za {count} minut",
+        "other": "za {count} minuty"
+      },
+      "in_hour": {
+        "one": "za 1 godzinę",
+        "few": "za {count} godziny",
+        "many": "za {count} godzin",
+        "other": "za {count} godziny"
+      },
+      "in_day": {
+        "one": "za 1 dzień",
+        "few": "za {count} dni",
+        "many": "za {count} dni",
+        "other": "za {count} dnia"
+      }
+    }
+  }
+}

i18n/locales/pt.json 🔗

@@ -0,0 +1,253 @@
+{
+  "language": "pt",
+  "messages": {
+    "common": {
+      "yes": "Sim",
+      "no": "Não",
+      "cancel": "Cancelar",
+      "ok": "OK",
+      "save": "Salvar",
+      "delete": "Excluir",
+      "archive": "Arquivar",
+      "back": "Voltar",
+      "next": "Próximo",
+      "previous": "Anterior",
+      "loading": "Carregando...",
+      "error": "Erro",
+      "success": "Sucesso"
+    },
+    "composer": {
+      "title": "Redigir Novo E-mail",
+      "from": "De",
+      "to_placeholder": "Digite os endereços de e-mail dos destinatários.",
+      "cc_placeholder": "Destinatários em cópia.",
+      "bcc_placeholder": "Destinatários em cópia oculta.",
+      "subject_placeholder": "Assunto",
+      "body_placeholder": "Redija sua mensagem...",
+      "signature": "Assinatura",
+      "signature_placeholder": "Sua assinatura de e-mail.",
+      "attachments": "Anexos",
+      "attachments_none": "Nenhum",
+      "enter_to_add": "Enter para adicionar",
+      "encrypt_smime": "Criptografar E-mail (S/MIME)",
+      "send": "Enviar",
+      "switchable": "alterável",
+      "enter_to_switch": "Enter para trocar",
+      "no_account": "nenhuma conta configurada",
+      "send_confirm": "Pressione Enter para enviar o e-mail.",
+      "help": "Markdown/HTML • tab/shift+tab: navegar • ctrl+e: $EDITOR • esc: salvar rascunho & sair",
+      "exit_confirm": "Tem certeza de que deseja sair? Este rascunho será salvo",
+      "sending": "Enviando e-mail...",
+      "sent": "E-mail enviado com sucesso",
+      "draft_saved": "Rascunho salvo"
+    },
+    "inbox": {
+      "title": "Caixa de entrada",
+      "all_accounts": "Todas as Contas",
+      "sent": "Enviados",
+      "trash": "Lixeira",
+      "archive": "Arquivo",
+      "empty": "Sem e-mails",
+      "loading": "Carregando e-mails...",
+      "refreshing": "Atualizando...",
+      "visual_mode": "modo visual",
+      "delete": "excluir",
+      "archive": "arquivar",
+      "refresh": "atualizar",
+      "reply": "responder",
+      "forward": "encaminhar",
+      "move": "mover",
+      "mark_read": "marcar como lido",
+      "mark_unread": "marcar como não lido",
+      "help_visual": "v: modo visual • d: excluir • a: arquivar",
+      "help_navigation": "j/k: navegar • enter: abrir • r: atualizar"
+    },
+    "choice": {
+      "what_to_do": "O que você gostaria de fazer?",
+      "compose": "Redigir E-mail",
+      "inbox": "Ver Caixa de Entrada",
+      "calendar": "Ver Calendário",
+      "settings": "Configurações",
+      "marketplace": "Loja de Plugins",
+      "drafts": "Rascunhos",
+      "help": "Use ↑/↓ para navegar, enter para selecionar e ctrl+c para sair.",
+      "unknown": "desconhecido",
+      "update_available": "Atualização disponível: {latest} (instalada: {current}) — execute `matcha update` para atualizar"
+    },
+    "folder_inbox": {
+      "folders_title": "Pastas",
+      "move_to_folder": "Mover para pasta:",
+      "move_single": "Mover e-mail para pasta:",
+      "move_multiple": {
+        "one": "Mover {count} e-mail para pasta:",
+        "other": "Mover {count} e-mails para pasta:"
+      },
+      "help": "j/k: navegar  enter: mover  esc: cancelar",
+      "help_folders": "tab: próxima pasta • shift+tab: pasta anterior • m: mover"
+    },
+    "login": {
+      "title": "Contas de E-mail",
+      "add_account": "Adicionar Conta",
+      "edit_account": "Editar Conta",
+      "description": "Digite as credenciais da sua conta de e-mail.",
+      "protocol_label": "Protocolo",
+      "protocol_placeholder": "Protocolo (imap, jmap ou pop3)",
+      "email_label": "E-mail",
+      "email_placeholder": "seu.email@exemplo.com",
+      "password_label": "Senha",
+      "password_placeholder": "Senha / Senha de Aplicativo",
+      "display_name_label": "Nome de Exibição",
+      "display_name_placeholder": "Seu Nome",
+      "imap_server_label": "Servidor IMAP",
+      "smtp_server_label": "Servidor SMTP",
+      "port_label": "Porta",
+      "save": "Salvar Conta",
+      "delete": "Excluir Conta",
+      "delete_confirm": "Excluir esta conta?",
+      "tip_protocol": "Escolha o protocolo: imap (padrão), jmap ou pop3.",
+      "tip_app_password": "Para Gmail, use uma Senha de Aplicativo em vez de sua senha normal."
+    },
+    "settings": {
+      "title": "Configurações",
+      "category_general": "Geral",
+      "category_accounts": "Contas",
+      "category_theme": "Tema",
+      "category_mailing_lists": "Listas de E-mail",
+      "category_encryption": "Criptografia do Aplicativo",
+      "help_menu": "↑/↓: navegar • direita/enter: selecionar • esc: voltar",
+      "help_content": "esc: voltar ao menu"
+    },
+    "settings_accounts": {
+      "title": "Configurações de Contas",
+      "no_accounts": "Nenhuma conta configurada.",
+      "add_account": "Adicionar Nova Conta",
+      "help": "↑/↓: navegar • enter: editar config. de criptografia • e: editar servidor • d: excluir"
+    },
+    "settings_theme": {
+      "title": "Tema",
+      "current": "ativo",
+      "help": "↑/↓: navegar • enter/espaço: aplicar tema"
+    },
+    "settings_mailing_lists": {
+      "title": "Listas de E-mail",
+      "no_lists": "Nenhuma lista de e-mail configurada.",
+      "add_list": "Adicionar Nova Lista de E-mail",
+      "delete_confirm": "Excluir lista de e-mail?",
+      "address_count": {
+        "one": "{count} endereço",
+        "other": "{count} endereços"
+      },
+      "help": "↑/↓: navegar • enter: selecionar • e: editar • d: excluir"
+    },
+    "settings_general": {
+      "title": "Configurações Gerais",
+      "disable_images": "Desativar Exibição de Imagens",
+      "hide_tips": "Ocultar Dicas Contextuais",
+      "disable_notifications": "Desativar Notificações",
+      "date_format": "Formato de Data",
+      "language": "Idioma",
+      "signature": "Editar Assinatura",
+      "signature_configured": "configurada",
+      "signature_not_configured": "não configurada",
+      "on": "ATIVADO",
+      "off": "DESATIVADO",
+      "restart_required": "Reinicialização necessária para aplicar a mudança de idioma"
+    },
+    "settings_encryption": {
+      "title": "Criptografia do Aplicativo",
+      "enabled": "A criptografia está atualmente ativada.",
+      "disabled": "Defina uma senha para criptografar todos os dados.",
+      "password_label": "Senha:",
+      "confirm_label": "Confirmar Senha:",
+      "enable_button": "Ativar Criptografia",
+      "disable_button": "Pressione enter para desativar a criptografia",
+      "disable_confirm": "Desativar criptografia?",
+      "disable_warning": "Todos os dados serão armazenados sem criptografia.",
+      "encrypting": "Criptografando dados...",
+      "error_empty": "A senha não pode estar vazia",
+      "error_mismatch": "As senhas não coincidem",
+      "help": "tab: próximo • enter: salvar"
+    },
+    "password_prompt": {
+      "title": "Matcha está bloqueado",
+      "enter_password": "Digite sua senha",
+      "error_empty": "A senha não pode estar vazia",
+      "error_incorrect": "Senha incorreta",
+      "help": "enter: desbloquear • ctrl+c: sair"
+    },
+    "email_view": {
+      "from": "De",
+      "to": "Para",
+      "cc": "Cc",
+      "bcc": "Cco",
+      "subject": "Assunto",
+      "date": "Data",
+      "attachments": "Anexos",
+      "download": "Baixar",
+      "save": "Salvar",
+      "reply": "Responder",
+      "reply_all": "Responder a Todos",
+      "forward": "Encaminhar",
+      "delete": "Excluir",
+      "archive": "Arquivar",
+      "help": "r: responder • f: encaminhar • d: excluir • a: arquivar • esc: voltar"
+    },
+    "calendar": {
+      "title": "Calendário",
+      "meeting": "Reunião",
+      "event": "Evento",
+      "accept": "Aceitar",
+      "decline": "Recusar",
+      "tentative": "Provisório",
+      "rsvp_sent": "RSVP enviado: {response}"
+    },
+    "marketplace": {
+      "title": "Loja de Plugins",
+      "installing": "Instalando...",
+      "installed": "Instalado",
+      "install": "Instalar",
+      "error": "Falha na instalação",
+      "help": "j/k: navegar • enter: instalar • esc: voltar"
+    },
+    "time": {
+      "just_now": "agora mesmo",
+      "minute_ago": {
+        "one": "há 1 minuto",
+        "other": "há {count} minutos"
+      },
+      "hour_ago": {
+        "one": "há 1 hora",
+        "other": "há {count} horas"
+      },
+      "day_ago": {
+        "one": "há 1 dia",
+        "other": "há {count} dias"
+      },
+      "week_ago": {
+        "one": "há 1 semana",
+        "other": "há {count} semanas"
+      },
+      "month_ago": {
+        "one": "há 1 mês",
+        "other": "há {count} meses"
+      },
+      "year_ago": {
+        "one": "há 1 ano",
+        "other": "há {count} anos"
+      },
+      "in_moment": "em um momento",
+      "in_minute": {
+        "one": "em 1 minuto",
+        "other": "em {count} minutos"
+      },
+      "in_hour": {
+        "one": "em 1 hora",
+        "other": "em {count} horas"
+      },
+      "in_day": {
+        "one": "em 1 dia",
+        "other": "em {count} dias"
+      }
+    }
+  }
+}

i18n/locales/ru.json 🔗

@@ -0,0 +1,275 @@
+{
+  "language": "ru",
+  "messages": {
+    "common": {
+      "yes": "Да",
+      "no": "Нет",
+      "cancel": "Отмена",
+      "ok": "OK",
+      "save": "Сохранить",
+      "delete": "Удалить",
+      "archive": "Архивировать",
+      "back": "Назад",
+      "next": "Далее",
+      "previous": "Предыдущий",
+      "loading": "Загрузка...",
+      "error": "Ошибка",
+      "success": "Успех"
+    },
+    "composer": {
+      "title": "Создать Новое Письмо",
+      "from": "От",
+      "to_placeholder": "Введите адреса электронной почты получателей.",
+      "cc_placeholder": "Получатели копии.",
+      "bcc_placeholder": "Получатели скрытой копии.",
+      "subject_placeholder": "Тема",
+      "body_placeholder": "Напишите сообщение...",
+      "signature": "Подпись",
+      "signature_placeholder": "Ваша подпись электронной почты.",
+      "attachments": "Вложения",
+      "attachments_none": "Нет",
+      "enter_to_add": "Enter для добавления",
+      "encrypt_smime": "Зашифровать Письмо (S/MIME)",
+      "send": "Отправить",
+      "switchable": "переключаемый",
+      "enter_to_switch": "Enter для переключения",
+      "no_account": "учётная запись не настроена",
+      "send_confirm": "Нажмите Enter для отправки письма.",
+      "help": "Markdown/HTML • tab/shift+tab: навигация • ctrl+e: $EDITOR • esc: сохранить черновик и выйти",
+      "exit_confirm": "Вы уверены, что хотите выйти? Этот черновик будет сохранён",
+      "sending": "Отправка письма...",
+      "sent": "Письмо успешно отправлено",
+      "draft_saved": "Черновик сохранён"
+    },
+    "inbox": {
+      "title": "Входящие",
+      "all_accounts": "Все Учётные Записи",
+      "sent": "Отправленные",
+      "trash": "Корзина",
+      "archive": "Архив",
+      "empty": "Нет писем",
+      "loading": "Загрузка писем...",
+      "refreshing": "Обновление...",
+      "visual_mode": "визуальный режим",
+      "delete": "удалить",
+      "archive": "архивировать",
+      "refresh": "обновить",
+      "reply": "ответить",
+      "forward": "переслать",
+      "move": "переместить",
+      "mark_read": "отметить как прочитанное",
+      "mark_unread": "отметить как непрочитанное",
+      "help_visual": "v: визуальный режим • d: удалить • a: архивировать",
+      "help_navigation": "j/k: навигация • enter: открыть • r: обновить"
+    },
+    "choice": {
+      "what_to_do": "Что вы хотите сделать?",
+      "compose": "Создать Письмо",
+      "inbox": "Просмотреть Входящие",
+      "calendar": "Просмотреть Календарь",
+      "settings": "Настройки",
+      "marketplace": "Магазин Плагинов",
+      "drafts": "Черновики",
+      "help": "Используйте ↑/↓ для навигации, enter для выбора и ctrl+c для выхода.",
+      "unknown": "неизвестно",
+      "update_available": "Доступно обновление: {latest} (установлено: {current}) — запустите `matcha update` для обновления"
+    },
+    "folder_inbox": {
+      "folders_title": "Папки",
+      "move_to_folder": "Переместить в папку:",
+      "move_single": "Переместить письмо в папку:",
+      "move_multiple": {
+        "one": "Переместить {count} письмо в папку:",
+        "few": "Переместить {count} письма в папку:",
+        "many": "Переместить {count} писем в папку:",
+        "other": "Переместить {count} письма в папку:"
+      },
+      "help": "j/k: навигация  enter: переместить  esc: отмена",
+      "help_folders": "tab: следующая папка • shift+tab: предыдущая папка • m: переместить"
+    },
+    "login": {
+      "title": "Учётные Записи Электронной Почты",
+      "add_account": "Добавить Учётную Запись",
+      "edit_account": "Редактировать Учётную Запись",
+      "description": "Введите учётные данные вашей учётной записи электронной почты.",
+      "protocol_label": "Протокол",
+      "protocol_placeholder": "Протокол (imap, jmap или pop3)",
+      "email_label": "Электронная Почта",
+      "email_placeholder": "your.email@example.com",
+      "password_label": "Пароль",
+      "password_placeholder": "Пароль / Пароль Приложения",
+      "display_name_label": "Отображаемое Имя",
+      "display_name_placeholder": "Ваше Имя",
+      "imap_server_label": "IMAP Сервер",
+      "smtp_server_label": "SMTP Сервер",
+      "port_label": "Порт",
+      "save": "Сохранить Учётную Запись",
+      "delete": "Удалить Учётную Запись",
+      "delete_confirm": "Удалить эту учётную запись?",
+      "tip_protocol": "Выберите протокол: imap (по умолчанию), jmap или pop3.",
+      "tip_app_password": "Для Gmail используйте Пароль Приложения вместо обычного пароля."
+    },
+    "settings": {
+      "title": "Настройки",
+      "category_general": "Общие",
+      "category_accounts": "Учётные Записи",
+      "category_theme": "Тема",
+      "category_mailing_lists": "Списки Рассылки",
+      "category_encryption": "Шифрование Приложения",
+      "help_menu": "↑/↓: навигация • вправо/enter: выбор • esc: назад",
+      "help_content": "esc: назад в меню"
+    },
+    "settings_accounts": {
+      "title": "Настройки Учётных Записей",
+      "no_accounts": "Учётные записи не настроены.",
+      "add_account": "Добавить Новую Учётную Запись",
+      "help": "↑/↓: навигация • enter: редактировать конфигурацию шифрования • e: редактировать сервер • d: удалить"
+    },
+    "settings_theme": {
+      "title": "Тема",
+      "current": "активная",
+      "help": "↑/↓: навигация • enter/пробел: применить тему"
+    },
+    "settings_mailing_lists": {
+      "title": "Списки Рассылки",
+      "no_lists": "Списки рассылки не настроены.",
+      "add_list": "Добавить Новый Список Рассылки",
+      "delete_confirm": "Удалить список рассылки?",
+      "address_count": {
+        "one": "{count} адрес",
+        "few": "{count} адреса",
+        "many": "{count} адресов",
+        "other": "{count} адреса"
+      },
+      "help": "↑/↓: навигация • enter: выбрать • e: редактировать • d: удалить"
+    },
+    "settings_general": {
+      "title": "Общие Настройки",
+      "disable_images": "Отключить Отображение Изображений",
+      "hide_tips": "Скрыть Контекстные Подсказки",
+      "disable_notifications": "Отключить Уведомления",
+      "date_format": "Формат Даты",
+      "language": "Язык",
+      "signature": "Редактировать Подпись",
+      "signature_configured": "настроена",
+      "signature_not_configured": "не настроена",
+      "on": "ВКЛ",
+      "off": "ВЫКЛ",
+      "restart_required": "Требуется перезапуск для применения изменения языка"
+    },
+    "settings_encryption": {
+      "title": "Шифрование Приложения",
+      "enabled": "Шифрование в настоящее время включено.",
+      "disabled": "Установите пароль для шифрования всех данных.",
+      "password_label": "Пароль:",
+      "confirm_label": "Подтвердите Пароль:",
+      "enable_button": "Включить Шифрование",
+      "disable_button": "Нажмите enter для отключения шифрования",
+      "disable_confirm": "Отключить шифрование?",
+      "disable_warning": "Все данные будут храниться незашифрованными.",
+      "encrypting": "Шифрование данных...",
+      "error_empty": "Пароль не может быть пустым",
+      "error_mismatch": "Пароли не совпадают",
+      "help": "tab: следующий • enter: сохранить"
+    },
+    "password_prompt": {
+      "title": "Matcha заблокирован",
+      "enter_password": "Введите пароль",
+      "error_empty": "Пароль не может быть пустым",
+      "error_incorrect": "Неверный пароль",
+      "help": "enter: разблокировать • ctrl+c: выйти"
+    },
+    "email_view": {
+      "from": "От",
+      "to": "Кому",
+      "cc": "Копия",
+      "bcc": "Скрытая Копия",
+      "subject": "Тема",
+      "date": "Дата",
+      "attachments": "Вложения",
+      "download": "Скачать",
+      "save": "Сохранить",
+      "reply": "Ответить",
+      "reply_all": "Ответить Всем",
+      "forward": "Переслать",
+      "delete": "Удалить",
+      "archive": "Архивировать",
+      "help": "r: ответить • f: переслать • d: удалить • a: архивировать • esc: назад"
+    },
+    "calendar": {
+      "title": "Календарь",
+      "meeting": "Встреча",
+      "event": "Событие",
+      "accept": "Принять",
+      "decline": "Отклонить",
+      "tentative": "Предварительно",
+      "rsvp_sent": "RSVP отправлен: {response}"
+    },
+    "marketplace": {
+      "title": "Магазин Плагинов",
+      "installing": "Установка...",
+      "installed": "Установлено",
+      "install": "Установить",
+      "error": "Не удалось установить",
+      "help": "j/k: навигация • enter: установить • esc: назад"
+    },
+    "time": {
+      "just_now": "только что",
+      "minute_ago": {
+        "one": "1 минуту назад",
+        "few": "{count} минуты назад",
+        "many": "{count} минут назад",
+        "other": "{count} минуты назад"
+      },
+      "hour_ago": {
+        "one": "1 час назад",
+        "few": "{count} часа назад",
+        "many": "{count} часов назад",
+        "other": "{count} часа назад"
+      },
+      "day_ago": {
+        "one": "1 день назад",
+        "few": "{count} дня назад",
+        "many": "{count} дней назад",
+        "other": "{count} дня назад"
+      },
+      "week_ago": {
+        "one": "1 неделю назад",
+        "few": "{count} недели назад",
+        "many": "{count} недель назад",
+        "other": "{count} недели назад"
+      },
+      "month_ago": {
+        "one": "1 месяц назад",
+        "few": "{count} месяца назад",
+        "many": "{count} месяцев назад",
+        "other": "{count} месяца назад"
+      },
+      "year_ago": {
+        "one": "1 год назад",
+        "few": "{count} года назад",
+        "many": "{count} лет назад",
+        "other": "{count} года назад"
+      },
+      "in_moment": "через мгновение",
+      "in_minute": {
+        "one": "через 1 минуту",
+        "few": "через {count} минуты",
+        "many": "через {count} минут",
+        "other": "через {count} минуты"
+      },
+      "in_hour": {
+        "one": "через 1 час",
+        "few": "через {count} часа",
+        "many": "через {count} часов",
+        "other": "через {count} часа"
+      },
+      "in_day": {
+        "one": "через 1 день",
+        "few": "через {count} дня",
+        "many": "через {count} дней",
+        "other": "через {count} дня"
+      }
+    }
+  }
+}

i18n/locales/uk.json 🔗

@@ -0,0 +1,264 @@
+{
+  "language": "uk",
+  "messages": {
+    "common": {
+      "yes": "Так",
+      "no": "Ні",
+      "cancel": "Скасувати",
+      "ok": "Гаразд",
+      "save": "Зберегти",
+      "delete": "Видалити",
+      "archive": "Архівувати",
+      "back": "Назад",
+      "next": "Далі",
+      "previous": "Попередній",
+      "loading": "Завантаження...",
+      "error": "Помилка",
+      "success": "Успіх"
+    },
+    "composer": {
+      "title": "Написати новий лист",
+      "from": "Від",
+      "to_placeholder": "Введіть адреси отримувачів.",
+      "cc_placeholder": "Копія.",
+      "bcc_placeholder": "Прихована копія.",
+      "subject_placeholder": "Тема",
+      "body_placeholder": "Напишіть своє повідомлення...",
+      "signature": "Підпис",
+      "signature_placeholder": "Ваш підпис електронної пошти.",
+      "attachments": "Вкладення",
+      "attachments_none": "Немає",
+      "enter_to_add": "Enter щоб додати",
+      "encrypt_smime": "Зашифрувати лист (S/MIME)",
+      "send": "Надіслати",
+      "switchable": "можна змінити",
+      "enter_to_switch": "Enter щоб змінити",
+      "no_account": "обліковий запис не налаштовано",
+      "send_confirm": "Натисніть Enter, щоб надіслати лист.",
+      "help": "Markdown/HTML • tab/shift+tab: навігація • ctrl+e: $EDITOR • esc: зберегти чернетку та вийти",
+      "exit_confirm": "Ви впевнені, що хочете вийти? Цей чернетку буде збережено",
+      "sending": "Відправлення листа...",
+      "sent": "Лист успішно надіслано",
+      "draft_saved": "Чернетку збережено"
+    },
+    "inbox": {
+      "title": "Вхідні",
+      "all_accounts": "Всі облікові записи",
+      "sent": "Відправлені",
+      "trash": "Кошик",
+      "archive": "Архів",
+      "empty": "Немає листів",
+      "loading": "Завантаження листів...",
+      "refreshing": "Оновлення...",
+      "visual_mode": "візуальний режим",
+      "delete": "видалити",
+      "archive": "архівувати",
+      "refresh": "оновити",
+      "reply": "відповісти",
+      "forward": "переслати",
+      "move": "перемістити",
+      "mark_read": "позначити як прочитане",
+      "mark_unread": "позначити як непрочитане",
+      "help_visual": "v: візуальний режим • d: видалити • a: архівувати",
+      "help_navigation": "j/k: навігація • enter: відкрити • r: оновити"
+    },
+    "choice": {
+      "what_to_do": "Що б ви хотіли зробити?",
+      "compose": "Написати лист",
+      "inbox": "Переглянути вхідні",
+      "calendar": "Переглянути календар",
+      "settings": "Налаштування",
+      "marketplace": "Магазин плагінів",
+      "drafts": "Чернетки",
+      "help": "Використовуйте ↑/↓ для навігації, enter для вибору, та ctrl+c щоб вийти.",
+      "unknown": "невідомо",
+      "update_available": "Доступне оновлення: {latest} (встановлено: {current}) — виконайте `matcha update` для оновлення"
+    },
+    "folder_inbox": {
+      "folders_title": "Теки",
+      "move_to_folder": "Перемістити до теки:",
+      "move_single": "Перемістити лист до теки:",
+      "move_multiple": {
+        "one": "Перемістити {count} лист до теки:",
+        "few": "Перемістити {count} листи до теки:",
+        "other": "Перемістити {count} листів до теки:"
+      },
+      "help": "j/k: навігація  enter: перемістити  esc: скасувати",
+      "help_folders": "tab: наступна тека • shift+tab: попередня тека • m: перемістити"
+    },
+    "login": {
+      "title": "Облікові записи електронної пошти",
+      "add_account": "Додати обліковий запис",
+      "edit_account": "Редагувати обліковий запис",
+      "description": "Введіть облікові дані вашого облікового запису електронної пошти.",
+      "protocol_label": "Протокол",
+      "protocol_placeholder": "Протокол (imap, jmap або pop3)",
+      "email_label": "Електронна пошта",
+      "email_placeholder": "your.email@example.com",
+      "password_label": "Пароль",
+      "password_placeholder": "Пароль / Пароль додатка",
+      "display_name_label": "Відображуване ім'я",
+      "display_name_placeholder": "Ваше ім'я",
+      "imap_server_label": "IMAP сервер",
+      "smtp_server_label": "SMTP сервер",
+      "port_label": "Порт",
+      "save": "Зберегти обліковий запис",
+      "delete": "Видалити обліковий запис",
+      "delete_confirm": "Видалити цей обліковий запис?",
+      "tip_protocol": "Виберіть протокол: imap (за замовчуванням), jmap або pop3.",
+      "tip_app_password": "Для Gmail використовуйте пароль додатка замість звичайного пароля."
+    },
+    "settings": {
+      "title": "Налаштування",
+      "category_general": "Загальні",
+      "category_accounts": "Облікові записи",
+      "category_theme": "Тема",
+      "category_mailing_lists": "Списки розсилки",
+      "category_encryption": "Шифрування додатка",
+      "help_menu": "↑/↓: навігація • right/enter: вибрати • esc: назад",
+      "help_content": "esc: назад до меню"
+    },
+    "settings_accounts": {
+      "title": "Налаштування облікових записів",
+      "no_accounts": "Облікові записи не налаштовано.",
+      "add_account": "Додати новий обліковий запис",
+      "help": "↑/↓: навігація • enter: редагувати криптоконфіг • e: редагувати сервер • d: видалити"
+    },
+    "settings_theme": {
+      "title": "Тема",
+      "current": "активна",
+      "help": "↑/↓: навігація • enter/пробіл: застосувати тему"
+    },
+    "settings_mailing_lists": {
+      "title": "Списки розсилки",
+      "no_lists": "Списки розсилки не налаштовано.",
+      "add_list": "Додати новий список розсилки",
+      "delete_confirm": "Видалити список розсилки?",
+      "address_count": {
+        "one": "{count} адреса",
+        "few": "{count} адреси",
+        "other": "{count} адрес"
+      },
+      "help": "↑/↓: навігація • enter: вибрати • e: редагувати • d: видалити"
+    },
+    "settings_general": {
+      "title": "Загальні налаштування",
+      "disable_images": "Вимкнути показ зображень",
+      "hide_tips": "Приховати контекстні підказки",
+      "disable_notifications": "Вимкнути сповіщення",
+      "date_format": "Формат дати",
+      "language": "Мова",
+      "signature": "Редагувати підпис",
+      "signature_configured": "налаштовано",
+      "signature_not_configured": "не налаштовано",
+      "on": "УВІМК",
+      "off": "ВИМК",
+      "restart_required": "Для застосування зміни мови потрібен перезапуск"
+    },
+    "settings_encryption": {
+      "title": "Шифрування додатка",
+      "enabled": "Шифрування наразі ввімкнено.",
+      "disabled": "Встановіть пароль для шифрування всіх даних.",
+      "password_label": "Пароль:",
+      "confirm_label": "Підтвердіть пароль:",
+      "enable_button": "Увімкнути шифрування",
+      "disable_button": "Натисніть enter, щоб вимкнути шифрування",
+      "disable_confirm": "Вимкнути шифрування?",
+      "disable_warning": "Всі дані будуть зберігатися без шифрування.",
+      "encrypting": "Шифрування даних...",
+      "error_empty": "Пароль не може бути порожнім",
+      "error_mismatch": "Паролі не співпадають",
+      "help": "tab: далі • enter: зберегти"
+    },
+    "password_prompt": {
+      "title": "Matcha заблоковано",
+      "enter_password": "Введіть ваш пароль",
+      "error_empty": "Пароль не може бути порожнім",
+      "error_incorrect": "Неправильний пароль",
+      "help": "enter: розблокувати • ctrl+c: вийти"
+    },
+    "email_view": {
+      "from": "Від",
+      "to": "Кому",
+      "cc": "Копія",
+      "bcc": "Прихована копія",
+      "subject": "Тема",
+      "date": "Дата",
+      "attachments": "Вкладення",
+      "download": "Завантажити",
+      "save": "Зберегти",
+      "reply": "Відповісти",
+      "reply_all": "Відповісти всім",
+      "forward": "Переслати",
+      "delete": "Видалити",
+      "archive": "Архівувати",
+      "help": "r: відповісти • f: переслати • d: видалити • a: архівувати • esc: назад"
+    },
+    "calendar": {
+      "title": "Календар",
+      "meeting": "Зустріч",
+      "event": "Подія",
+      "accept": "Прийняти",
+      "decline": "Відхилити",
+      "tentative": "Можливо",
+      "rsvp_sent": "RSVP надіслано: {response}"
+    },
+    "marketplace": {
+      "title": "Магазин плагінів",
+      "installing": "Встановлення...",
+      "installed": "Встановлено",
+      "install": "Встановити",
+      "error": "Не вдалося встановити",
+      "help": "j/k: навігація • enter: встановити • esc: назад"
+    },
+    "time": {
+      "just_now": "щойно",
+      "minute_ago": {
+        "one": "1 хвилину тому",
+        "few": "{count} хвилини тому",
+        "other": "{count} хвилин тому"
+      },
+      "hour_ago": {
+        "one": "1 годину тому",
+        "few": "{count} години тому",
+        "other": "{count} годин тому"
+      },
+      "day_ago": {
+        "one": "1 день тому",
+        "few": "{count} дні тому",
+        "other": "{count} днів тому"
+      },
+      "week_ago": {
+        "one": "1 тиждень тому",
+        "few": "{count} тижні тому",
+        "other": "{count} тижнів тому"
+      },
+      "month_ago": {
+        "one": "1 місяць тому",
+        "few": "{count} місяці тому",
+        "other": "{count} місяців тому"
+      },
+      "year_ago": {
+        "one": "1 рік тому",
+        "few": "{count} роки тому",
+        "other": "{count} років тому"
+      },
+      "in_moment": "через мить",
+      "in_minute": {
+        "one": "через 1 хвилину",
+        "few": "через {count} хвилини",
+        "other": "через {count} хвилин"
+      },
+      "in_hour": {
+        "one": "через 1 годину",
+        "few": "через {count} години",
+        "other": "через {count} годин"
+      },
+      "in_day": {
+        "one": "через 1 день",
+        "few": "через {count} дні",
+        "other": "через {count} днів"
+      }
+    }
+  }
+}

i18n/locales/zh.json 🔗

@@ -0,0 +1,242 @@
+{
+  "language": "zh",
+  "messages": {
+    "common": {
+      "yes": "是",
+      "no": "否",
+      "cancel": "取消",
+      "ok": "确定",
+      "save": "保存",
+      "delete": "删除",
+      "archive": "存档",
+      "back": "返回",
+      "next": "下一步",
+      "previous": "上一步",
+      "loading": "加载中...",
+      "error": "错误",
+      "success": "成功"
+    },
+    "composer": {
+      "title": "撰写新邮件",
+      "from": "发件人",
+      "to_placeholder": "输入收件人电子邮件地址。",
+      "cc_placeholder": "抄送收件人。",
+      "bcc_placeholder": "密送收件人。",
+      "subject_placeholder": "主题",
+      "body_placeholder": "撰写您的消息...",
+      "signature": "签名",
+      "signature_placeholder": "您的电子邮件签名。",
+      "attachments": "附件",
+      "attachments_none": "无",
+      "enter_to_add": "按Enter添加",
+      "encrypt_smime": "加密邮件 (S/MIME)",
+      "send": "发送",
+      "switchable": "可切换",
+      "enter_to_switch": "按Enter切换",
+      "no_account": "未配置账户",
+      "send_confirm": "按Enter发送邮件。",
+      "help": "Markdown/HTML • tab/shift+tab: 导航 • ctrl+e: $EDITOR • esc: 保存草稿并退出",
+      "exit_confirm": "确定要退出吗?此草稿将被保存",
+      "sending": "正在发送邮件...",
+      "sent": "邮件发送成功",
+      "draft_saved": "草稿已保存"
+    },
+    "inbox": {
+      "title": "收件箱",
+      "all_accounts": "所有账户",
+      "sent": "已发送",
+      "trash": "垃圾箱",
+      "archive": "存档",
+      "empty": "没有邮件",
+      "loading": "正在加载邮件...",
+      "refreshing": "正在刷新...",
+      "visual_mode": "视觉模式",
+      "delete": "删除",
+      "archive": "存档",
+      "refresh": "刷新",
+      "reply": "回复",
+      "forward": "转发",
+      "move": "移动",
+      "mark_read": "标记为已读",
+      "mark_unread": "标记为未读",
+      "help_visual": "v: 视觉模式 • d: 删除 • a: 存档",
+      "help_navigation": "j/k: 导航 • enter: 打开 • r: 刷新"
+    },
+    "choice": {
+      "what_to_do": "您想做什么?",
+      "compose": "撰写邮件",
+      "inbox": "查看收件箱",
+      "calendar": "查看日历",
+      "settings": "设置",
+      "marketplace": "插件市场",
+      "drafts": "草稿",
+      "help": "使用 ↑/↓ 导航,按 enter 选择,按 ctrl+c 退出。",
+      "unknown": "未知",
+      "update_available": "可用更新: {latest} (已安装: {current}) — 运行 `matcha update` 进行升级"
+    },
+    "folder_inbox": {
+      "folders_title": "文件夹",
+      "move_to_folder": "移动到文件夹:",
+      "move_single": "将邮件移动到文件夹:",
+      "move_multiple": {
+        "other": "将 {count} 封邮件移动到文件夹:"
+      },
+      "help": "j/k: 导航  enter: 移动  esc: 取消",
+      "help_folders": "tab: 下一个文件夹 • shift+tab: 上一个文件夹 • m: 移动"
+    },
+    "login": {
+      "title": "电子邮件账户",
+      "add_account": "添加账户",
+      "edit_account": "编辑账户",
+      "description": "输入您的电子邮件账户凭据。",
+      "protocol_label": "协议",
+      "protocol_placeholder": "协议 (imap, jmap 或 pop3)",
+      "email_label": "电子邮件",
+      "email_placeholder": "your.email@example.com",
+      "password_label": "密码",
+      "password_placeholder": "密码 / 应用专用密码",
+      "display_name_label": "显示名称",
+      "display_name_placeholder": "您的姓名",
+      "imap_server_label": "IMAP服务器",
+      "smtp_server_label": "SMTP服务器",
+      "port_label": "端口",
+      "save": "保存账户",
+      "delete": "删除账户",
+      "delete_confirm": "删除此账户?",
+      "tip_protocol": "选择协议: imap (默认), jmap 或 pop3。",
+      "tip_app_password": "对于Gmail,请使用应用专用密码而不是常规密码。"
+    },
+    "settings": {
+      "title": "设置",
+      "category_general": "常规",
+      "category_accounts": "账户",
+      "category_theme": "主题",
+      "category_mailing_lists": "邮件列表",
+      "category_encryption": "应用加密",
+      "help_menu": "↑/↓: 导航 • 右/enter: 选择 • esc: 返回",
+      "help_content": "esc: 返回菜单"
+    },
+    "settings_accounts": {
+      "title": "账户设置",
+      "no_accounts": "未配置账户。",
+      "add_account": "添加新账户",
+      "help": "↑/↓: 导航 • enter: 编辑加密配置 • e: 编辑服务器 • d: 删除"
+    },
+    "settings_theme": {
+      "title": "主题",
+      "current": "活动",
+      "help": "↑/↓: 导航 • enter/空格: 应用主题"
+    },
+    "settings_mailing_lists": {
+      "title": "邮件列表",
+      "no_lists": "未配置邮件列表。",
+      "add_list": "添加新邮件列表",
+      "delete_confirm": "删除邮件列表?",
+      "address_count": {
+        "other": "{count} 个地址"
+      },
+      "help": "↑/↓: 导航 • enter: 选择 • e: 编辑 • d: 删除"
+    },
+    "settings_general": {
+      "title": "常规设置",
+      "disable_images": "禁用图片显示",
+      "hide_tips": "隐藏上下文提示",
+      "disable_notifications": "禁用通知",
+      "date_format": "日期格式",
+      "language": "语言",
+      "signature": "编辑签名",
+      "signature_configured": "已配置",
+      "signature_not_configured": "未配置",
+      "on": "开",
+      "off": "关",
+      "restart_required": "需要重新启动以应用语言更改"
+    },
+    "settings_encryption": {
+      "title": "应用加密",
+      "enabled": "加密当前已启用。",
+      "disabled": "设置密码以加密所有数据。",
+      "password_label": "密码:",
+      "confirm_label": "确认密码:",
+      "enable_button": "启用加密",
+      "disable_button": "按Enter禁用加密",
+      "disable_confirm": "禁用加密?",
+      "disable_warning": "所有数据将以未加密方式存储。",
+      "encrypting": "正在加密数据...",
+      "error_empty": "密码不能为空",
+      "error_mismatch": "密码不匹配",
+      "help": "tab: 下一项 • enter: 保存"
+    },
+    "password_prompt": {
+      "title": "Matcha已锁定",
+      "enter_password": "输入您的密码",
+      "error_empty": "密码不能为空",
+      "error_incorrect": "密码错误",
+      "help": "enter: 解锁 • ctrl+c: 退出"
+    },
+    "email_view": {
+      "from": "发件人",
+      "to": "收件人",
+      "cc": "抄送",
+      "bcc": "密送",
+      "subject": "主题",
+      "date": "日期",
+      "attachments": "附件",
+      "download": "下载",
+      "save": "保存",
+      "reply": "回复",
+      "reply_all": "全部回复",
+      "forward": "转发",
+      "delete": "删除",
+      "archive": "存档",
+      "help": "r: 回复 • f: 转发 • d: 删除 • a: 存档 • esc: 返回"
+    },
+    "calendar": {
+      "title": "日历",
+      "meeting": "会议",
+      "event": "事件",
+      "accept": "接受",
+      "decline": "拒绝",
+      "tentative": "暂定",
+      "rsvp_sent": "已发送RSVP: {response}"
+    },
+    "marketplace": {
+      "title": "插件市场",
+      "installing": "正在安装...",
+      "installed": "已安装",
+      "install": "安装",
+      "error": "安装失败",
+      "help": "j/k: 导航 • enter: 安装 • esc: 返回"
+    },
+    "time": {
+      "just_now": "刚刚",
+      "minute_ago": {
+        "other": "{count} 分钟前"
+      },
+      "hour_ago": {
+        "other": "{count} 小时前"
+      },
+      "day_ago": {
+        "other": "{count} 天前"
+      },
+      "week_ago": {
+        "other": "{count} 周前"
+      },
+      "month_ago": {
+        "other": "{count} 个月前"
+      },
+      "year_ago": {
+        "other": "{count} 年前"
+      },
+      "in_moment": "即将",
+      "in_minute": {
+        "other": "{count} 分钟后"
+      },
+      "in_hour": {
+        "other": "{count} 小时后"
+      },
+      "in_day": {
+        "other": "{count} 天后"
+      }
+    }
+  }
+}

i18n/localizer.go 🔗

@@ -0,0 +1,104 @@
+package i18n
+
+// Localizer handles translation lookups for a specific language.
+type Localizer struct {
+	lang     string
+	bundle   *Bundle
+	locale   *Locale
+	cache    *Cache
+	fallback *FallbackChain
+}
+
+// NewLocalizer creates a new Localizer for a language.
+func NewLocalizer(lang string, bundle *Bundle) *Localizer {
+	locale, _ := bundle.GetLocale(lang)
+	if locale == nil {
+		// Fallback to parsing locale
+		locale, _ = ParseLocale(lang)
+	}
+
+	return &Localizer{
+		lang:     lang,
+		bundle:   bundle,
+		locale:   locale,
+		cache:    NewCache(),
+		fallback: NewFallbackChain(lang, bundle.DefaultLanguage()),
+	}
+}
+
+// Localize translates a message ID to text.
+func (l *Localizer) Localize(messageID string) string {
+	// Check cache first
+	if cached, ok := l.cache.Get(messageID); ok {
+		return cached
+	}
+
+	// Try fallback chain
+	msg, _, err := l.fallback.Resolve(l.bundle, messageID)
+	if err != nil {
+		// Return the key itself if translation not found
+		return messageID
+	}
+
+	text := msg.GetDefault()
+	l.cache.Set(messageID, text)
+	return text
+}
+
+// LocalizePlural translates a message with plural support.
+func (l *Localizer) LocalizePlural(messageID string, count int, data map[string]interface{}) string {
+	// Try fallback chain
+	msg, _, err := l.fallback.Resolve(l.bundle, messageID)
+	if err != nil {
+		return messageID
+	}
+
+	// Get plural function
+	pluralFunc := l.locale.PluralFunc
+	if pluralFunc == nil {
+		pluralFunc = DefaultPlural
+	}
+
+	// Get appropriate plural form
+	text := msg.Pluralize(count, pluralFunc)
+
+	// Interpolate variables
+	if data != nil {
+		text = Interpolate(text, data)
+	}
+
+	return text
+}
+
+// LocalizeTemplate translates a message and applies template data.
+func (l *Localizer) LocalizeTemplate(messageID string, data map[string]interface{}) string {
+	// Try fallback chain
+	msg, _, err := l.fallback.Resolve(l.bundle, messageID)
+	if err != nil {
+		return messageID
+	}
+
+	text := msg.GetDefault()
+
+	// Interpolate variables
+	if data != nil {
+		text = Interpolate(text, data)
+	}
+
+	return text
+}
+
+// Language returns the localizer's language code.
+func (l *Localizer) Language() string {
+	return l.lang
+}
+
+// Locale returns the localizer's locale.
+func (l *Localizer) Locale() *Locale {
+	return l.locale
+}
+
+// ClearCache clears the localizer's cache.
+func (l *Localizer) ClearCache() {
+	l.cache.Clear()
+}

i18n/manager.go 🔗

@@ -0,0 +1,173 @@
+package i18n
+
+import (
+	"fmt"
+	"sync"
+)
+
+var (
+	globalManager *Manager
+	managerOnce   sync.Once
+)
+
+// Manager is the global translation manager.
+type Manager struct {
+	bundle      *Bundle
+	currentLang string
+	localizers  map[string]*Localizer
+	cache       *Cache
+	mu          sync.RWMutex
+}
+
+// Init initializes the global translation manager with a default language.
+func Init(defaultLang string) error {
+	var initErr error
+
+	managerOnce.Do(func() {
+		bundle := NewBundle(defaultLang)
+
+		// Load all embedded translations
+		if err := LoadTranslations(bundle); err != nil {
+			initErr = err
+			return
+		}
+
+		// Register locales from registry into bundle
+		for _, locale := range AvailableLanguages() {
+			bundle.RegisterLocale(locale)
+		}
+
+		globalManager = &Manager{
+			bundle:      bundle,
+			currentLang: defaultLang,
+			localizers:  make(map[string]*Localizer),
+			cache:       NewCache(),
+		}
+
+		// Create default localizer
+		globalManager.localizers[defaultLang] = NewLocalizer(defaultLang, bundle)
+	})
+
+	return initErr
+}
+
+// GetManager returns the global manager instance.
+func GetManager() *Manager {
+	if globalManager == nil {
+		// Auto-initialize with English if not yet initialized
+		_ = Init("en")
+	}
+	return globalManager
+}
+
+// SetLanguage changes the current language.
+func (m *Manager) SetLanguage(lang string) error {
+	if lang == "" {
+		return ErrInvalidLocale
+	}
+
+	lang = normalizeLanguageCode(lang)
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	// Check if language is available
+	if !m.bundle.HasLanguage(lang) {
+		return fmt.Errorf("%w: %s", ErrLanguageNotFound, lang)
+	}
+
+	// Create localizer if not exists
+	if _, ok := m.localizers[lang]; !ok {
+		m.localizers[lang] = NewLocalizer(lang, m.bundle)
+	}
+
+	m.currentLang = lang
+	m.cache.Clear() // Clear cache when switching languages
+
+	return nil
+}
+
+// GetLanguage returns the current language code.
+func (m *Manager) GetLanguage() string {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	return m.currentLang
+}
+
+// T translates a message key using the current language.
+func (m *Manager) T(key string) string {
+	m.mu.RLock()
+	localizer := m.localizers[m.currentLang]
+	m.mu.RUnlock()
+
+	if localizer == nil {
+		return key
+	}
+
+	return localizer.Localize(key)
+}
+
+// Tn translates a message with plural support.
+func (m *Manager) Tn(key string, count int, data map[string]interface{}) string {
+	m.mu.RLock()
+	localizer := m.localizers[m.currentLang]
+	m.mu.RUnlock()
+
+	if localizer == nil {
+		return key
+	}
+
+	// Ensure count is in data
+	if data == nil {
+		data = make(map[string]interface{})
+	}
+	if _, ok := data["count"]; !ok {
+		data["count"] = count
+	}
+
+	return localizer.LocalizePlural(key, count, data)
+}
+
+// Tpl translates a message and applies template variables.
+func (m *Manager) Tpl(key string, data map[string]interface{}) string {
+	m.mu.RLock()
+	localizer := m.localizers[m.currentLang]
+	m.mu.RUnlock()
+
+	if localizer == nil {
+		return key
+	}
+
+	return localizer.LocalizeTemplate(key, data)
+}
+
+// AvailableLanguages returns all loaded languages.
+func (m *Manager) AvailableLanguages() []string {
+	return m.bundle.AvailableLanguages()
+}
+
+// GetLocale returns the current locale.
+func (m *Manager) GetLocale() *Locale {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	if localizer, ok := m.localizers[m.currentLang]; ok {
+		return localizer.Locale()
+	}
+
+	locale, _ := ParseLocale(m.currentLang)
+	return locale
+}
+
+// ClearCache clears all translation caches.
+func (m *Manager) ClearCache() {
+	m.cache.Clear()
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	for _, localizer := range m.localizers {
+		localizer.ClearCache()
+	}
+}

i18n/message.go 🔗

@@ -0,0 +1,88 @@
+package i18n
+
+// Message represents a translatable message with support for plural forms.
+type Message struct {
+	// ID is the unique identifier for this message (e.g., "composer.title")
+	ID string `json:"id"`
+
+	// Description provides context for translators
+	Description string `json:"description,omitempty"`
+
+	// Hash is an optional content hash for tracking changes
+	Hash string `json:"hash,omitempty"`
+
+	// Zero form is used when count is exactly 0 (optional)
+	Zero string `json:"zero,omitempty"`
+
+	// One form is used for singular (count == 1)
+	One string `json:"one,omitempty"`
+
+	// Two form is used for dual (count == 2) in some languages
+	Two string `json:"two,omitempty"`
+
+	// Few form is used for small counts in some languages (e.g., Polish)
+	Few string `json:"few,omitempty"`
+
+	// Many form is used for larger counts in some languages (e.g., Russian)
+	Many string `json:"many,omitempty"`
+
+	// Other is the default form used when no specific plural form matches
+	Other string `json:"other,omitempty"`
+}
+
+// MessageMap maps message IDs to Message structs.
+type MessageMap map[string]*Message
+
+// GetText returns the appropriate text for the given plural form.
+func (m *Message) GetText(form PluralForm) string {
+	switch form {
+	case Zero:
+		if m.Zero != "" {
+			return m.Zero
+		}
+	case One:
+		if m.One != "" {
+			return m.One
+		}
+	case Two:
+		if m.Two != "" {
+			return m.Two
+		}
+	case Few:
+		if m.Few != "" {
+			return m.Few
+		}
+	case Many:
+		if m.Many != "" {
+			return m.Many
+		}
+	}
+	// Fallback to Other or One
+	if m.Other != "" {
+		return m.Other
+	}
+	return m.One
+}
+
+// GetDefault returns the most appropriate default text (tries Other, then One).
+func (m *Message) GetDefault() string {
+	if m.Other != "" {
+		return m.Other
+	}
+	if m.One != "" {
+		return m.One
+	}
+	if m.Zero != "" {
+		return m.Zero
+	}
+	if m.Few != "" {
+		return m.Few
+	}
+	if m.Many != "" {
+		return m.Many
+	}
+	if m.Two != "" {
+		return m.Two
+	}
+	return ""
+}

i18n/parser.go 🔗

@@ -0,0 +1,101 @@
+package i18n
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+// TranslationFile represents the structure of a JSON translation file.
+type TranslationFile struct {
+	Language string                 `json:"language"`
+	Messages map[string]interface{} `json:"messages"`
+}
+
+// ParseJSON parses a JSON translation file and returns a MessageMap.
+func ParseJSON(data []byte) (MessageMap, error) {
+	var file TranslationFile
+	if err := json.Unmarshal(data, &file); err != nil {
+		return nil, fmt.Errorf("%w: %v", ErrParseFailed, err)
+	}
+
+	messages := make(MessageMap)
+	parseNestedMessages("", file.Messages, messages)
+
+	return messages, nil
+}
+
+// parseNestedMessages recursively parses nested message structures.
+// Builds dot-notation keys like "composer.title" from nested objects.
+func parseNestedMessages(prefix string, data map[string]interface{}, messages MessageMap) {
+	for key, value := range data {
+		fullKey := key
+		if prefix != "" {
+			fullKey = prefix + "." + key
+		}
+
+		switch v := value.(type) {
+		case string:
+			// Simple string message
+			messages[fullKey] = &Message{
+				ID:    fullKey,
+				Other: v,
+			}
+
+		case map[string]interface{}:
+			// Check if this is a plural form object or nested structure
+			if isPluralForm(v) {
+				messages[fullKey] = parsePluralMessage(fullKey, v)
+			} else {
+				// Nested structure - recurse
+				parseNestedMessages(fullKey, v, messages)
+			}
+
+		default:
+			// Unexpected type - treat as string
+			messages[fullKey] = &Message{
+				ID:    fullKey,
+				Other: fmt.Sprintf("%v", v),
+			}
+		}
+	}
+}
+
+// isPluralForm checks if a map contains plural form keys.
+func isPluralForm(data map[string]interface{}) bool {
+	pluralKeys := []string{"zero", "one", "two", "few", "many", "other"}
+	for _, key := range pluralKeys {
+		if _, ok := data[key]; ok {
+			return true
+		}
+	}
+	return false
+}
+
+// parsePluralMessage creates a Message from plural form data.
+func parsePluralMessage(id string, data map[string]interface{}) *Message {
+	msg := &Message{ID: id}
+
+	if v, ok := data["zero"].(string); ok {
+		msg.Zero = v
+	}
+	if v, ok := data["one"].(string); ok {
+		msg.One = v
+	}
+	if v, ok := data["two"].(string); ok {
+		msg.Two = v
+	}
+	if v, ok := data["few"].(string); ok {
+		msg.Few = v
+	}
+	if v, ok := data["many"].(string); ok {
+		msg.Many = v
+	}
+	if v, ok := data["other"].(string); ok {
+		msg.Other = v
+	}
+	if v, ok := data["description"].(string); ok {
+		msg.Description = v
+	}
+
+	return msg
+}

i18n/plural_rules.go 🔗

@@ -0,0 +1,172 @@
+package i18n
+
+// This file contains plural rule implementations for different languages.
+// Plural rules are based on Unicode CLDR plural rules.
+// Reference: https://cldr.unicode.org/index/cldr-spec/plural-rules
+
+// EnglishPlural implements plural rules for English.
+// Rule: one (n == 1), other (everything else)
+func EnglishPlural(n int) PluralForm {
+	if n == 1 {
+		return One
+	}
+	return Other
+}
+
+// SpanishPlural implements plural rules for Spanish.
+// Rule: one (n == 1), other (everything else)
+func SpanishPlural(n int) PluralForm {
+	if n == 1 {
+		return One
+	}
+	return Other
+}
+
+// GermanPlural implements plural rules for German.
+// Rule: one (n == 1), other (everything else)
+func GermanPlural(n int) PluralForm {
+	if n == 1 {
+		return One
+	}
+	return Other
+}
+
+// FrenchPlural implements plural rules for French.
+// Rule: one (n == 0 or n == 1), other (everything else)
+func FrenchPlural(n int) PluralForm {
+	if n == 0 || n == 1 {
+		return One
+	}
+	return Other
+}
+
+// PortuguesePlural implements plural rules for Portuguese.
+// Rule: one (n == 0 or n == 1), other (everything else)
+func PortuguesePlural(n int) PluralForm {
+	if n == 0 || n == 1 {
+		return One
+	}
+	return Other
+}
+
+// RussianPlural implements plural rules for Russian.
+// Rule: one (n mod 10 == 1 and n mod 100 != 11)
+//
+//	few (n mod 10 in 2..4 and n mod 100 not in 12..14)
+//	many (everything else)
+func RussianPlural(n int) PluralForm {
+	mod10 := n % 10
+	mod100 := n % 100
+
+	if mod10 == 1 && mod100 != 11 {
+		return One
+	}
+	if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
+		return Few
+	}
+	return Many
+}
+
+// ArabicPlural implements plural rules for Arabic.
+// Rule: zero (n == 0)
+//
+//	one (n == 1)
+//	two (n == 2)
+//	few (n mod 100 in 3..10)
+//	many (n mod 100 in 11..99)
+//	other (everything else)
+func ArabicPlural(n int) PluralForm {
+	if n == 0 {
+		return Zero
+	}
+	if n == 1 {
+		return One
+	}
+	if n == 2 {
+		return Two
+	}
+
+	mod100 := n % 100
+	if mod100 >= 3 && mod100 <= 10 {
+		return Few
+	}
+	if mod100 >= 11 && mod100 <= 99 {
+		return Many
+	}
+	return Other
+}
+
+// JapanesePlural implements plural rules for Japanese.
+// Rule: other (always - no plural distinction)
+func JapanesePlural(n int) PluralForm {
+	return Other
+}
+
+// ChinesePlural implements plural rules for Chinese.
+// Rule: other (always - no plural distinction)
+func ChinesePlural(n int) PluralForm {
+	return Other
+}
+
+// PolishPlural implements plural rules for Polish.
+// Rule: one (n == 1)
+//
+//	few (n mod 10 in 2..4 and n mod 100 not in 12..14)
+//	many (everything else)
+func PolishPlural(n int) PluralForm {
+	if n == 1 {
+		return One
+	}
+
+	mod10 := n % 10
+	mod100 := n % 100
+
+	if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
+		return Few
+	}
+	return Many
+}
+
+// CzechPlural implements plural rules for Czech.
+// Rule: one (n == 1)
+//
+//	few (n in 2..4)
+//	many (everything else)
+func CzechPlural(n int) PluralForm {
+	if n == 1 {
+		return One
+	}
+	if n >= 2 && n <= 4 {
+		return Few
+	}
+	return Many
+}
+
+// ItalianPlural implements plural rules for Italian.
+// Rule: one (n == 1), other (everything else)
+func ItalianPlural(n int) PluralForm {
+	if n == 1 {
+		return One
+	}
+	return Other
+}
+
+// UkrainianPlural implements plural rules for Ukrainian.
+// Rule: one (n mod 10 == 1 and n mod 100 != 11)
+//
+//	few (n mod 10 in 2..4 and n mod 100 not in 12..14)
+//	many (everything else)
+//
+// Same as Russian
+func UkrainianPlural(n int) PluralForm {
+	mod10 := n % 10
+	mod100 := n % 100
+
+	if mod10 == 1 && mod100 != 11 {
+		return One
+	}
+	if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
+		return Few
+	}
+	return Many
+}

i18n/pluralizer.go 🔗

@@ -0,0 +1,59 @@
+package i18n
+
+// PluralForm represents the different plural categories.
+type PluralForm int
+
+const (
+	// Zero is used when count is exactly 0
+	Zero PluralForm = iota
+	// One is used for singular (typically count == 1)
+	One
+	// Two is used for dual (count == 2) in some languages
+	Two
+	// Few is used for small counts in some languages
+	Few
+	// Many is used for larger counts in some languages
+	Many
+	// Other is the default/fallback form
+	Other
+)
+
+// String returns the string representation of the plural form.
+func (p PluralForm) String() string {
+	switch p {
+	case Zero:
+		return "zero"
+	case One:
+		return "one"
+	case Two:
+		return "two"
+	case Few:
+		return "few"
+	case Many:
+		return "many"
+	case Other:
+		return "other"
+	default:
+		return "other"
+	}
+}
+
+// PluralFunc is a function that returns the appropriate plural form for a count.
+type PluralFunc func(n int) PluralForm
+
+// Pluralize returns the appropriate text from a message based on count and plural rules.
+func (m *Message) Pluralize(count int, pluralFunc PluralFunc) string {
+	if pluralFunc == nil {
+		pluralFunc = DefaultPlural
+	}
+	form := pluralFunc(count)
+	return m.GetText(form)
+}
+
+// DefaultPlural is a simple plural function (English-like: 1 = one, else = other).
+func DefaultPlural(n int) PluralForm {
+	if n == 1 {
+		return One
+	}
+	return Other
+}

i18n/registry.go 🔗

@@ -0,0 +1,68 @@
+package i18n
+
+import "sync"
+
+var registry = &Registry{
+	languages: make(map[string]*Locale),
+}
+
+// Registry holds all registered language locales.
+type Registry struct {
+	languages map[string]*Locale
+	mu        sync.RWMutex
+}
+
+// RegisterLanguage registers a locale in the global registry.
+// This is typically called from init() functions in language files.
+func RegisterLanguage(locale *Locale) {
+	if locale == nil || locale.Code == "" {
+		return
+	}
+
+	registry.mu.Lock()
+	defer registry.mu.Unlock()
+
+	registry.languages[locale.Code] = locale
+}
+
+// GetLanguage retrieves a registered locale by code.
+func GetLanguage(code string) (*Locale, bool) {
+	registry.mu.RLock()
+	defer registry.mu.RUnlock()
+
+	locale, ok := registry.languages[code]
+	return locale, ok
+}
+
+// AvailableLanguages returns all registered locales.
+func AvailableLanguages() []*Locale {
+	registry.mu.RLock()
+	defer registry.mu.RUnlock()
+
+	locales := make([]*Locale, 0, len(registry.languages))
+	for _, locale := range registry.languages {
+		locales = append(locales, locale)
+	}
+	return locales
+}
+
+// LanguageCodes returns all registered language codes.
+func LanguageCodes() []string {
+	registry.mu.RLock()
+	defer registry.mu.RUnlock()
+
+	codes := make([]string, 0, len(registry.languages))
+	for code := range registry.languages {
+		codes = append(codes, code)
+	}
+	return codes
+}
+
+// HasLanguage checks if a language code is registered.
+func HasLanguage(code string) bool {
+	registry.mu.RLock()
+	defer registry.mu.RUnlock()
+
+	_, ok := registry.languages[code]
+	return ok
+}

i18n/template.go 🔗

@@ -0,0 +1,91 @@
+package i18n
+
+import "strings"
+
+// Template represents a parsed template string with placeholders.
+type Template struct {
+	raw   string
+	parts []templatePart
+}
+
+type templatePart struct {
+	isVar bool
+	value string
+}
+
+// NewTemplate parses a template string and returns a Template.
+func NewTemplate(s string) *Template {
+	t := &Template{
+		raw:   s,
+		parts: parseTemplate(s),
+	}
+	return t
+}
+
+// Execute applies data to the template and returns the result.
+func (t *Template) Execute(data map[string]interface{}) string {
+	if len(t.parts) == 0 {
+		return t.raw
+	}
+
+	var result strings.Builder
+	for _, part := range t.parts {
+		if part.isVar {
+			if val, ok := data[part.value]; ok {
+				result.WriteString(formatValue(val))
+			} else {
+				// Keep placeholder if no value provided
+				result.WriteString("{")
+				result.WriteString(part.value)
+				result.WriteString("}")
+			}
+		} else {
+			result.WriteString(part.value)
+		}
+	}
+	return result.String()
+}
+
+// parseTemplate breaks a template string into parts (literal text and variables).
+func parseTemplate(s string) []templatePart {
+	var parts []templatePart
+	var current strings.Builder
+	inVar := false
+	var varName strings.Builder
+
+	for i := 0; i < len(s); i++ {
+		ch := s[i]
+
+		if ch == '{' && !inVar {
+			// Start of variable
+			if current.Len() > 0 {
+				parts = append(parts, templatePart{isVar: false, value: current.String()})
+				current.Reset()
+			}
+			inVar = true
+			varName.Reset()
+		} else if ch == '}' && inVar {
+			// End of variable
+			if varName.Len() > 0 {
+				parts = append(parts, templatePart{isVar: true, value: varName.String()})
+			}
+			inVar = false
+		} else if inVar {
+			varName.WriteByte(ch)
+		} else {
+			current.WriteByte(ch)
+		}
+	}
+
+	// Add remaining text
+	if current.Len() > 0 {
+		parts = append(parts, templatePart{isVar: false, value: current.String()})
+	}
+
+	return parts
+}
+
+// String returns the raw template string.
+func (t *Template) String() string {
+	return t.raw
+}

i18n/validator.go 🔗

@@ -0,0 +1,159 @@
+package i18n
+
+import (
+	"fmt"
+	"sort"
+)
+
+// ValidationResult contains the results of validating translation files.
+type ValidationResult struct {
+	Valid   bool
+	Errors  []ValidationError
+	Missing map[string][]string // lang -> missing keys
+	Extra   map[string][]string // lang -> extra keys
+}
+
+// ValidationError represents a validation issue.
+type ValidationError struct {
+	Language string
+	Key      string
+	Message  string
+}
+
+// ValidateTranslations validates all translations against a base language.
+// Checks for missing keys, extra keys, and consistency.
+func ValidateTranslations(bundle *Bundle, baseLang string) *ValidationResult {
+	result := &ValidationResult{
+		Valid:   true,
+		Errors:  []ValidationError{},
+		Missing: make(map[string][]string),
+		Extra:   make(map[string][]string),
+	}
+
+	// Get base language messages
+	baseMessages, err := getMessages(bundle, baseLang)
+	if err != nil {
+		result.Valid = false
+		result.Errors = append(result.Errors, ValidationError{
+			Language: baseLang,
+			Message:  fmt.Sprintf("Failed to load base language: %v", err),
+		})
+		return result
+	}
+
+	// Get all available languages
+	languages := bundle.AvailableLanguages()
+
+	// Validate each language against base
+	for _, lang := range languages {
+		if lang == baseLang {
+			continue
+		}
+
+		langMessages, err := getMessages(bundle, lang)
+		if err != nil {
+			result.Valid = false
+			result.Errors = append(result.Errors, ValidationError{
+				Language: lang,
+				Message:  fmt.Sprintf("Failed to load language: %v", err),
+			})
+			continue
+		}
+
+		// Find missing and extra keys
+		missing, extra := compareKeys(baseMessages, langMessages)
+
+		if len(missing) > 0 {
+			result.Valid = false
+			result.Missing[lang] = missing
+		}
+
+		if len(extra) > 0 {
+			result.Extra[lang] = extra
+		}
+	}
+
+	return result
+}
+
+// getMessages retrieves all message keys for a language.
+func getMessages(bundle *Bundle, lang string) (MessageMap, error) {
+	bundle.mu.RLock()
+	defer bundle.mu.RUnlock()
+
+	messages, ok := bundle.messages[lang]
+	if !ok {
+		return nil, fmt.Errorf("language not found: %s", lang)
+	}
+
+	return messages, nil
+}
+
+// compareKeys compares two message maps and returns missing and extra keys.
+func compareKeys(base, target MessageMap) (missing, extra []string) {
+	// Find missing keys (in base but not in target)
+	for key := range base {
+		if _, ok := target[key]; !ok {
+			missing = append(missing, key)
+		}
+	}
+
+	// Find extra keys (in target but not in base)
+	for key := range target {
+		if _, ok := base[key]; !ok {
+			extra = append(extra, key)
+		}
+	}
+
+	sort.Strings(missing)
+	sort.Strings(extra)
+
+	return missing, extra
+}
+
+// String returns a human-readable validation report.
+func (v *ValidationResult) String() string {
+	if v.Valid {
+		return "✓ All translations are valid"
+	}
+
+	var report string
+
+	// Report errors
+	if len(v.Errors) > 0 {
+		report += "Errors:\n"
+		for _, err := range v.Errors {
+			if err.Key != "" {
+				report += fmt.Sprintf("  [%s] %s: %s\n", err.Language, err.Key, err.Message)
+			} else {
+				report += fmt.Sprintf("  [%s] %s\n", err.Language, err.Message)
+			}
+		}
+		report += "\n"
+	}
+
+	// Report missing keys
+	if len(v.Missing) > 0 {
+		report += "Missing translations:\n"
+		for lang, keys := range v.Missing {
+			report += fmt.Sprintf("  [%s] %d missing keys:\n", lang, len(keys))
+			for _, key := range keys {
+				report += fmt.Sprintf("    - %s\n", key)
+			}
+		}
+		report += "\n"
+	}
+
+	// Report extra keys
+	if len(v.Extra) > 0 {
+		report += "Extra translations (not in base):\n"
+		for lang, keys := range v.Extra {
+			report += fmt.Sprintf("  [%s] %d extra keys:\n", lang, len(keys))
+			for _, key := range keys {
+				report += fmt.Sprintf("    - %s\n", key)
+			}
+		}
+	}
+
+	return report
+}

main.go 🔗

@@ -35,6 +35,8 @@ import (
 	"github.com/floatpane/matcha/daemonclient"
 	"github.com/floatpane/matcha/daemonrpc"
 	"github.com/floatpane/matcha/fetcher"
+	"github.com/floatpane/matcha/i18n"
+	_ "github.com/floatpane/matcha/i18n/languages"
 	"github.com/floatpane/matcha/notify"
 	"github.com/floatpane/matcha/plugin"
 	"github.com/floatpane/matcha/sender"
@@ -947,9 +949,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		// Password verified — set session key and load config
 		config.SetSessionKey(msg.Key)
 		cfg, err := config.LoadConfig()
-		if err == nil && cfg.Theme != "" {
-			theme.SetTheme(cfg.Theme)
-			tui.RebuildStyles()
+		if err == nil {
+			if cfg.Theme != "" {
+				theme.SetTheme(cfg.Theme)
+				tui.RebuildStyles()
+			}
+			// Set language from config
+			lang := i18n.DetectLanguage(cfg)
+			log.Printf("Detected language: %s", lang)
+			if err := i18n.GetManager().SetLanguage(lang); err != nil {
+				log.Printf("Failed to set language %s: %v", lang, err)
+			} else {
+				log.Printf("Language set to: %s", i18n.GetManager().GetLanguage())
+				log.Printf("Test translation: %s", i18n.GetManager().T("composer.title"))
+			}
 		}
 		_ = config.EnsurePGPDir()
 		if err != nil {
@@ -3433,6 +3446,11 @@ func main() {
 	// Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
 	_ = config.MigrateCacheFiles()
 
+	// Initialize i18n
+	if err := i18n.Init("en"); err != nil {
+		log.Printf("Failed to initialize i18n: %v", err)
+	}
+
 	var initialModel *mainModel
 
 	if config.IsSecureModeEnabled() {
@@ -3442,8 +3460,15 @@ func main() {
 		initialModel.current = tui.NewPasswordPrompt()
 	} else {
 		cfg, err := config.LoadConfig()
-		if err == nil && cfg.Theme != "" {
-			theme.SetTheme(cfg.Theme)
+		if err == nil {
+			if cfg.Theme != "" {
+				theme.SetTheme(cfg.Theme)
+			}
+			// Set language from config
+			lang := i18n.DetectLanguage(cfg)
+			if err := i18n.GetManager().SetLanguage(lang); err != nil {
+				log.Printf("Failed to set language %s: %v", lang, err)
+			}
 		}
 		tui.RebuildStyles()
 

tui/choice.go 🔗

@@ -43,12 +43,15 @@ type Choice struct {
 
 func NewChoice() Choice {
 	hasSavedDrafts := config.HasDrafts()
-	choices := []string{"\ueb1c Inbox", "\ueb1b Compose Email"}
+	choices := []string{
+		"\ueb1c " + t("choice.inbox"),
+		"\ueb1b " + t("choice.compose"),
+	}
 	if hasSavedDrafts {
-		choices = append(choices, "\uec0e Drafts")
+		choices = append(choices, "\uec0e "+t("choice.drafts"))
 	}
-	choices = append(choices, "\uf487 Marketplace")
-	choices = append(choices, "\uf013 Settings")
+	choices = append(choices, "\uf487 "+t("choice.marketplace"))
+	choices = append(choices, "\uf013 "+t("choice.settings"))
 	return Choice{
 		choices:         choices,
 		hasSavedDrafts:  hasSavedDrafts,
@@ -80,17 +83,22 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.cursor++
 			}
 		case "enter":
-			selectedChoice := m.choices[m.cursor]
-			switch selectedChoice {
-			case "\ueb1c Inbox":
+			// Use cursor index instead of string comparison
+			idx := m.cursor
+			if idx == 0 {
+				// Inbox
 				return m, func() tea.Msg { return GoToInboxMsg{} }
-			case "\ueb1b Compose Email":
+			} else if idx == 1 {
+				// Compose
 				return m, func() tea.Msg { return GoToSendMsg{} }
-			case "\uec0e Drafts":
+			} else if m.hasSavedDrafts && idx == 2 {
+				// Drafts
 				return m, func() tea.Msg { return GoToDraftsMsg{} }
-			case "\uf487 Marketplace":
+			} else if (m.hasSavedDrafts && idx == 3) || (!m.hasSavedDrafts && idx == 2) {
+				// Marketplace
 				return m, func() tea.Msg { return GoToMarketplaceMsg{} }
-			case "\uf013 Settings":
+			} else if (m.hasSavedDrafts && idx == 4) || (!m.hasSavedDrafts && idx == 3) {
+				// Settings
 				return m, func() tea.Msg { return GoToSettingsMsg{} }
 			}
 
@@ -126,7 +134,7 @@ func (m Choice) View() tea.View {
 
 	b.WriteString(logoStyle.Render(choiceLogo))
 	b.WriteString("\n")
-	b.WriteString(listHeader.Render("What would you like to do?"))
+	b.WriteString(listHeader.Render(t("choice.what_to_do")))
 	b.WriteString("\n\n")
 
 	// If we detected an update, show a short message under the header.
@@ -134,9 +142,12 @@ func (m Choice) View() tea.View {
 		updateStyle := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Warning).Padding(0, 1)
 		cur := m.CurrentVersion
 		if cur == "" {
-			cur = "unknown"
+			cur = t("choice.unknown")
 		}
-		msg := fmt.Sprintf("Update available: %s (installed: %s) — run `matcha update` to upgrade", m.LatestVersion, cur)
+		msg := tpl("choice.update_available", map[string]interface{}{
+			"latest":  m.LatestVersion,
+			"current": cur,
+		})
 		b.WriteString(updateStyle.Render(msg))
 		b.WriteString("\n\n")
 	}
@@ -151,7 +162,7 @@ func (m Choice) View() tea.View {
 	}
 
 	mainContent := b.String()
-	helpView := helpStyle.Render("Use ↑/↓ to navigate, enter to select, and ctrl+c to quit.")
+	helpView := helpStyle.Render(t("choice.help"))
 
 	if m.height > 0 {
 		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))

tui/composer.go 🔗

@@ -25,8 +25,6 @@ var (
 	blurredStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
 	noStyle             = lipgloss.NewStyle()
 	helpStyle           = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
-	focusedButton       = focusedStyle.Copy().Render("[ Send ]")
-	blurredButton       = blurredStyle.Copy().Render("[ Send ]")
 	emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 	attachmentStyle     = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
 	fromSelectorStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
@@ -104,40 +102,40 @@ func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
 	taStyles := ThemedTextAreaStyles()
 
 	m.toInput = textinput.New()
-	m.toInput.Placeholder = "To"
+	m.toInput.Placeholder = t("composer.to_placeholder")
 	m.toInput.SetValue(to)
 	m.toInput.Prompt = "> "
 	m.toInput.CharLimit = 256
 	m.toInput.SetStyles(tiStyles)
 
 	m.ccInput = textinput.New()
-	m.ccInput.Placeholder = "Cc"
+	m.ccInput.Placeholder = t("composer.cc_placeholder")
 	m.ccInput.Prompt = "> "
 	m.ccInput.CharLimit = 256
 	m.ccInput.SetStyles(tiStyles)
 
 	m.bccInput = textinput.New()
-	m.bccInput.Placeholder = "Bcc"
+	m.bccInput.Placeholder = t("composer.bcc_placeholder")
 	m.bccInput.Prompt = "> "
 	m.bccInput.CharLimit = 256
 	m.bccInput.SetStyles(tiStyles)
 
 	m.subjectInput = textinput.New()
-	m.subjectInput.Placeholder = "Subject"
+	m.subjectInput.Placeholder = t("composer.subject_placeholder")
 	m.subjectInput.SetValue(subject)
 	m.subjectInput.Prompt = "> "
 	m.subjectInput.CharLimit = 256
 	m.subjectInput.SetStyles(tiStyles)
 
 	m.bodyInput = textarea.New()
-	m.bodyInput.Placeholder = "Body (Markdown supported)..."
+	m.bodyInput.Placeholder = t("composer.body_placeholder")
 	m.bodyInput.SetValue(body)
 	m.bodyInput.Prompt = "> "
 	m.bodyInput.SetHeight(10)
 	m.bodyInput.SetStyles(taStyles)
 
 	m.signatureInput = textarea.New()
-	m.signatureInput.Placeholder = "Signature (optional)..."
+	m.signatureInput.Placeholder = t("composer.signature_placeholder")
 	m.signatureInput.Prompt = "> "
 	m.signatureInput.SetHeight(3)
 	m.signatureInput.SetStyles(taStyles)
@@ -499,9 +497,9 @@ func (m *Composer) View() tea.View {
 	var button string
 
 	if m.focusIndex == focusSend {
-		button = focusedButton
+		button = focusedStyle.Copy().Render("[ " + t("composer.send") + " ]")
 	} else {
-		button = blurredButton
+		button = blurredStyle.Copy().Render("[ " + t("composer.send") + " ]")
 	}
 
 	// From field with account selector
@@ -509,23 +507,23 @@ func (m *Composer) View() tea.View {
 	var fromField string
 	if len(m.accounts) > 1 {
 		if m.focusIndex == focusFrom {
-			fromField = focusedStyle.Render(fmt.Sprintf("> From: %s [Enter to switch]", fromAddr))
+			fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch")))
 		} else {
-			fromField = blurredStyle.Render(fmt.Sprintf("  From: %s [switchable]", fromAddr))
+			fromField = blurredStyle.Render(fmt.Sprintf("  %s %s [%s]", t("composer.from"), fromAddr, t("composer.switchable")))
 		}
 	} else if fromAddr != "" {
-		fromField = "  From: " + emailRecipientStyle.Render(fromAddr)
+		fromField = "  " + t("composer.from") + " " + emailRecipientStyle.Render(fromAddr)
 	} else {
-		fromField = blurredStyle.Render("  From: (no account configured)")
+		fromField = blurredStyle.Render(fmt.Sprintf("  %s (%s)", t("composer.from"), t("composer.no_account")))
 	}
 
 	var attachmentField string
 	if len(m.attachmentPaths) == 0 {
-		attachmentText := "None (Enter to add)"
+		attachmentText := fmt.Sprintf("%s (%s)", t("composer.attachments_none"), t("composer.enter_to_add"))
 		if m.focusIndex == focusAttachment {
-			attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachments: %s", attachmentText))
+			attachmentField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.attachments"), attachmentText))
 		} else {
-			attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachments: %s", attachmentText))
+			attachmentField = blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.attachments"), attachmentText))
 		}
 	} else {
 		var names []string
@@ -534,9 +532,9 @@ func (m *Composer) View() tea.View {
 		}
 		attachmentText := strings.Join(names, ", ")
 		if m.focusIndex == focusAttachment {
-			attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachments (%d): %s", len(m.attachmentPaths), attachmentText))
+			attachmentField = focusedStyle.Render(fmt.Sprintf("> %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
 		} else {
-			attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachments (%d): %s", len(m.attachmentPaths), attachmentText))
+			attachmentField = blurredStyle.Render(fmt.Sprintf("  %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
 		}
 	}
 
@@ -544,9 +542,9 @@ func (m *Composer) View() tea.View {
 	if m.encryptSMIME {
 		encToggle = "[x]"
 	}
-	encField := blurredStyle.Render(fmt.Sprintf("  Encrypt Email (S/MIME): %s", encToggle))
+	encField := blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.encrypt_smime"), encToggle))
 	if m.focusIndex == focusEncryptSMIME {
-		encField = focusedStyle.Render(fmt.Sprintf("> Encrypt Email (S/MIME): %s", encToggle))
+		encField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encToggle))
 	}
 
 	// Build To field with suggestions
@@ -570,9 +568,9 @@ func (m *Composer) View() tea.View {
 	// Signature field label
 	var signatureLabel string
 	if m.focusIndex == focusSignature {
-		signatureLabel = focusedStyle.Render("Signature:")
+		signatureLabel = focusedStyle.Render(t("composer.signature") + ":")
 	} else {
-		signatureLabel = blurredStyle.Render("Signature:")
+		signatureLabel = blurredStyle.Render(t("composer.signature") + ":")
 	}
 
 	tip := ""
@@ -600,7 +598,7 @@ func (m *Composer) View() tea.View {
 	}
 
 	composerViewElements := []string{
-		"Compose New Email",
+		t("composer.title"),
 		fromField,
 		toFieldView,
 		m.ccInput.View(),
@@ -620,7 +618,7 @@ func (m *Composer) View() tea.View {
 	}
 
 	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
-	helpText := "Markdown/HTML • tab/shift+tab: navigate • ctrl+e: $EDITOR • esc: save draft & exit"
+	helpText := t("composer.help")
 	for _, pk := range m.pluginKeyBindings {
 		helpText += " • " + pk.Key + ": " + pk.Description
 	}
@@ -684,7 +682,7 @@ func (m *Composer) View() tea.View {
 	if m.confirmingExit {
 		dialog := DialogBoxStyle.Render(
 			lipgloss.JoinVertical(lipgloss.Center,
-				"Are you sure you want to exit? This draft will be saved",
+				t("composer.exit_confirm"),
 				HelpStyle.Render("\n(y/n)"),
 			),
 		)

tui/folder_inbox.go 🔗

@@ -383,7 +383,7 @@ func (m *FolderInbox) renderSidebar() string {
 	var b strings.Builder
 
 	// Account name as title
-	title := "Folders"
+	title := t("folder_inbox.folders_title")
 	if len(m.accounts) > 0 {
 		acc := m.accounts[0]
 		if acc.Name != "" {
@@ -435,9 +435,11 @@ func (m *FolderInbox) renderWithMoveOverlay(content string) string {
 	}
 
 	var b strings.Builder
-	title := "Move to folder:"
+	title := t("folder_inbox.move_to_folder")
 	if len(m.moveUIDs) > 1 {
-		title = fmt.Sprintf("Move %d emails to folder:", len(m.moveUIDs))
+		title = tn("folder_inbox.move_multiple", len(m.moveUIDs), map[string]interface{}{
+			"count": len(m.moveUIDs),
+		})
 	}
 	b.WriteString(moveOverlayTitleStyle.Render(title))
 	b.WriteString("\n")
@@ -455,7 +457,7 @@ func (m *FolderInbox) renderWithMoveOverlay(content string) string {
 	}
 
 	b.WriteString("\n\n")
-	b.WriteString(helpStyle.Render("j/k: navigate  enter: move  esc: cancel"))
+	b.WriteString(helpStyle.Render(t("folder_inbox.help")))
 
 	overlay := moveOverlayStyle.Render(b.String())
 

tui/i18n_helper.go 🔗

@@ -0,0 +1,21 @@
+package tui
+
+import "github.com/floatpane/matcha/i18n"
+
+// t translates a message key to the current language.
+// Example: t("composer.title") -> "Compose New Email"
+func t(key string) string {
+	return i18n.GetManager().T(key)
+}
+
+// tn translates a message with plural support.
+// Example: tn("inbox.emails", 5, nil) -> "5 emails"
+func tn(key string, count int, data map[string]interface{}) string {
+	return i18n.GetManager().Tn(key, count, data)
+}
+
+// tpl translates a message and applies template variables.
+// Example: tpl("welcome.message", map[string]interface{}{"name": "John"}) -> "Welcome, John!"
+func tpl(key string, data map[string]interface{}) string {
+	return i18n.GetManager().Tpl(key, data)
+}

tui/inbox.go 🔗

@@ -165,43 +165,34 @@ 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 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() {
+func formatRelativeDate(timestamp time.Time, layout string) string {
+	if timestamp.IsZero() {
 		return ""
 	}
 	now := time.Now()
-	d := now.Sub(t)
+	d := now.Sub(timestamp)
 
 	switch {
 	case d < time.Minute:
-		return "just now"
+		return t("time.just_now")
 	case d < time.Hour:
 		mins := int(d.Minutes())
-		if mins == 1 {
-			return "1 min ago"
-		}
-		return fmt.Sprintf("%d min ago", mins)
+		return tn("time.minute_ago", mins, map[string]interface{}{"count": mins})
 	case d < 24*time.Hour:
 		hours := int(d.Hours())
-		if hours == 1 {
-			return "1 hour ago"
-		}
-		return fmt.Sprintf("%d hours ago", hours)
+		return tn("time.hour_ago", hours, map[string]interface{}{"count": hours})
 	case d < 7*24*time.Hour:
 		days := int(d.Hours() / 24)
-		if days == 1 {
-			return "1 day ago"
-		}
-		return fmt.Sprintf("%d days ago", days)
+		return tn("time.day_ago", days, map[string]interface{}{"count": days})
 	default:
-		t = t.Local()
+		timestamp = timestamp.Local()
 		if layout != "" {
-			return t.Format(layout)
+			return timestamp.Format(layout)
 		}
-		if t.Year() == now.Year() {
-			return t.Format("Jan 02")
+		if timestamp.Year() == now.Year() {
+			return timestamp.Format("Jan 02")
 		}
-		return t.Format("Jan 02, 2006")
+		return timestamp.Format("Jan 02, 2006")
 	}
 }
 
@@ -413,10 +404,10 @@ func (m *Inbox) updateList() {
 	l.SetStatusBarItemName("email", "emails")
 	l.AdditionalShortHelpKeys = func() []key.Binding {
 		bindings := []key.Binding{
-			key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "visual mode")),
-			key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", "delete")),
-			key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", "archive")),
-			key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", "refresh")),
+			key.NewBinding(key.WithKeys("v"), key.WithHelp("v", t("inbox.visual_mode"))),
+			key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", t("inbox.delete"))),
+			key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", t("inbox.archive"))),
+			key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", t("inbox.refresh"))),
 		}
 		if len(m.tabs) > 1 {
 			bindings = append(bindings,
@@ -459,7 +450,7 @@ func (m *Inbox) updateList() {
 func (m *Inbox) getTitle() string {
 	var title string
 	if m.currentAccountID == "" {
-		title = m.getBaseTitle() + " - All Accounts"
+		title = m.getBaseTitle() + " - " + t("inbox.all_accounts")
 	} else {
 		title = m.getBaseTitle()
 		for _, acc := range m.accounts {

tui/password_prompt.go 🔗

@@ -21,7 +21,7 @@ type PasswordPrompt struct {
 // NewPasswordPrompt creates a new password prompt screen.
 func NewPasswordPrompt() *PasswordPrompt {
 	ti := textinput.New()
-	ti.Placeholder = "Enter your password"
+	ti.Placeholder = t("password_prompt.enter_password")
 	ti.EchoMode = textinput.EchoPassword
 	ti.EchoCharacter = '*'
 	ti.Prompt = "> "
@@ -50,7 +50,7 @@ func (m *PasswordPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case "enter":
 			password := m.input.Value()
 			if password == "" {
-				m.err = "Password cannot be empty"
+				m.err = t("password_prompt.error_empty")
 				return m, nil
 			}
 			m.verifying = true
@@ -90,7 +90,7 @@ func (m *PasswordPrompt) View() tea.View {
 		Foreground(lipgloss.Color("#FFFDF5")).
 		Background(lipgloss.Color("#25A065")).
 		Padding(0, 1).
-		Render("Matcha is locked")
+		Render(t("password_prompt.title"))
 
 	b.WriteString(lockTitle)
 	b.WriteString("\n\n")
@@ -107,7 +107,7 @@ func (m *PasswordPrompt) View() tea.View {
 	}
 
 	mainContent := b.String()
-	helpView := helpStyle.Render("enter: unlock • ctrl+c: quit")
+	helpView := helpStyle.Render(t("password_prompt.help"))
 
 	if m.height > 0 {
 		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))

tui/settings.go 🔗

@@ -253,9 +253,15 @@ func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 func (m *Settings) View() tea.View {
 	// Left pane
 	var left strings.Builder
-	left.WriteString(titleStyle.Render("Settings") + "\n\n")
-
-	categories := []string{"General", "Accounts", "Theme", "Mailing Lists", "App Encryption"}
+	left.WriteString(titleStyle.Render(t("settings.title")) + "\n\n")
+
+	categories := []string{
+		t("settings.category_general"),
+		t("settings.category_accounts"),
+		t("settings.category_theme"),
+		t("settings.category_mailing_lists"),
+		t("settings.category_encryption"),
+	}
 	for i, c := range categories {
 		cursor := "  "
 		if m.menuCursor == i {
@@ -303,9 +309,9 @@ func (m *Settings) View() tea.View {
 
 	content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
 
-	helpText := "esc: back to menu"
+	helpText := t("settings.help_content")
 	if m.activePane == PaneMenu {
-		helpText = "↑/↓: navigate • right/enter: select • esc: go back"
+		helpText = t("settings.help_menu")
 	}
 	helpView := helpStyle.Render(helpText)
 

tui/settings_accounts.go 🔗

@@ -116,10 +116,10 @@ func (m *Settings) viewAccounts() string {
 	}
 
 	var b strings.Builder
-	b.WriteString(titleStyle.Render("Account Settings") + "\n\n")
+	b.WriteString(titleStyle.Render(t("settings_accounts.title")) + "\n\n")
 
 	if len(m.cfg.Accounts) == 0 {
-		b.WriteString(accountEmailStyle.Render("  No accounts configured.\n\n"))
+		b.WriteString(accountEmailStyle.Render("  " + t("settings_accounts.no_accounts") + "\n\n"))
 	}
 
 	for i, account := range m.cfg.Accounts {
@@ -159,9 +159,9 @@ func (m *Settings) viewAccounts() string {
 		cursor = "> "
 		style = selectedAccountItemStyle
 	}
-	b.WriteString(style.Render(cursor+"Add New Account") + "\n\n")
+	b.WriteString(style.Render(cursor+t("settings_accounts.add_account")) + "\n\n")
 
-	b.WriteString(helpStyle.Render("↑/↓: navigate • enter: edit crypto config • e: edit server • d: delete"))
+	b.WriteString(helpStyle.Render(t("settings_accounts.help")))
 
 	if m.confirmingDelete {
 		accountName := m.cfg.Accounts[m.accountsCursor].Email

tui/settings_encryption.go 🔗

@@ -79,11 +79,11 @@ func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 			password := m.encPasswordInput.Value()
 			confirm := m.encConfirmInput.Value()
 			if password == "" {
-				m.encError = "Password cannot be empty"
+				m.encError = t("settings_encryption.error_empty")
 				return m, nil
 			}
 			if password != confirm {
-				m.encError = "Passwords do not match"
+				m.encError = t("settings_encryption.error_mismatch")
 				return m, nil
 			}
 			m.encEnabling = true
@@ -111,41 +111,41 @@ func (m *Settings) viewEncryption() string {
 	var b strings.Builder
 	isEnabled := config.IsSecureModeEnabled()
 
-	b.WriteString(titleStyle.Render("App Encryption") + "\n\n")
+	b.WriteString(titleStyle.Render(t("settings_encryption.title")) + "\n\n")
 
 	if isEnabled {
 		if m.confirmingDisable {
 			dialog := DialogBoxStyle.Render(
 				lipgloss.JoinVertical(lipgloss.Center,
-					dangerStyle.Render("Disable encryption?"),
-					accountEmailStyle.Render("All data will be stored unencrypted."),
+					dangerStyle.Render(t("settings_encryption.disable_confirm")),
+					accountEmailStyle.Render(t("settings_encryption.disable_warning")),
 					HelpStyle.Render("\n(y/n)"),
 				),
 			)
 			b.WriteString(dialog + "\n")
 		} else {
-			b.WriteString(settingsFocusedStyle.Render("  Encryption is currently enabled.") + "\n\n")
-			b.WriteString(accountEmailStyle.Render("  Press enter to disable encryption.") + "\n\n")
+			b.WriteString(settingsFocusedStyle.Render("  "+t("settings_encryption.enabled")) + "\n\n")
+			b.WriteString(accountEmailStyle.Render("  "+t("settings_encryption.disable_button")) + "\n\n")
 			b.WriteString(helpStyle.Render("enter: disable"))
 		}
 	} else {
-		b.WriteString(accountEmailStyle.Render("Set a password to encrypt all data.") + "\n\n")
+		b.WriteString(accountEmailStyle.Render(t("settings_encryption.disabled")) + "\n\n")
 
 		if m.encFocusIndex == 0 {
-			b.WriteString(settingsFocusedStyle.Render("Password:\n"))
+			b.WriteString(settingsFocusedStyle.Render(t("settings_encryption.password_label") + "\n"))
 		} else {
-			b.WriteString(settingsBlurredStyle.Render("Password:\n"))
+			b.WriteString(settingsBlurredStyle.Render(t("settings_encryption.password_label") + "\n"))
 		}
 		b.WriteString(m.encPasswordInput.View() + "\n\n")
 
 		if m.encFocusIndex == 1 {
-			b.WriteString(settingsFocusedStyle.Render("Confirm Password:\n"))
+			b.WriteString(settingsFocusedStyle.Render(t("settings_encryption.confirm_label") + "\n"))
 		} else {
-			b.WriteString(settingsBlurredStyle.Render("Confirm Password:\n"))
+			b.WriteString(settingsBlurredStyle.Render(t("settings_encryption.confirm_label") + "\n"))
 		}
 		b.WriteString(m.encConfirmInput.View() + "\n\n")
 
-		saveBtn := "[ Enable Encryption ]"
+		saveBtn := "[ " + t("settings_encryption.enable_button") + " ]"
 		if m.encFocusIndex == 2 {
 			b.WriteString(settingsFocusedStyle.Render(saveBtn) + "\n")
 		} else {
@@ -153,10 +153,10 @@ func (m *Settings) viewEncryption() string {
 		}
 
 		if m.encEnabling {
-			b.WriteString("\n" + accountEmailStyle.Render("  Encrypting data...") + "\n")
+			b.WriteString("\n" + accountEmailStyle.Render("  "+t("settings_encryption.encrypting")) + "\n")
 		}
 
-		b.WriteString("\n" + helpStyle.Render("tab: next • enter: save"))
+		b.WriteString("\n" + helpStyle.Render(t("settings_encryption.help")))
 	}
 
 	if m.encError != "" {

tui/settings_general.go 🔗

@@ -6,6 +6,7 @@ import (
 
 	tea "charm.land/bubbletea/v2"
 	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/i18n"
 )
 
 func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
@@ -15,7 +16,7 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 			m.generalCursor--
 		}
 	case "down", "j":
-		if m.generalCursor < 4 {
+		if m.generalCursor < 5 {
 			m.generalCursor++
 		}
 	case "enter", "space", "right", "l":
@@ -39,7 +40,21 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 				m.cfg.DateFormat = config.DateFormatEU
 			}
 			_ = config.SaveConfig(m.cfg)
-		case 4: // Edit Signature
+		case 4: // Language
+			// Cycle through available languages
+			langs := i18n.LanguageCodes()
+			currentLang := m.cfg.GetLanguage()
+			currentIdx := -1
+			for i, lang := range langs {
+				if lang == currentLang {
+					currentIdx = i
+					break
+				}
+			}
+			nextIdx := (currentIdx + 1) % len(langs)
+			m.cfg.Language = langs[nextIdx]
+			_ = config.SaveConfig(m.cfg)
+		case 5: // Edit Signature
 			if msg.String() == "enter" || msg.String() == "right" || msg.String() == "l" {
 				return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
 			}
@@ -54,15 +69,16 @@ func (m *Settings) viewGeneral() string {
 	b.WriteString(titleStyle.Render("General Settings") + "\n\n")
 
 	options := []struct {
-		label string
-		value string
-		tip   string
+		labelKey string
+		value    string
+		tip      string
 	}{
-		{"Disable Image Display", onOff(m.cfg.DisableImages), "Prevent images from loading automatically in emails."},
-		{"Hide Contextual Tips", onOff(m.cfg.HideTips), "Hide helpful hints displayed at the bottom of the screen."},
-		{"Disable Notifications", onOff(m.cfg.DisableNotifications), "Turn off desktop notifications for new mail."},
-		{"Date Format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed."},
-		{"Signature", getSignatureStatus(), "Configure the signature appended to your outgoing emails."},
+		{"settings_general.disable_images", onOff(m.cfg.DisableImages), "Prevent images from loading automatically in emails."},
+		{"settings_general.hide_tips", onOff(m.cfg.HideTips), "Hide helpful hints displayed at the bottom of the screen."},
+		{"settings_general.disable_notifications", onOff(m.cfg.DisableNotifications), "Turn off desktop notifications for new mail."},
+		{"settings_general.date_format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed."},
+		{"settings_general.language", getLanguageLabel(m.cfg.GetLanguage()), "Change the interface language. Restart required."},
+		{"settings_general.signature", getSignatureStatus(), "Configure the signature appended to your outgoing emails."},
 	}
 
 	for i, opt := range options {
@@ -73,9 +89,10 @@ func (m *Settings) viewGeneral() string {
 			style = selectedAccountItemStyle
 		}
 
-		text := fmt.Sprintf("%s: %s", opt.label, opt.value)
-		if opt.label == "Signature" {
-			text = fmt.Sprintf("Edit Signature (%s)", opt.value)
+		label := t(opt.labelKey)
+		text := fmt.Sprintf("%s: %s", label, opt.value)
+		if opt.labelKey == "settings_general.signature" {
+			text = fmt.Sprintf("%s (%s)", label, opt.value)
 		}
 
 		b.WriteString(style.Render(cursor+text) + "\n")
@@ -92,9 +109,9 @@ func (m *Settings) viewGeneral() string {
 
 func onOff(b bool) string {
 	if b {
-		return "ON"
+		return t("settings_general.on")
 	}
-	return "OFF"
+	return t("settings_general.off")
 }
 
 func getDateFormatLabel(f string) string {
@@ -113,7 +130,14 @@ func getDateFormatLabel(f string) string {
 
 func getSignatureStatus() string {
 	if config.HasSignature() {
-		return "configured"
+		return t("settings_general.signature_configured")
+	}
+	return t("settings_general.signature_not_configured")
+}
+
+func getLanguageLabel(langCode string) string {
+	if locale, ok := i18n.GetLanguage(langCode); ok {
+		return fmt.Sprintf("%s (%s)", locale.NativeName, locale.Code)
 	}
-	return "not configured"
+	return langCode
 }

tui/settings_lists.go 🔗

@@ -64,17 +64,16 @@ func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
 func (m *Settings) viewMailingLists() string {
 	var b strings.Builder
 
-	b.WriteString(titleStyle.Render("Mailing Lists") + "\n\n")
+	b.WriteString(titleStyle.Render(t("settings_mailing_lists.title")) + "\n\n")
 
 	if len(m.cfg.MailingLists) == 0 {
-		b.WriteString(accountEmailStyle.Render("  No mailing lists configured.\n\n"))
+		b.WriteString(accountEmailStyle.Render("  " + t("settings_mailing_lists.no_lists") + "\n\n"))
 	}
 
 	for i, list := range m.cfg.MailingLists {
-		addrCount := fmt.Sprintf("%d address", len(list.Addresses))
-		if len(list.Addresses) != 1 {
-			addrCount += "es"
-		}
+		addrCount := tn("settings_mailing_lists.address_count", len(list.Addresses), map[string]interface{}{
+			"count": len(list.Addresses),
+		})
 		line := fmt.Sprintf("%s - %s", list.Name, accountEmailStyle.Render(addrCount))
 
 		cursor := "  "
@@ -92,15 +91,15 @@ func (m *Settings) viewMailingLists() string {
 		cursor = "> "
 		style = selectedAccountItemStyle
 	}
-	b.WriteString(style.Render(cursor+"Add New Mailing List") + "\n\n")
+	b.WriteString(style.Render(cursor+t("settings_mailing_lists.add_list")) + "\n\n")
 
-	b.WriteString(helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete"))
+	b.WriteString(helpStyle.Render(t("settings_mailing_lists.help")))
 
 	if m.confirmingDelete {
 		listName := m.cfg.MailingLists[m.listsCursor].Name
 		dialog := DialogBoxStyle.Render(
 			lipgloss.JoinVertical(lipgloss.Center,
-				dangerStyle.Render("Delete mailing list?"),
+				dangerStyle.Render(t("settings_mailing_lists.delete_confirm")),
 				accountEmailStyle.Render(listName),
 				HelpStyle.Render("\n(y/n)"),
 			),

tui/settings_theme.go 🔗

@@ -37,13 +37,13 @@ func (m *Settings) viewTheme() string {
 	themes := theme.AllThemes()
 	var b strings.Builder
 
-	b.WriteString(titleStyle.Render("Theme") + "\n\n")
+	b.WriteString(titleStyle.Render(t("settings_theme.title")) + "\n\n")
 
-	for i, t := range themes {
-		isActive := t.Name == theme.ActiveTheme.Name
-		label := t.Name
+	for i, thm := range themes {
+		isActive := thm.Name == theme.ActiveTheme.Name
+		label := thm.Name
 		if isActive {
-			label += " (active)"
+			label += " (" + t("settings_theme.current") + ")"
 		}
 
 		cursor := "  "
@@ -77,7 +77,7 @@ func (m *Settings) viewTheme() string {
 		b.WriteString(TipStyle.Render("Tip: Custom themes can be added as JSON files in ~/.config/matcha/themes/") + "\n\n")
 	}
 
-	b.WriteString(helpStyle.Render("↑/↓: navigate • enter/space: apply theme"))
+	b.WriteString(helpStyle.Render(t("settings_theme.help")))
 
 	return b.String()
 }

tui/theme.go 🔗

@@ -61,8 +61,6 @@ func RebuildStyles() {
 	blurredStyle = lipgloss.NewStyle().Foreground(t.Secondary)
 	noStyle = lipgloss.NewStyle()
 	helpStyle = lipgloss.NewStyle().Foreground(t.SubtleText)
-	focusedButton = focusedStyle.Render("[ Send ]")
-	blurredButton = blurredStyle.Render("[ Send ]")
 	emailRecipientStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
 	attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(t.Secondary)
 	fromSelectorStyle = lipgloss.NewStyle().Foreground(t.Accent)