diff --git a/app/account.go b/app/account.go
index 76d93e03..cbb528b5 100644
--- a/app/account.go
+++ b/app/account.go
@@ -434,6 +434,7 @@ func (a *App) TestConnection(config account.AccountConfig) ConnectionTestResult
clientConfig.Host = config.IMAPHost
clientConfig.Port = config.IMAPPort
clientConfig.Security = imap.SecurityType(config.IMAPSecurity)
+ clientConfig.AuthMechanism = string(config.IMAPAuthMechanism)
clientConfig.Username = config.Username
clientConfig.Password = config.Password
clientConfig.AuthType = imap.AuthTypePassword
diff --git a/app/actions.go b/app/actions.go
index 901126d0..dbdc012c 100644
--- a/app/actions.go
+++ b/app/actions.go
@@ -541,6 +541,12 @@ func (a *App) moveMessagesToIMAP(messages []*message.Message, sourceFolderID str
return nil
}
+ // Mark the IDLE echo-suppression window at start AND completion so the
+ // EXISTS/EXPUNGE echoes of this op defer the inbox reconcile instead of
+ // racing our own in-flight EXPUNGE (see ownExpungeEchoSuppress).
+ a.noteOwnExpunge(accountID)
+ defer a.noteOwnExpunge(accountID)
+
err = a.withIMAPRetry(accountID, func(conn *imap.Client) error {
// Select source mailbox
log.Debug().Str("mailbox", sourceFolder.Path).Msg("Selecting source mailbox")
@@ -982,6 +988,10 @@ func (a *App) removeFromIMAPFolder(messages []*message.Message, folderID string)
return nil
}
+ // See ownExpungeEchoSuppress — defer the IDLE echo of our own delete.
+ a.noteOwnExpunge(messages[0].AccountID)
+ defer a.noteOwnExpunge(messages[0].AccountID)
+
return a.withIMAPRetry(messages[0].AccountID, func(conn *imap.Client) error {
if _, err := conn.SelectMailbox(a.ctx, folderObj.Path); err != nil {
return fmt.Errorf("failed to select mailbox: %w", err)
@@ -1188,6 +1198,10 @@ func (a *App) deleteMessagesFromIMAP(messages []*message.Message, folderID strin
return nil
}
+ // See ownExpungeEchoSuppress — defer the IDLE echo of our own delete.
+ a.noteOwnExpunge(accountID)
+ defer a.noteOwnExpunge(accountID)
+
return a.withIMAPRetry(accountID, func(conn *imap.Client) error {
if _, err := conn.SelectMailbox(a.ctx, folderObj.Path); err != nil {
return fmt.Errorf("failed to select mailbox: %w", err)
diff --git a/app/app.go b/app/app.go
index 5b09a211..48b7ee88 100644
--- a/app/app.go
+++ b/app/app.go
@@ -320,6 +320,7 @@ type App struct {
// same change doesn't trigger a re-sync (only other clients' changes do).
ownFlagMu goSync.Mutex
ownFlagChangeAt map[string]time.Time // accountID -> last own flag STORE time
+ ownExpungeAt map[string]time.Time // accountID -> last own move/delete IMAP op time
// Draft IMAP sync goroutine tracking — cancel in-flight syncDraftToIMAP
draftSyncContexts map[string]context.CancelFunc // keyed by draft ID
@@ -754,6 +755,7 @@ func (a *App) Startup(ctx context.Context) {
a.syncContexts = make(map[string]context.CancelFunc)
a.syncLastRequest = make(map[string]time.Time)
a.ownFlagChangeAt = make(map[string]time.Time)
+ a.ownExpungeAt = make(map[string]time.Time)
a.draftSyncContexts = make(map[string]context.CancelFunc)
a.draftSyncDone = make(map[string]chan struct{})
diff --git a/app/background.go b/app/background.go
index f696f59c..ff3730f6 100644
--- a/app/background.go
+++ b/app/background.go
@@ -3,6 +3,7 @@ package app
import (
"context"
"fmt"
+ goSync "sync"
"time"
"github.com/hkdb/aerion/internal/folder"
@@ -101,14 +102,19 @@ func (a *App) processIdleEvents(ctx context.Context) {
defer recoverPanic("app.idle", "process IDLE events")
log := logging.WithComponent("app.idle")
+ // timerMu guards the debounce/defer timer maps below. The event loop and
+ // the self-re-arming timer callbacks (own-expunge deferral) both write them.
+ var timerMu goSync.Mutex
// Per-account debounce for flag-change re-syncs. A single read/unread action
// on another client can emit a burst of unilateral FETCHes; coalesce them
- // into one inbox flag re-sync so IDLE stays light. Accessed only from this
- // goroutine, so no lock needed.
+ // into one inbox flag re-sync so IDLE stays light.
flagDebounce := make(map[string]*time.Timer)
// Same idea for EXPUNGE: a bulk delete/move on another client emits a burst
// of EXPUNGEs; coalesce them into one lightweight deletion reconcile.
expungeDebounce := make(map[string]*time.Timer)
+ // Per-account deferral for new-mail syncs suppressed during our own
+ // move/delete bursts (see ownExpungeEchoSuppress).
+ newMailDeferred := make(map[string]*time.Timer)
for {
select {
@@ -124,8 +130,36 @@ func (a *App) processIdleEvents(ctx context.Context) {
switch event.Type {
case imap.EventNewMail:
- // New mail arrived - trigger sync for this account's INBOX
- go a.handleIdleNewMail(event)
+ // New mail arrived - trigger sync for this account's INBOX.
+ // Gmail/Outlook signal deletes via EXISTS, so this path also
+ // fires on the echo of our OWN deletes — and the reconcile it
+ // triggers can snapshot the server while a later delete's
+ // EXPUNGE is still in flight, re-inserting a just-deleted
+ // message that then flickers back into the list. While our own
+ // move/delete is in flight, DEFER (not drop) so real new mail
+ // still lands once the burst quiets.
+ if !a.recentOwnExpunge(event.AccountID) {
+ go a.handleIdleNewMail(event)
+ break
+ }
+ log.Debug().Str("accountID", event.AccountID).Msg("Deferring IDLE new-mail sync - own delete/move in flight")
+ ev := event
+ var fireNewMail func()
+ fireNewMail = func() {
+ if a.recentOwnExpunge(ev.AccountID) {
+ timerMu.Lock()
+ newMailDeferred[ev.AccountID] = time.AfterFunc(idleExpungeDebounce, fireNewMail)
+ timerMu.Unlock()
+ return
+ }
+ a.handleIdleNewMail(ev)
+ }
+ timerMu.Lock()
+ if t := newMailDeferred[ev.AccountID]; t != nil {
+ t.Stop()
+ }
+ newMailDeferred[ev.AccountID] = time.AfterFunc(idleExpungeDebounce, fireNewMail)
+ timerMu.Unlock()
case imap.EventExpunge:
// A message was removed on the server. RFC-strict servers (Dovecot,
@@ -133,14 +167,28 @@ func (a *App) processIdleEvents(ctx context.Context) {
// EXISTS — so, unlike Gmail, nothing trips the new-mail path and the
// deletion would otherwise wait for the next scheduled sync. Debounce
// a burst (bulk delete/move) into one lightweight reconcile whose UID
- // diff removes the rows. INBOX only, matching IDLE's scope.
+ // diff removes the rows. While our OWN move/delete is in flight the
+ // event is our echo — keep re-arming until the burst quiets so the
+ // reconcile can't race an in-flight EXPUNGE and re-insert a
+ // just-deleted message. INBOX only, matching IDLE's scope.
acctID := event.AccountID
+ var fireExpunge func()
+ fireExpunge = func() {
+ if a.recentOwnExpunge(acctID) {
+ log.Debug().Str("accountID", acctID).Msg("Deferring IDLE expunge reconcile - own delete/move in flight")
+ timerMu.Lock()
+ expungeDebounce[acctID] = time.AfterFunc(idleExpungeDebounce, fireExpunge)
+ timerMu.Unlock()
+ return
+ }
+ a.handleIdleExpunge(acctID)
+ }
+ timerMu.Lock()
if t := expungeDebounce[acctID]; t != nil {
t.Stop()
}
- expungeDebounce[acctID] = time.AfterFunc(idleExpungeDebounce, func() {
- a.handleIdleExpunge(acctID)
- })
+ expungeDebounce[acctID] = time.AfterFunc(idleExpungeDebounce, fireExpunge)
+ timerMu.Unlock()
case imap.EventFlagsChanged:
// A flag changed on the server. Debounce, then re-sync the inbox
@@ -154,12 +202,14 @@ func (a *App) processIdleEvents(ctx context.Context) {
log.Debug().Str("accountID", acctID).Msg("Ignoring IDLE flag echo of our own change")
break
}
+ timerMu.Lock()
if t := flagDebounce[acctID]; t != nil {
t.Stop()
}
flagDebounce[acctID] = time.AfterFunc(idleFlagResyncDebounce, func() {
a.handleIdleFlagsChanged(acctID)
})
+ timerMu.Unlock()
}
}
}
@@ -334,6 +384,34 @@ func (a *App) recentOwnFlagChange(accountID string) bool {
return ok && time.Since(t) < ownFlagEchoSuppress
}
+// ownExpungeEchoSuppress is how long after Aerion runs its own move/delete IMAP
+// operation we treat incoming IDLE EXPUNGE/EXISTS notifications as echoes of
+// that operation and DEFER the inbox reconcile they would trigger. During a
+// rapid-delete burst the reconcile's remote UID snapshot can race a still-in-
+// flight EXPUNGE and re-insert a just-deleted message, which then flickers back
+// into the list until the next sync removes it again. Our own deletes are
+// already applied locally, so the reconcile is only needed once the burst
+// quiets. Noted at both start and completion of each op, so the window extends
+// past slow operations.
+const ownExpungeEchoSuppress = 5 * time.Second
+
+// noteOwnExpunge records that Aerion just ran (or is running) a move/delete
+// IMAP operation for an account.
+func (a *App) noteOwnExpunge(accountID string) {
+ a.ownFlagMu.Lock()
+ a.ownExpungeAt[accountID] = time.Now()
+ a.ownFlagMu.Unlock()
+}
+
+// recentOwnExpunge reports whether Aerion ran a move/delete IMAP operation for
+// the account within the suppression window.
+func (a *App) recentOwnExpunge(accountID string) bool {
+ a.ownFlagMu.Lock()
+ defer a.ownFlagMu.Unlock()
+ t, ok := a.ownExpungeAt[accountID]
+ return ok && time.Since(t) < ownExpungeEchoSuppress
+}
+
// handleIdleFlagsChanged reconciles the inbox after a flag change on the server
// (another client marked read/unread/starred) and refreshes the message list +
// sidebar badge. Lighter than handleIdleNewMail — flags only via the fast
diff --git a/app/carddav.go b/app/carddav.go
index 2c3c442e..14077ee8 100644
--- a/app/carddav.go
+++ b/app/carddav.go
@@ -83,13 +83,7 @@ func (a *App) AddContactSource(config carddav.SourceConfig) (*carddav.Source, er
// Create addressbooks based on enabled paths
for _, path := range config.EnabledAddressbooks {
- // Try to get the name from discovery
- name := path
- if parts := strings.Split(strings.Trim(path, "/"), "/"); len(parts) > 0 {
- name = parts[len(parts)-1]
- }
-
- _, err := a.carddavStore.CreateAddressbook(source.ID, path, name, true)
+ _, err := a.carddavStore.CreateAddressbook(source.ID, path, addressbookDisplayName(config.AddressbookNames, path), true)
if err != nil {
log.Warn().Err(err).Str("path", path).Msg("Failed to create addressbook")
}
@@ -163,11 +157,7 @@ func (a *App) UpdateContactSource(id string, config carddav.SourceConfig) error
if _, exists := existingByPath[path]; exists {
continue
}
- name := path
- if parts := strings.Split(strings.Trim(path, "/"), "/"); len(parts) > 0 {
- name = parts[len(parts)-1]
- }
- if _, err := a.carddavStore.CreateAddressbook(id, path, name, true); err != nil {
+ if _, err := a.carddavStore.CreateAddressbook(id, path, addressbookDisplayName(config.AddressbookNames, path), true); err != nil {
log.Warn().Err(err).Str("path", path).Msg("Failed to create new addressbook")
}
}
@@ -644,3 +634,16 @@ func (a *App) CancelContactSourceOAuthFlow() {
wailsRuntime.EventsEmit(a.ctx, "contact-source-oauth:cancelled", nil)
}
+
+// addressbookDisplayName resolves the stored name for an addressbook path:
+// the display name discovery found (passed through SourceConfig, #366),
+// falling back to the path's last segment.
+func addressbookDisplayName(names map[string]string, path string) string {
+ if name := names[path]; name != "" {
+ return name
+ }
+ if parts := strings.Split(strings.Trim(path, "/"), "/"); len(parts) > 0 && parts[len(parts)-1] != "" {
+ return parts[len(parts)-1]
+ }
+ return path
+}
diff --git a/app/compose.go b/app/compose.go
index 9c3cee27..18011dd6 100644
--- a/app/compose.go
+++ b/app/compose.go
@@ -152,6 +152,7 @@ func (ops *composeOps) getIMAPCredentials(ctx context.Context, accountID string)
config.Host = acc.IMAPHost
config.Port = acc.IMAPPort
config.Security = imap.SecurityType(acc.IMAPSecurity)
+ config.AuthMechanism = string(acc.IMAPAuthMechanism)
config.Username = acc.Username
config.TLSConfig = certificate.BuildTLSConfig(acc.IMAPHost, ops.certStore)
@@ -385,6 +386,7 @@ func (ops *composeOps) sendMessage(ctx context.Context, accountID string, msg sm
smtpConfig.Host = acc.SMTPHost
smtpConfig.Port = acc.SMTPPort
smtpConfig.Security = smtp.SecurityType(acc.SMTPSecurity)
+ smtpConfig.AuthMechanism = string(acc.EffectiveSMTPAuthMechanism())
smtpConfig.Username = acc.Username
// Shared mailboxes authenticate SMTP with the parent account's username
if acc.SharedMailboxParentID != "" {
@@ -801,6 +803,14 @@ func (a *App) PrepareReply(messageID, mode string) (*smtp.ComposeMessage, error)
log.Warn().Err(inlineErr).Msg("Failed to get inline attachments for reply/forward")
}
for cid, dataURL := range inlineMap {
+ // Replies include an "inline" attachment only when the quoted HTML
+ // actually embeds it — mailers stamp Content-IDs on ordinary document
+ // attachments, and re-attaching those sends phantom copies with the
+ // cid as filename (#381). Forwards keep every part (the forwarder
+ // wants the documents).
+ if mode != "forward" && !quotedHTMLReferencesCID(quotedHTML, cid) {
+ continue
+ }
ct, b64 := parseDataURL(dataURL)
if b64 == "" {
continue
@@ -832,6 +842,15 @@ func (a *App) PrepareReply(messageID, mode string) (*smtp.ComposeMessage, error)
}, nil
}
+// quotedHTMLReferencesCID reports whether the quoted HTML actually references
+// the given (bracket-stripped) Content-ID. Guards against re-attaching
+// misclassified "inline" documents that the reply body never embeds (#381).
+// Plain substring match errs toward keeping an attachment when the cid
+// appears anywhere in the body, regardless of quote style.
+func quotedHTMLReferencesCID(html, cid string) bool {
+ return strings.Contains(html, "cid:"+cid)
+}
+
// fetchForwardAttachments fetches regular (non-inline) attachment content from IMAP
// and appends them to the attachments slice. Failures are logged but not fatal.
func (a *App) fetchForwardAttachments(log zerolog.Logger, msg *message.Message, attachments *[]smtp.Attachment) {
@@ -889,7 +908,7 @@ func parseDataURL(dataURL string) (contentType, base64Data string) {
}
// TestSMTPConnection tests SMTP connection settings
-func (a *App) TestSMTPConnection(host string, port int, security, username, password string) error {
+func (a *App) TestSMTPConnection(host string, port int, security, username, password, authMechanism string) error {
log := logging.WithComponent("app")
// Map security string to type
@@ -913,6 +932,7 @@ func (a *App) TestSMTPConnection(host string, port int, security, username, pass
config.Username = username
config.Password = password
config.AuthType = smtp.AuthTypePassword
+ config.AuthMechanism = authMechanism
config.TLSConfig = certificate.BuildTLSConfig(host, a.certStore)
client := smtp.NewClient(config)
diff --git a/app/compose_test.go b/app/compose_test.go
new file mode 100644
index 00000000..25478238
--- /dev/null
+++ b/app/compose_test.go
@@ -0,0 +1,54 @@
+package app
+
+import "testing"
+
+func TestQuotedHTMLReferencesCID(t *testing.T) {
+ tests := []struct {
+ name string
+ html string
+ cid string
+ want bool
+ }{
+ {
+ name: "double-quoted src reference",
+ html: `hi
`,
+ cid: "abc123@mailer",
+ want: true,
+ },
+ {
+ name: "single-quoted src reference",
+ html: ` `,
+ cid: "img1@x",
+ want: true,
+ },
+ {
+ name: "cid absent (misclassified document, #381)",
+ html: `just text, no embeds
`,
+ cid: "aljdusoh@mailer",
+ want: false,
+ },
+ {
+ name: "empty html",
+ html: "",
+ cid: "abc@x",
+ want: false,
+ },
+ {
+ // Substring matching deliberately errs toward keeping: a cid that
+ // is a prefix of another referenced cid still counts as present.
+ name: "prefix of another cid counts as referenced (err-safe)",
+ html: ` `,
+ cid: "abc",
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := quotedHTMLReferencesCID(tt.html, tt.cid)
+ if got != tt.want {
+ t.Fatalf("quotedHTMLReferencesCID(%q, %q) = %v, want %v", tt.html, tt.cid, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/app/settings.go b/app/settings.go
index d949cc5c..f6b8108a 100644
--- a/app/settings.go
+++ b/app/settings.go
@@ -84,6 +84,16 @@ func (a *App) SetShowMessageListProfilePics(enabled bool) error {
return a.settingsStore.SetShowMessageListProfilePics(enabled)
}
+// GetAlwaysShowMessageCheckbox returns whether the message list reserves a fixed checkbox column
+func (a *App) GetAlwaysShowMessageCheckbox() (bool, error) {
+ return a.settingsStore.GetAlwaysShowMessageCheckbox()
+}
+
+// SetAlwaysShowMessageCheckbox enables or disables the always-reserved checkbox column
+func (a *App) SetAlwaysShowMessageCheckbox(enabled bool) error {
+ return a.settingsStore.SetAlwaysShowMessageCheckbox(enabled)
+}
+
// GetShowMessageListCircles returns whether colored sender circles are shown in the message list
func (a *App) GetShowMessageListCircles() (bool, error) {
return a.settingsStore.GetShowMessageListCircles()
@@ -445,11 +455,12 @@ func (a *App) SendReadReceipt(accountID, messageID string) error {
// Create SMTP config
smtpConfig := smtp.ClientConfig{
- Host: acc.SMTPHost,
- Port: acc.SMTPPort,
- Username: acc.Username,
- Security: smtp.SecurityType(acc.SMTPSecurity),
- TLSConfig: certificate.BuildTLSConfig(acc.SMTPHost, a.certStore),
+ Host: acc.SMTPHost,
+ Port: acc.SMTPPort,
+ Username: acc.Username,
+ Security: smtp.SecurityType(acc.SMTPSecurity),
+ AuthMechanism: string(acc.EffectiveSMTPAuthMechanism()),
+ TLSConfig: certificate.BuildTLSConfig(acc.SMTPHost, a.certStore),
}
// Handle authentication based on auth type
diff --git a/app/state.go b/app/state.go
index 6e02f08c..1a7288e4 100644
--- a/app/state.go
+++ b/app/state.go
@@ -27,7 +27,7 @@ func (a *App) SaveUIState(state *appstate.UIState) error {
// the About dialog via GetAppInfo() and by the --version CLI flag in main.go.
// (wails.json, frontend/package.json, and metainfo.xml each carry their own
// version strings for their respective tooling.)
-const Version = "0.3.2"
+const Version = "0.3.3"
// AppInfo contains application metadata
type AppInfo struct {
diff --git a/build/flatpak/flathub/go.mod.yml b/build/flatpak/flathub/go.mod.yml
index 2ae98014..012a6576 100644
--- a/build/flatpak/flathub/go.mod.yml
+++ b/build/flatpak/flathub/go.mod.yml
@@ -57,6 +57,11 @@
strip-components: 3
type: archive
url: https://proxy.golang.org/github.com/google/uuid/@v/v1.6.0.zip
+- dest: vendor/github.com/icholy/digest
+ sha256: 5ff9d8b2a7dcae0d50b57060035258b3d85f6a8afff00d6bf9d0e3ab37137a06
+ strip-components: 3
+ type: archive
+ url: https://proxy.golang.org/github.com/icholy/digest/@v/v1.2.0.zip
- dest: vendor/github.com/microcosm-cc/bluemonday
sha256: dbbc62b06600331e29a4d453b8b3e95b1ba48e50baddfd981a3825f8d0043011
strip-components: 3
@@ -73,10 +78,10 @@
type: archive
url: https://proxy.golang.org/github.com/teamwork/tnef/@v/v0.0.0-20200108124832-7deabccfdb32.zip
- dest: vendor/github.com/wailsapp/wails/v2
- sha256: 180adf48c80ff08a23a30964fd69f6fe205d7915e5903237b46236f605c0dfd6
+ sha256: 0fd166526859f16364d47a3c71682298fca91ec7ba8c37a86ae078a8ec004203
strip-components: 4
type: archive
- url: https://proxy.golang.org/github.com/wailsapp/wails/v2/@v/v2.12.0.zip
+ url: https://proxy.golang.org/github.com/wailsapp/wails/v2/@v/v2.13.0.zip
- dest: vendor/github.com/zalando/go-keyring
sha256: 8dea1c2777e7275317e5011f54cddb252c017a7e152558c4fcac84fa5101cf4e
strip-components: 3
@@ -88,25 +93,25 @@
type: archive
url: https://proxy.golang.org/go.mozilla.org/pkcs7/@v/v0.9.0.zip
- dest: vendor/golang.org/x/crypto
- sha256: 5f2921ccdf7252ffbe26046948c6044ab0917e500a34710e0ba6664260d6a460
+ sha256: a28a63b8a6abbbfcbf9fecf8417ce734c50dd988564cadf301c72c2a944f3a6a
strip-components: 3
type: archive
- url: https://proxy.golang.org/golang.org/x/crypto/@v/v0.46.0.zip
+ url: https://proxy.golang.org/golang.org/x/crypto/@v/v0.51.0.zip
- dest: vendor/golang.org/x/image
sha256: dfa6f0db97442ba5f22a385e8f226334f79373cd59d2e3b91f8cdaa1d122d2be
strip-components: 3
type: archive
url: https://proxy.golang.org/golang.org/x/image/@v/v0.41.0.zip
- dest: vendor/golang.org/x/net
- sha256: cf5206797e66bbe72fc13542d53a57d069a563cebb6d045c07a870eb4fd888c9
+ sha256: 1fef7f3ac7fc69f85b7fa47df036c75ad2136f719072a2bb4b93fc79f90bc7c7
strip-components: 3
type: archive
- url: https://proxy.golang.org/golang.org/x/net/@v/v0.48.0.zip
+ url: https://proxy.golang.org/golang.org/x/net/@v/v0.54.0.zip
- dest: vendor/golang.org/x/sys
- sha256: 65444efd299ce78bc457588bc2061e69fb5ceadec70220eeda39657402ad4b1b
+ sha256: f1fa1052808e6bd6eb9c5372c053b2370a582532fac5d6a4600e7a6fab190ff3
strip-components: 3
type: archive
- url: https://proxy.golang.org/golang.org/x/sys/@v/v0.39.0.zip
+ url: https://proxy.golang.org/golang.org/x/sys/@v/v0.44.0.zip
- dest: vendor/golang.org/x/text
sha256: b8d475c17835ab602b91f1147684ceb5dbf4060e72e5dfb2e0e33efc9312a982
strip-components: 3
@@ -138,10 +143,10 @@
type: archive
url: https://proxy.golang.org/github.com/bep/debounce/@v/v1.2.1.zip
- dest: vendor/github.com/cloudflare/circl
- sha256: 07400ed2808f2324faf1aeca9b5f5539de884d6820ad36453bcefbd96bfd7fc4
+ sha256: 9aed6385d52ccd66e0a3b8cc093b5f0e7744640485405dd08c8ef89ef6bbeb06
strip-components: 3
type: archive
- url: https://proxy.golang.org/github.com/cloudflare/circl/@v/v1.6.2.zip
+ url: https://proxy.golang.org/github.com/cloudflare/circl/@v/v1.6.3.zip
- dest: vendor/github.com/danieljoos/wincred
sha256: 5cad35b62beb448347a6c7422b1ed25936aea3edf6c57f198286282a4ce8df6a
strip-components: 3
diff --git a/build/flatpak/flathub/modules.txt b/build/flatpak/flathub/modules.txt
index bdc65032..1e9cf1da 100644
--- a/build/flatpak/flathub/modules.txt
+++ b/build/flatpak/flathub/modules.txt
@@ -46,7 +46,7 @@ github.com/aymerick/douceur/parser
# github.com/bep/debounce v1.2.1
## explicit
github.com/bep/debounce
-# github.com/cloudflare/circl v1.6.2
+# github.com/cloudflare/circl v1.6.3
## explicit; go 1.22.0
github.com/cloudflare/circl/dh/x25519
github.com/cloudflare/circl/dh/x448
@@ -110,6 +110,10 @@ github.com/gorilla/css/scanner
# github.com/gorilla/websocket v1.5.3
## explicit; go 1.12
github.com/gorilla/websocket
+# github.com/icholy/digest v1.2.0
+## explicit; go 1.22
+github.com/icholy/digest
+github.com/icholy/digest/internal/param
# github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e
## explicit; go 1.14
github.com/jchv/go-winloader
@@ -205,8 +209,8 @@ github.com/wailsapp/mimetype
github.com/wailsapp/mimetype/internal/charset
github.com/wailsapp/mimetype/internal/json
github.com/wailsapp/mimetype/internal/magic
-# github.com/wailsapp/wails/v2 v2.12.0
-## explicit; go 1.22.0
+# github.com/wailsapp/wails/v2 v2.13.0
+## explicit; go 1.25.0
github.com/wailsapp/wails/v2
github.com/wailsapp/wails/v2/internal/app
github.com/wailsapp/wails/v2/internal/binding
@@ -256,8 +260,8 @@ github.com/zalando/go-keyring/secret_service
# go.mozilla.org/pkcs7 v0.9.0
## explicit; go 1.11
go.mozilla.org/pkcs7
-# golang.org/x/crypto v0.46.0
-## explicit; go 1.24.0
+# golang.org/x/crypto v0.51.0
+## explicit; go 1.25.0
golang.org/x/crypto/acme
golang.org/x/crypto/acme/autocert
golang.org/x/crypto/argon2
@@ -279,8 +283,8 @@ golang.org/x/image/riff
golang.org/x/image/vp8
golang.org/x/image/vp8l
golang.org/x/image/webp
-# golang.org/x/net v0.48.0
-## explicit; go 1.24.0
+# golang.org/x/net v0.54.0
+## explicit; go 1.25.0
golang.org/x/net/html
golang.org/x/net/html/atom
golang.org/x/net/html/charset
@@ -290,8 +294,9 @@ golang.org/x/net/http2/h2c
golang.org/x/net/http2/hpack
golang.org/x/net/idna
golang.org/x/net/internal/httpcommon
-# golang.org/x/sys v0.39.0
-## explicit; go 1.24.0
+golang.org/x/net/internal/httpsfv
+# golang.org/x/sys v0.44.0
+## explicit; go 1.25.0
golang.org/x/sys/cpu
golang.org/x/sys/unix
golang.org/x/sys/windows
diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml
index ba16b57b..e3cc4a1f 100644
--- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml
+++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml
@@ -10,6 +10,7 @@
Client e-mail open-source léger pour Linux
Leggero client di posta elettronica open-source per Linux
Lettvekt e-postklient for Linux med åpen kildekode
+ Lekki klienta poczty e-mail open-source dla Linuksa
Ứng dụng email nhẹ mã nguồn mở dành cho Linux
一款现代化,轻量级的开源 Linux 电子邮件客户端应用程序
一款現代化,輕量級的開源 Linux 電子郵件客戶端應用程式
@@ -39,6 +40,10 @@
Aerion er en moderne og lettvekt e-postklient inspirert av Geary, fokusert på
effektiv ressursbruk og en ren brukeropplevelse.
+
+ Aerion to nowoczesny, lekki klient poczty e-mail zainspirowany programem Geary, stawiający na
+ wydajne wykorzystanie zasobów oraz przejrzysty interfejs użytkownika.
+
Aerion là ứng dụng email hiện đại và nhẹ, được lấy cảm hứng từ Geary, tập trung vào
hiệu quả sử dụng tài nguyên và trải nghiệm người dùng gọn gàng.
@@ -59,6 +64,7 @@
Fonctionnalités :
Funzionalità:
Funksjoner:
+ Funkcje:
Tính năng:
功能特色:
功能特色:
@@ -71,6 +77,7 @@
Plusieurs comptes e-mail avec une boîte de réception unifiée
Account multipli con un'unica casella di posta elettronica unificata
Bruk flere e-postkontoer med en felles innboks
+ Obsługa wielu kont pocztowych ze zbiorczą skrzynką odbiorczą
Nhiều tài khoản email với hộp thư đến hợp nhất
多账号支持与统一收件箱
多帳戶支援與統一收件箱
@@ -82,6 +89,7 @@
Prise en charge de Gmail, Outlook et IMAP/SMTP générique
Supporta Gmail, Outlook e servizi IMAP/SMTP generici
Støtte for Gmail, Outlook og øvrige tjenester via IMAP/SMTP
+ Wsparcie dla Gmaila, Outlooka oraz standardowych protokołów IMAP/SMTP
Hỗ trợ Gmail, Outlook và IMAP/SMTP thông thường
支持 Gmail、Outlook 及通用 IMAP/SMTP
支援 Gmail、Outlook 及通用 IMAP/SMTP
@@ -93,6 +101,7 @@
Affichage des messages par fil de discussion
Visualizzazione dei messaggi in discussioni
Vise samtaler som tråder
+ Grupowanie wiadomości w wątki konwersacji
Xem email theo chuỗi hội thoại
会话线程视图
對話串檢視
@@ -104,6 +113,7 @@
Éditeur de message WYSIWYG en texte enrichi avec TipTap
Editor WYSIWYG con formato Rich Text e supporto TipTap
Enkel WYSIWYG-skriving med rik tekst med TipTap
+ Edytor tekstu sformatowanego WYSIWYG oparty na edytorze TipTap
Trình soạn thảo trực quan văn bản phong phú với trình chỉnh sửa TipTap
使用 TipTap 编辑器的所见即所得撰写界面
使用 TipTap 編輯器的所見即所得撰寫界面
@@ -115,6 +125,7 @@
Synchronisation des contacts (CardDAV, Google, Microsoft) pour l'auto-complétion
Sincronizzazione dei contatti (CardDAV, Google, Microsoft) tramite compilazione automatica
Synkronisere kontakter via CardDAV, Google eller Microsoft for automatisk fullføring
+ Synchronizacja kontaktów (CardDAV, Google, Microsoft) z automatycznym podpowiadaniem adresów
Đồng bộ danh bạ (CardDAV, Google, Microsoft) để tự động gợi ý danh bạ
联系人同步(CardDAV、Google、Microsoft)与自动补全
聯絡人同步(CardDAV、Google、Microsoft)與自動完成
@@ -126,6 +137,7 @@
Mode sombre et thèmes de couleurs multiples
Modalità scura e molteplici temi colorati
Mørk modus og flere fargelagte temaer
+ Tryb ciemny oraz różnorodne motywy kolorystyczne
Chế độ tối và nhiều giao diện màu sắc
深色模式与多种颜色主题
深色模式與多種色彩主題
@@ -137,6 +149,7 @@
Navigation au clavier avec raccourcis de style Vim
Navigazione rapida tramite tastiera con scorciatoie nello stile Vim
Tastaturvennlig navigasjon med Vim-aktige snarveier
+ Wygodna nawigacja klawiszowa ze skrótami w stylu Vima
Điều hướng thân thiện với bàn phím với phím tắt theo phong cách Vim
支持 Vim 风格快捷键的键盘导航
支援 Vim 風格快捷鍵的鍵盤導航
@@ -148,6 +161,7 @@
Fenêtre de rédaction détachable
Finestra di scrittura dei messaggi separabile
Avtagbart vindu for skriving
+ Możliwość odłączenia okna tworzenia wiadomości
Cửa sổ soạn thảo có thể tách rời
可分离的撰写窗口
可分離的撰寫視窗
@@ -159,6 +173,7 @@
Suppression basique des éléments de suivi des e-mails
Rimozione basica degli elementi di tracciamento dalle email
Fjerning av enkle sporingselementer fra e-poster
+ Podstawowa ochrona przed elementami śledzącymi w wiadomościach
Loại bỏ cơ bản các thành phần theo dõi khỏi email
基本的邮件追踪元素移除
基本的郵件追蹤元素移除
@@ -170,6 +185,7 @@
Prise en charge de PGP et S/MIME
Supporto per PGP e S/MIME
Støtte for PGP og S/MIME
+ Obsługa PGP oraz S/MIME
Hỗ trợ PGP và S/MIME
PGP 与 S/MIME 支持
PGP 與 S/MIME 支援
@@ -181,6 +197,7 @@
Extension Contacts (ALPHA)
Estensione Contatti (ALPHA)
Kontakter-utvidelse (ALPHA)
+ Wtyczka kontaktów (ALPHA)
Tiện ích Danh bạ (ALPHA)
联系人扩展(ALPHA)
聯絡人擴充功能(ALPHA)
@@ -192,6 +209,7 @@
Extension Calendrier (ALPHA)
Estensione Calendario (ALPHA)
Kalender-utvidelse (ALPHA)
+ Wtyczka kalendarza (ALPHA)
Tiện ích Lịch (ALPHA)
日历扩展(ALPHA)
日曆擴充功能(ALPHA)
@@ -203,6 +221,7 @@
Prise en charge d'OAuth personnalisé pour IMAP, SMTP, CardDAV et CalDAV génériques
Supporto per OAuth personalizzato per IMAP, SMTP, CardDAV e CalDAV generici
Støtte for tilpasset OAuth for generisk IMAP, SMTP, CardDAV og CalDAV
+ Obsługa własnych dostawców OAuth dla standardowych IMAP, SMTP, CardDAV i CalDAV
Hỗ trợ OAuth tùy chỉnh cho IMAP, SMTP, CardDAV và CalDAV thông thường
为通用 IMAP、SMTP、CardDAV 和 CalDAV 提供自定义 OAuth 支持
為通用 IMAP、SMTP、CardDAV 和 CalDAV 提供自訂 OAuth 支援
@@ -221,6 +240,7 @@
Fenêtre principale d'Aerion affichant la boîte de réception
Finestra principale di Aerion mostrante la casella di posta
Aerion-vindu som viser innboks
+ Główne okno programu Aerion przedstawiające skrzynkę odbiorczą
Cửa sổ chính của Aerion hiển thị hộp thư đến
Aerion 主窗口显示收件箱
Aerion 主視窗顯示收件箱
@@ -269,6 +289,10 @@
e-post
melding
+ e-mail
+ poczta
+ wiadomości
+
thư điện tử
hộp thư
@@ -285,6 +309,30 @@
+
+
+
+ Message list redesign - #70 - #340
+ Added swipe gestures - right select and left delete - #68
+ Added manual config of IMAP/SMTP auth mech
+ Added digest auth for contacts and calendars - #313
+ Added Polish translation - PR #374
+ Fixed IMAP/SMTP auto auth mech - #365
+ Fixed glitch when rapid deleting messages
+ Refactored to engine-level serialization
+ Improved toast notification logic - #115
+ Added states to fix unwanted message list reset - #348
+ Improved CalDAV discovery - #363
+ Basic CarDAV (Mailfence) support - #366
+ Fixed typo in autostart code - #33
+ Updated nb translations - PR #371
+ Added g, G, alt+g, alt+G, alt+c, alt+m shortcuts - #351
+ Backfilled v to contact list
+ Fixed .desktop - #356 - #367
+ Fixed attachment leak on reply - #381
+
+
+
diff --git a/build/linux/aerion.desktop b/build/linux/aerion.desktop
index 80a7f420..770e0b53 100644
--- a/build/linux/aerion.desktop
+++ b/build/linux/aerion.desktop
@@ -8,6 +8,7 @@ GenericName[de]=E-Mail
GenericName[fr]=Client e-mail
GenericName[it]=Posta elettronica
GenericName[nb]=E-post
+GenericName[pl]=Klient poczty e-mail
GenericName[vi]=Email
GenericName[zh_CN]=电子邮件
GenericName[zh_HK]=電子郵件
@@ -18,6 +19,7 @@ Comment[de]=Ein moderner, plattformübergreifender E-Mail-Client
Comment[fr]=Un client e-mail moderne et léger pour Linux
Comment[it]=Un moderno client multipiattaforma di posta elettronica
Comment[nb]=En moderne e-postklient designet for Linux
+Comment[pl]=Nowoczesny, wieloplatformowy klient poczty e-mail
Comment[vi]=Ứng dụng email hiện đại và đa nền tảng
Comment[zh_CN]=一款现代化,轻量级的开源 Linux 电子邮件客户端应用程序
Comment[zh_HK]=一款現代化,輕量級的開源 Linux 電子郵件客戶端應用程式
@@ -28,6 +30,7 @@ Keywords[de]=Mail;E-mail;email;IMAP;SMTP;CardDAV;
Keywords[fr]=e-mail;mail;courriel;messagerie;IMAP;SMTP;
Keywords[it]=Posta;Posta elettronica;IMAP;SMTP;CardDAV;
Keywords[nb]=Mail;E-mail;email;e-post;epost;IMAP;SMTP;
+Keywords[pl]=poczta;email;e-mail;IMAP;SMTP;CardDAV;
Keywords[vi]=Mail;E-mail;email;IMAP;SMTP;CardDAV;thư;thư điện tử;
Keywords[zh_CN]=邮件;电子邮件;IMAP;SMTP;CardDAV;
Keywords[zh_HK]=郵件;電子郵件;IMAP;SMTP;CardDAV;
@@ -39,7 +42,7 @@ Terminal=false
Categories=Network;Email;
MimeType=x-scheme-handler/mailto;
StartupNotify=true
-StartupWMClass=io.github.hkdb.Aerion
+StartupWMClass=Aerion
X-GNOME-UsesNotifications=true
Actions=compose;
@@ -50,6 +53,7 @@ Name[de]=Neue E-Mail verfassen
Name[fr]=Rédiger un nouveau message
Name[it]=Scrivi un nuovo messaggio
Name[nb]=Skriv ny e-post
+Name[pl]=Napisz wiadomość
Name[vi]=Soạn thư mới
Name[zh_CN]=撰写新邮件
Name[zh_HK]=撰寫新郵件
diff --git a/docs/KEYBOARD_SHORTCUTS.md b/docs/KEYBOARD_SHORTCUTS.md
index c7c5fa03..68d802be 100644
--- a/docs/KEYBOARD_SHORTCUTS.md
+++ b/docs/KEYBOARD_SHORTCUTS.md
@@ -31,6 +31,8 @@ These shortcuts work anywhere in the application (unless in composer).
|----------|--------|
| `Alt+Up` / `Alt+K` | Navigate to previous folder |
| `Alt+Down` / `Alt+J` | Navigate to next folder |
+| `Alt+G` | Jump to top of sidebar (All Inboxes when present) |
+| `Alt+Shift+G` | Jump to last visible folder (last account header if all trees are collapsed) |
| `Alt+Enter` | Expand/collapse focused account folder tree |
| `Alt(L)+Alt(R)` | Brings up context menu for the focused folder |
@@ -61,6 +63,7 @@ These shortcuts depend on which pane is focused. They are disabled when typing i
|----------|--------|
| `Up` / `K` | Navigate to previous folder |
| `Down` / `J` | Navigate to next folder |
+| `Alt+G` / `Alt+Shift+G` | Jump to top of sidebar / last visible folder |
| `Alt + Enter` / `Space` | Expand/collapse account (when account header is focused) |
| `Alt(L) + Alt(R)` | Brings up context menu for the focused folder |
@@ -70,14 +73,20 @@ These shortcuts depend on which pane is focused. They are disabled when typing i
|----------|--------|
| `Up` / `K` | Select previous conversation |
| `Down` / `J` | Select next conversation |
+| `G` (lowercase) | Select + scroll to first conversation |
+| `Shift+G` | Select + scroll to last loaded conversation (does not auto-load more) |
| `Shift+Up` / `Shift+K` | Select previous + toggle checkbox |
| `Shift+Down` / `Shift+J` | Select next + toggle checkbox |
| `Space` | Toggle checkbox on current conversation |
| `Enter` / `V` | Open selected conversation in viewer |
| `D` | Delete selected/checked message(s) — move to Trash (same as `Delete`) |
| `CTRL+A` | Select all messages in folder |
+| `Alt+M` | Toggle "Move to folder" dialog for the focused message |
+| `Alt+C` | Toggle "Copy to folder" dialog for the focused message |
| `Alt(R)` | Brings up context menu for the selected message(s) |
+`G` / `Shift+G` / `V` also work in extension list panes (e.g., Contacts list, Calendar agenda).
+
### Conversation Viewer
| Shortcut | Action |
@@ -138,6 +147,8 @@ These only work when the composer is open.
NAVIGATION
Alt + Arrow Keys Pane focus (Left/Right) or Folder nav (Up/Down)
Alt + H/J/K/L Vim-style: pane (H/L) or folder (J/K)
+ Alt + G Jump to top of sidebar (All Inboxes)
+ Alt + Shift + G Jump to last visible folder
Alt(L) + Alt(R) Brings context menu up for the focused folder
Alt + Enter Expand/collapse account
Ctrl + Tab Switch to next extension on rail
@@ -167,6 +178,9 @@ MESSAGE ACTIONS
Ctrl + K Archive
Ctrl + J Spam
V View message
+ G / Shift + G Jump to first / last loaded message
+ Alt + M Move to folder
+ Alt + C Copy to folder
Delete / D Trash
Shift + Delete Permanent delete
Alt(R) Context Menu
diff --git a/extensions/calendar/backend/api.go b/extensions/calendar/backend/api.go
index 127beac4..638d9dbc 100644
--- a/extensions/calendar/backend/api.go
+++ b/extensions/calendar/backend/api.go
@@ -245,7 +245,7 @@ func (a *API) davHTTPClient(accountID, username, password string, timeout time.D
}
return davutil.NewWebDAVClient(hc.Transport, timeout), nil
}
- return webdav.HTTPClientWithBasicAuth(newCalDAVHTTPClient(timeout), username, password), nil
+ return davutil.NewBasicDigestHTTPClient(username, password, timeout), nil
}
// SetOrganizerIdentity replaces the stored organizer identity list for
diff --git a/extensions/calendar/backend/caldav.go b/extensions/calendar/backend/caldav.go
index fdc5ba2c..1d7509fd 100644
--- a/extensions/calendar/backend/caldav.go
+++ b/extensions/calendar/backend/caldav.go
@@ -50,10 +50,7 @@ type DiscoveredCalendar struct {
// hit the affected XML elements — 1C will factor / inline the fix when
// ETag-based sync needs it.
func DiscoverCalendars(ctx context.Context, baseURL, username, password string) (string, []DiscoveredCalendar, error) {
- httpClient := webdav.HTTPClientWithBasicAuth(
- newCalDAVHTTPClient(30*time.Second),
- username, password,
- )
+ httpClient := davutil.NewBasicDigestHTTPClient(username, password, 30*time.Second)
return discoverCalendars(ctx, baseURL, username, httpClient)
}
@@ -78,6 +75,10 @@ func discoverCalendars(ctx context.Context, baseURL, username string, httpClient
}
var lastErr error
+ // The direct-URL attempt's error is the most user-relevant one — later
+ // fallback rungs can fail for unrelated reasons (e.g. an SSO proxy
+ // answering common paths with a login page, #363) and would mask it.
+ var directErr error
// Attempt 1: URL as-is.
homePath, calendars, err := tryDiscoverCalDAVFromURL(ctx, httpClient, parsedURL.String())
@@ -86,6 +87,7 @@ func discoverCalendars(ctx context.Context, baseURL, username string, httpClient
}
if err != nil {
lastErr = err
+ directErr = err
}
// Attempt 2: .well-known/caldav.
@@ -118,6 +120,9 @@ func discoverCalendars(ctx context.Context, baseURL, username string, httpClient
}
}
+ if directErr != nil {
+ return "", nil, fmt.Errorf("no calendars found at %s: %w", baseURL, directErr)
+ }
if lastErr != nil {
return "", nil, fmt.Errorf("no calendars found at %s: %w", baseURL, lastErr)
}
@@ -136,14 +141,24 @@ func tryDiscoverCalDAVFromURL(ctx context.Context, httpClient webdav.HTTPClient,
principal, err := client.FindCurrentUserPrincipal(ctx)
if err != nil {
- // Principal lookup unsupported / unauthorized — try the URL itself as
- // the calendar home set. Some servers (or pre-supplied home-set URLs)
- // work that way.
- cals, lerr := tryListCalendarsAt(ctx, httpClient, urlStr)
- if lerr != nil {
- return "", nil, fmt.Errorf("find principal: %w", err)
+ // go-webdav's probe path.Join-strips a trailing slash from the request
+ // path, 404ing servers that serve the collection only at "/x/"
+ // (SabreDAV/Davis, #363). Retry with a raw PROPFIND that keeps the URL
+ // exactly as given (and once with "/" appended).
+ rawPrincipal, rawErr := davutil.FindCurrentUserPrincipalRaw(ctx, httpClient, urlStr)
+ if rawErr == nil {
+ principal = rawPrincipal
+ }
+ if rawErr != nil {
+ // Principal lookup unsupported / unauthorized — try the URL itself
+ // as the calendar home set. Some servers (or pre-supplied home-set
+ // URLs) work that way.
+ cals, lerr := tryListCalendarsAt(ctx, httpClient, urlStr)
+ if lerr != nil {
+ return "", nil, fmt.Errorf("find principal: %w", err)
+ }
+ return urlStr, cals, nil
}
- return urlStr, cals, nil
}
homeSet, err := client.FindCalendarHomeSet(ctx, principal)
@@ -411,19 +426,3 @@ func resolveCalDAVURL(baseURL, href string) string {
return base.ResolveReference(ref).String()
}
-// newCalDAVHTTPClient returns the plain *http.Client used for discovery.
-// Mirrors `internal/carddav/client.go::newHTTPClient` MINUS the
-// xmlFixTransport. The XML-fix is needed for the ETag / lastmodified parsing
-// quirks in some servers; discovery PROPFIND doesn't touch those fields.
-// If 1C's sync layer hits the same compat issues, the transport gets factored
-// at that point (likely as a shared internal/davutil package the host
-// exposes, since calendar can't import internal/carddav).
-func newCalDAVHTTPClient(timeout time.Duration) *http.Client {
- // Route through davutil so the client uses the host-installed cert-aware
- // (TOFU) base transport — same trusted-cert store as IMAP/SMTP — instead of
- // the implicit http.DefaultTransport.
- return &http.Client{
- Timeout: timeout,
- Transport: davutil.NewXMLFixTransport(nil),
- }
-}
diff --git a/extensions/calendar/backend/provider_caldav.go b/extensions/calendar/backend/provider_caldav.go
index ea6a8c42..34c23372 100644
--- a/extensions/calendar/backend/provider_caldav.go
+++ b/extensions/calendar/backend/provider_caldav.go
@@ -71,7 +71,7 @@ func (p caldavProvider) davClient(src Source, timeout time.Duration) (webdav.HTT
if password == "" {
return nil, fmt.Errorf("no password stored for source — re-add it in settings")
}
- return webdav.HTTPClientWithBasicAuth(davutil.NewHTTPClient(timeout), src.Username, password), nil
+ return davutil.NewBasicDigestHTTPClient(src.Username, password, timeout), nil
}
// --- Sync (lifted from Syncer.syncCalendar) --------------------------------
diff --git a/extensions/calendar/backend/provider_caldav_compose.go b/extensions/calendar/backend/provider_caldav_compose.go
index d0442640..29e6b955 100644
--- a/extensions/calendar/backend/provider_caldav_compose.go
+++ b/extensions/calendar/backend/provider_caldav_compose.go
@@ -410,10 +410,7 @@ func (p caldavProvider) PushInstance(ctx context.Context, src Source, cal Calend
// xmlfix-wrapped client — PUT responses may carry unquoted ETags on
// some servers; without the fix the new ETag fails to parse and the
// next conditional update breaks. Same builder as sync.
- httpClient := webdav.HTTPClientWithBasicAuth(
- davutil.NewHTTPClient(30*time.Second),
- src.Username, password,
- )
+ httpClient := davutil.NewBasicDigestHTTPClient(src.Username, password, 30*time.Second)
// PUT the new master blob to master.Href.
masterHref, err := absoluteHref(src.URL, payload.Master.Href)
diff --git a/extensions/calendar/frontend/components/AddCalDAVSourceDialog.svelte b/extensions/calendar/frontend/components/AddCalDAVSourceDialog.svelte
index d6a1b853..502e5c53 100644
--- a/extensions/calendar/frontend/components/AddCalDAVSourceDialog.svelte
+++ b/extensions/calendar/frontend/components/AddCalDAVSourceDialog.svelte
@@ -42,6 +42,21 @@
let needsOrganizerEmail = $state(false)
let submitting = $state(false)
let lastError = $state('')
+ // 'connection' = a real server/connection failure — rendered with the
+ // "Couldn't connect" title and check-your-credentials footer. 'prompt' =
+ // guidance (organizer email needed/invalid, missing fields) where the
+ // connection did NOT fail — same alert box, but message only (#363).
+ let lastErrorKind = $state<'connection' | 'prompt'>('connection')
+
+ function setPromptError(msg: string) {
+ lastError = msg
+ lastErrorKind = 'prompt'
+ }
+
+ function setConnectionError(msg: string) {
+ lastError = msg
+ lastErrorKind = 'connection'
+ }
// Authentication method. CalDAV can authenticate with username/password
// (Basic) or by reusing a custom-OAuth mail account's Bearer token — the
@@ -142,26 +157,26 @@
function validate(): boolean {
lastError = ''
if (nameInput.trim() === '') {
- lastError = $_('calendar.add.fieldRequired', { values: { field: $_('calendar.add.nameLabel') } })
+ setPromptError($_('calendar.add.fieldRequired', { values: { field: $_('calendar.add.nameLabel') } }))
return false
}
if (urlInput.trim() === '') {
- lastError = $_('calendar.add.fieldRequired', { values: { field: $_('calendar.add.urlLabel') } })
+ setPromptError($_('calendar.add.fieldRequired', { values: { field: $_('calendar.add.urlLabel') } }))
return false
}
if (isOAuth) {
if (selectedAccountId === '') {
- lastError = $_('calendar.add.oauthAccountRequired')
+ setPromptError($_('calendar.add.oauthAccountRequired'))
return false
}
return true
}
if (usernameInput.trim() === '') {
- lastError = $_('calendar.add.fieldRequired', { values: { field: $_('calendar.add.usernameLabel') } })
+ setPromptError($_('calendar.add.fieldRequired', { values: { field: $_('calendar.add.usernameLabel') } }))
return false
}
if (passwordInput === '') {
- lastError = $_('calendar.add.fieldRequired', { values: { field: $_('calendar.add.passwordLabel') } })
+ setPromptError($_('calendar.add.fieldRequired', { values: { field: $_('calendar.add.passwordLabel') } }))
return false
}
return true
@@ -170,7 +185,7 @@
async function submit() {
if (!validate()) return
if (needsOrganizerEmail && !emailLooksValid) {
- lastError = $_('calendar.add.organizerEmailInvalid')
+ setPromptError($_('calendar.add.organizerEmailInvalid'))
return
}
submitting = true
@@ -201,10 +216,10 @@
// bouncing the user out with a generic error.
if (isOrganizerEmailRequired(msg)) {
needsOrganizerEmail = true
- lastError = $_('calendar.add.organizerEmailPrompt')
+ setPromptError($_('calendar.add.organizerEmailPrompt'))
return
}
- lastError = msg
+ setConnectionError(msg)
} finally {
submitting = false
}
@@ -361,9 +376,16 @@
-
{$_('calendar.add.errorTitle')}
+
+ {#if lastErrorKind === 'connection'}
+
{$_('calendar.add.errorTitle')}
+ {/if}
{lastError}
-
{$_('calendar.add.errorHelp')}
+ {#if lastErrorKind === 'connection'}
+
{$_('calendar.add.errorHelp')}
+ {/if}
{/if}
diff --git a/extensions/calendar/frontend/i18n/locales/nb.json b/extensions/calendar/frontend/i18n/locales/nb.json
index 59e58e82..a763287f 100644
--- a/extensions/calendar/frontend/i18n/locales/nb.json
+++ b/extensions/calendar/frontend/i18n/locales/nb.json
@@ -9,12 +9,12 @@
"close": "Lukk"
},
"write": {
- "queued": "Lagret — synkroniseres når du er tilkoblet igjen.",
+ "queued": "Lagret – synkroniseres når du er tilkoblet igjen.",
"conflict": "Denne hendelsen ble endret på tjeneren. Åpne den og bruk endringen din på nytt."
},
"drag": {
"errorSave": "Klarte ikke å lagre hendelsen: {message}",
- "recurringHint": "Dra er ikke tilgjengelig for gjentakende hendelser. Bruk detaljvisningen for å redigere."
+ "recurringHint": "Dra og slipp er ikke tilgjengelig for gjentakende hendelser. Bruk detaljvisningen for å redigere."
},
"hooks": {
"loading": "Laster kalenderne dine…",
@@ -31,7 +31,7 @@
"skip": "Hopp over",
"setup": "Sett opp",
"done": "Ferdig",
- "completeMessage": "Kalenderkilde lagt til — synkroniserer nå.",
+ "completeMessage": "Kalenderkilde lagt til – synkroniserer nå.",
"successToast": "Kalendere lagt til for {name}.",
"errorPick": "Velg minst én kalender.",
"errorName": "Kildenavn er påkrevd."
@@ -109,7 +109,7 @@
"providerLabelGoogle": "Google",
"providerLabelMicrosoft": "Outlook",
"organizerEmailLabel": "Arrangørens e-post",
- "organizerEmailPlaceholder": "deg@example.com",
+ "organizerEmailPlaceholder": "deg@eksempel.no",
"organizerEmailPrompt": "Tjeneren din publiserte ikke en kalenderbrukeradresse. Skriv inn e-posten Aerion skal bruke som møtearrangør for hendelser på denne kilden.",
"organizerEmailHelp": "Dette er adressen som vises som ORGANIZER på hendelser du oppretter her.",
"organizerEmailInvalid": "Skriv inn en gyldig e-postadresse."
@@ -160,13 +160,13 @@
"settings": {
"title": "Kalenderinnstillinger",
"localCalendarsSection": "Lokale kalendere",
- "noLocalCalendars": "Ingen lokale kalendere ennå — legg til en for å opprette hendelser.",
+ "noLocalCalendars": "Ingen lokale kalendere ennå – legg til en for å opprette hendelser.",
"addLocalCalendar": "Legg til lokal kalender",
"calendarDeleted": "Slettet den lokale kalenderen «{name}»",
"deleteCalendarConfirmTitle": "Slette {name}?",
"deleteCalendarConfirmDescription": "Alle hendelser i denne kalenderen fjernes. Dette kan ikke angres.",
"sourcesSection": "Kilder",
- "noSources": "Ingen kilder ennå — legg til en for å begynne å synkronisere.",
+ "noSources": "Ingen kilder ennå – legg til en for å begynne å synkronisere.",
"calendarCount": "{count} kalendere",
"calendarCountOne": "1 kalender",
"lastSync": "Synkronisert for {time} siden",
@@ -209,7 +209,7 @@
"addGoogleErrorAccount": "Velg en konto.",
"addGoogleErrorPick": "Velg minst én kalender.",
"addGoogleErrorName": "Kildenavn er påkrevd.",
- "addGoogleToastSuccess": "Google-kalendere lagt til — synkroniserer nå.",
+ "addGoogleToastSuccess": "Google-kalendere lagt til – synkroniserer nå.",
"addGoogleToastError": "Klarte ikke å legge til Google-kalender: {message}",
"addOutlook": "Legg til Outlook-kalender",
"addOutlookTitle": "Legg til Outlook-kalender",
@@ -231,13 +231,13 @@
"addOutlookErrorAccount": "Velg en konto.",
"addOutlookErrorPick": "Velg minst én kalender.",
"addOutlookErrorName": "Kildenavn er påkrevd.",
- "addOutlookToastSuccess": "Outlook-kalendere lagt til — synkroniserer nå.",
+ "addOutlookToastSuccess": "Outlook-kalendere lagt til – synkroniserer nå.",
"addOutlookToastError": "Klarte ikke å legge til Outlook-kalender: {message}",
"organizerEmailLabel": "Arrangørens e-post",
- "organizerEmailPlaceholder": "deg@example.com",
+ "organizerEmailPlaceholder": "deg@eksempel.no",
"organizerEmailSaved": "Arrangørens e-post oppdatert",
"organizerEmailReprobed": "Fant {count} adresse(r) på tjeneren",
- "organizerEmailReprobeEmpty": "Tjeneren publiserer fortsatt ikke en kalenderbrukeradresse — skriv inn en ovenfor og lagre.",
+ "organizerEmailReprobeEmpty": "Tjeneren publiserer fortsatt ikke en kalenderbrukeradresse – skriv inn en ovenfor og lagre.",
"organizerEmailReprobeTitle": "Undersøk tjeneren på nytt etter kalenderbrukeradresser",
"renameTitle": "Gi kilden nytt navn",
"renameToastSuccess": "Fikk nytt navn til «{name}»",
@@ -281,7 +281,7 @@
"dialogSend": "Send oppdatering",
"dialogDontSend": "Ikke send",
"dialogCancel": "Avbryt",
- "dialogNoteMicrosoft": "Microsoft Graph kan sende invitasjoner uansett hva du velger — v1.0-API-et har ingen dokumentert mekanisme for å undertrykke dem.",
+ "dialogNoteMicrosoft": "Microsoft Graph kan sende invitasjoner uansett hva du velger – v1.0-API-et har ingen dokumentert mekanisme for å undertrykke dem.",
"dialogNoteCalDAVNone": "Denne CalDAV-tjeneren støtter ikke automatisk levering av invitasjoner, så oppdateringer sendes ikke til deltakerne.",
"findATime": "Finn et tidspunkt",
"findATimeTitle": "Finn et tidspunkt som passer",
diff --git a/extensions/contacts/frontend/i18n/index.ts b/extensions/contacts/frontend/i18n/index.ts
index dc133601..eef0b5d5 100644
--- a/extensions/contacts/frontend/i18n/index.ts
+++ b/extensions/contacts/frontend/i18n/index.ts
@@ -17,6 +17,7 @@ export function registerExtensionI18n(): void {
register('fr', () => import('./locales/fr.json'))
register('it', () => import('./locales/it.json'))
register('nb', () => import('./locales/nb.json'))
+ register('pl', () => import('./locales/pl.json'))
register('vi', () => import('./locales/vi.json'))
register('zh-CN', () => import('./locales/zh-CN.json'))
register('zh-HK', () => import('./locales/zh-HK.json'))
diff --git a/extensions/contacts/frontend/i18n/locales/nb.json b/extensions/contacts/frontend/i18n/locales/nb.json
index d73e2a2c..407dc666 100644
--- a/extensions/contacts/frontend/i18n/locales/nb.json
+++ b/extensions/contacts/frontend/i18n/locales/nb.json
@@ -47,7 +47,7 @@
"copyTooltip": "Klikk for å kopiere",
"readonlyHint": "Skrivebeskyttet",
"cardDAVReadOnlyNote": "Denne kilden er skrivebeskyttet. For å aktivere redigering, åpne Innstillinger → Kontaktkilder, rediger denne kilden, og slå på «Aktiver skrivetilgang».",
- "oauthReadOnlyNote": "Skrivebeskyttet – skrivemulighet for Google-/Microsoft-kontakter kommer i en fremtidig utgave.",
+ "oauthReadOnlyNote": "Skrivebeskyttet – skrivemulighet for Google-/Microsoft-kontakter kommer i en fremtidig oppdatering av Aerion.",
"labels": {
"email": "E-post",
"phone": "Telefon",
@@ -56,7 +56,7 @@
"title": "Tittel",
"bday": "Fødselsdag",
"nickname": "Kallenavn",
- "url": "URL",
+ "url": "Nettadresse",
"im": "Lynmelding",
"categories": "Kategorier",
"note": "Notat",
@@ -91,7 +91,7 @@
"removeEmail": "Fjern e-post",
"phones": "Telefoner",
"addPhone": "Legg til telefon",
- "phonePlaceholder": "+47 555 01 00",
+ "phonePlaceholder": "+47 90 12 34 56",
"removePhone": "Fjern telefon",
"addresses": "Adresser",
"addAddress": "Legg til adresse",
@@ -103,10 +103,10 @@
"addressCountry": "Land",
"org": "Organisasjon",
"titleField": "Tittel",
- "urls": "URL-er",
- "addUrl": "Legg til URL",
+ "urls": "Nettadresser",
+ "addUrl": "Legg til nettadresse",
"urlPlaceholder": "https://eksempel.no",
- "removeUrl": "Fjern URL",
+ "removeUrl": "Fjern nettadresse",
"impps": "Lynmeldinger",
"addImpp": "Legg til lynmelding",
"removeImpp": "Fjern lynmelding",
diff --git a/extensions/contacts/frontend/i18n/locales/pl.json b/extensions/contacts/frontend/i18n/locales/pl.json
new file mode 100644
index 00000000..dec35163
--- /dev/null
+++ b/extensions/contacts/frontend/i18n/locales/pl.json
@@ -0,0 +1,168 @@
+{
+ "contacts": {
+ "common": {
+ "unnamed": "(bez nazwy)",
+ "cancel": "Anuluj",
+ "save": "Zapisz",
+ "delete": "Usuń",
+ "primary": "główny",
+ "primaryLabel": "Główny",
+ "primaryTooltip": "Główny"
+ },
+ "sidebar": {
+ "title": "Kontakty",
+ "all": "Wszystkie",
+ "localAll": "Lokalne - Wszystkie",
+ "localManual": "Lokalne - Kontakty",
+ "localCollected": "Lokalne - Zebrane",
+ "sourcesHeading": "Źródła",
+ "noSources": "Brak skonfigurowanych źródeł CardDAV.",
+ "syncing": "Synchronizowanie kontaktów…",
+ "idle": "Kontakty",
+ "settings": "Ustawienia kontaktów"
+ },
+ "list": {
+ "header": "Kontakty",
+ "label": "Kontakty",
+ "searchPlaceholder": "Szukaj kontaktów...",
+ "searchOpen": "Szukaj",
+ "searchClose": "Zamknij wyszukiwanie",
+ "searchClear": "Wyczyść wyszukiwanie",
+ "sortAsc": "Sortuj: A → Z",
+ "sortDesc": "Sortuj: Z → A",
+ "addTooltip": "Dodaj kontakt",
+ "emptySearch": "Brak kontaktów pasujących do wyszukiwania.",
+ "empty": "Brak kontaktów."
+ },
+ "writeAccessBanner": {
+ "title": "Źródła tylko do odczytu — dostęp do zapisu jest wyłączony",
+ "enableButton": "Włącz dostęp do zapisu",
+ "providerGoogle": "Google",
+ "providerMicrosoft": "Microsoft",
+ "providerCardDAV": "CardDAV"
+ },
+ "detail": {
+ "emptyState": "Wybierz kontakt, aby wyświetlić szczegóły.",
+ "edit": "Edytuj",
+ "copyTooltip": "Kliknij, aby skopiować",
+ "readonlyHint": "Tylko do odczytu",
+ "cardDAVReadOnlyNote": "To źródło jest tylko do odczytu. Aby włączyć edycję, otwórz Ustawienia → Źródła kontaktów, edytuj to źródło i włącz \"Włącz dostęp do zapisu\".",
+ "oauthReadOnlyNote": "Tylko do odczytu — możliwość zapisu kontaktów dla Google/Microsoft pojawi się w przyszłym wydaniu.",
+ "labels": {
+ "email": "E-mail",
+ "phone": "Telefon",
+ "address": "Adres",
+ "org": "Organizacja",
+ "title": "Stanowisko",
+ "bday": "Urodziny",
+ "nickname": "Pseudonim",
+ "url": "URL",
+ "im": "Komunikator",
+ "categories": "Kategorie",
+ "note": "Notatka",
+ "source": "Źródło",
+ "lastUpdated": "Ostatnia aktualizacja"
+ }
+ },
+ "add": {
+ "title": "Dodaj kontakt",
+ "description": "Wybierz źródło, a następnie wprowadź e-mail i wyświetlaną nazwę kontaktu.",
+ "sourceLabel": "Zapisz w",
+ "sourcePlaceholder": "Wybierz źródło",
+ "localOption": "Kontakty lokalne",
+ "addressbookLabel": "Książka adresowa",
+ "addressbookPlaceholder": "Wybierz książkę adresową",
+ "errorEmailRequired": "E-mail jest wymagany.",
+ "errorEmailInvalid": "Wprowadź prawidłowy adres e-mail.",
+ "errorEmailExists": "Kontakt z tym adresem e-mail już istnieje."
+ },
+ "edit": {
+ "title": "Edytuj kontakt",
+ "photoChange": "Zmień…",
+ "photoRemove": "Usuń",
+ "photoUrlOnly": "(zlinkowano z serwera — wybierz plik, aby zastąpić go zdjęciem wklejonym w treść)",
+ "name": "Wyświetlana nazwa",
+ "nameRequired": "Wyświetlana nazwa jest wymagana.",
+ "nickname": "Pseudonim",
+ "emails": "Adresy e-mail",
+ "addEmail": "Dodaj e-mail",
+ "emailPlaceholder": "nazwa@example.com",
+ "emailInvalid": "Wprowadź prawidłowy adres e-mail.",
+ "removeEmail": "Usuń e-mail",
+ "phones": "Numery telefonów",
+ "addPhone": "Dodaj telefon",
+ "phonePlaceholder": "+48 123 456 789",
+ "removePhone": "Usuń telefon",
+ "addresses": "Adresy",
+ "addAddress": "Dodaj adres",
+ "removeAddress": "Usuń adres",
+ "addressStreet": "Ulica",
+ "addressCity": "Miasto",
+ "addressRegion": "Województwo / Region",
+ "addressPostcode": "Kod pocztowy",
+ "addressCountry": "Kraj",
+ "org": "Organizacja",
+ "titleField": "Stanowisko",
+ "urls": "Adresy URL",
+ "addUrl": "Dodaj URL",
+ "urlPlaceholder": "https://example.com",
+ "removeUrl": "Usuń URL",
+ "impps": "Komunikator internetowy",
+ "addImpp": "Dodaj komunikator",
+ "removeImpp": "Usuń komunikator",
+ "imppPlaceholder": "xmpp:user@chat.example",
+ "categoriesLabel": "Kategorie",
+ "categoriesPlaceholder": "znajomy, zespół, siłownia (rozdzielone przecinkami)",
+ "bday": "Urodziny",
+ "note": "Notatka"
+ },
+ "delete": {
+ "title": "Usunąć ten kontakt?",
+ "descriptionLocal": "{name} zostanie usunięty z Twoich lokalnych kontaktów. Wiadomości wysłane wcześniej na ten adres pozostaną nienaruszone.",
+ "descriptionCardDAV": "{name} zostanie usunięty z tej książki adresowej na serwerze. Tej operacji nie można cofnąć z poziomu Aerion."
+ },
+ "settings": {
+ "title": "Ustawienia kontaktów",
+ "description": "Skonfiguruj poświadczenia OAuth dla rozszerzenia Kontakty. Dostęp do odczytu korzysta z istniejących uprawnień Twojego konta pocztowego; te poświadczenia obsługują operacje ZAPISU (edycja / tworzenie / usuwanie) w źródłach Google lub Microsoft.",
+ "oauthHeading": "Poświadczenia OAuth",
+ "oauthDescription": "Każde pole zawiera zweryfikowane poświadczenia Aerion, jeśli są dostępne; przełącz na Niestandardowe, aby podać własny Client ID i Secret projektu.",
+ "googleLabel": "Kontakty Google",
+ "microsoftLabel": "Kontakty Microsoft",
+ "writeAccessHeading": "Dostęp do zapisu",
+ "writeAccessDescription": "Włącz lub wyłącz uprawnienie zapisu w źródle kontaktów. Źródła CardDAV wymagają jedynie przełącznika. Google i Microsoft wymagają dodatkowo jednorazowej zgody OAuth.",
+ "writeAccessRowSubtitle": "Tylko do odczytu — dostęp do zapisu wyłączony",
+ "writeAccessRowSubtitleEnabled": "Dostęp do zapisu włączony",
+ "enableWriteAccess": "Włącz dostęp do zapisu",
+ "disableWriteAccess": "Wyłącz dostęp do zapisu",
+ "reauthorizeWriteAccess": "Autoryzuj ponownie",
+ "writeAccessEnabled": "Włączono dostęp do zapisu dla {name}",
+ "writeAccessDisabled": "Wyłączono dostęp do zapisu dla {name}",
+ "writeAccessDisableFailed": "Nie udało się wyłączyć dostępu do zapisu",
+ "writeAccessCanceled": "Nie włączono dostępu do zapisu",
+ "close": "Zamknij"
+ },
+ "hook": {
+ "skip": "Pomiń",
+ "setUp": "Skonfiguruj",
+ "done": "Gotowe",
+ "successMessage": "Skonfigurowano synchronizację kontaktów dla {name}",
+ "completeMessage": "Synchronizacja kontaktów została skonfigurowana."
+ },
+ "toast": {
+ "conflict": "Ten kontakt został zmieniony w innym miejscu. Twoje edycje zostały odrzucone i zastąpione najnowszą wersją.",
+ "deleted": "Usunięto kontakt",
+ "failedDelete": "Nie udało się usunąć kontaktu.",
+ "added": "Dodano kontakt",
+ "failedAdd": "Nie udało się dodać kontaktu.",
+ "updated": "Zaktualizowano kontakt",
+ "failedUpdate": "Nie udało się zaktualizować kontaktu.",
+ "emailCopied": "Skopiowano {email}",
+ "emailCopyFailed": "Nie udało się skopiować adresu e-mail.",
+ "photoFailed": "Nie można przetworzyć tego obrazu. Spróbuj użyć innego pliku.",
+ "syncSucceeded": "Zsynchronizowano kontakty",
+ "syncAllSucceeded": "Zsynchronizowano wszystkie źródła kontaktów",
+ "syncNoSource": "Najpierw wybierz źródło CardDAV na pasku bocznym."
+ }
+ }
+}
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 4ca7dde4..a02022c3 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "aerion",
- "version": "0.3.2",
+ "version": "0.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aerion",
- "version": "0.3.2",
+ "version": "0.3.3",
"dependencies": {
"@iconify/svelte": "^4.2.0",
"@tanstack/svelte-virtual": "^3.13.0",
diff --git a/frontend/package.json b/frontend/package.json
index 818982d3..8bccb7a6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "aerion",
"private": true,
- "version": "0.3.2",
+ "version": "0.3.3",
"type": "module",
"scripts": {
"predev": "node scripts/copy-dictionaries.mjs",
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
index 1b7fd3fc..eb195a77 100755
--- a/frontend/package.json.md5
+++ b/frontend/package.json.md5
@@ -1 +1 @@
-094a91fee09afbc45deb48c483b6d504
\ No newline at end of file
+48a90fd1c3ac1a9e4f4707bb63114d36
\ No newline at end of file
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index c0ac4e71..c4ba674f 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -865,6 +865,21 @@
// (bits-ui portals mount [role="menu"] only while open)
if (document.querySelector('[role="menu"]')) return
+ // Alt+M/Alt+C toggle the message list's folder picker. While it's open the
+ // dialog guard below swallows all keys, so close/switch is handled here.
+ if (messageListRef?.isFolderPickerOpen()) {
+ if (KEY.LIST_MOVE_TO(e)) {
+ e.preventDefault()
+ messageListRef.toggleMoveToDialog()
+ return
+ }
+ if (KEY.LIST_COPY_TO(e)) {
+ e.preventDefault()
+ messageListRef.toggleCopyToDialog()
+ return
+ }
+ }
+
// Don't intercept while a modal dialog has the guard active — keystrokes
// (especially Ctrl+A) should target dialog inputs, not the background.
if (isDialogGuardActive()) return
@@ -1174,6 +1189,44 @@
return
}
+ // Alt+G / Alt+Shift+G — jump to the first sidebar item (All Inboxes
+ // when present) or the last visible one (last account header when all
+ // folder trees are collapsed).
+ if (KEY.SIDEBAR_FIRST(e)) {
+ e.preventDefault()
+ if (isMailActive()) {
+ sidebarRef?.selectFirstFolder()
+ return
+ }
+ getPaneNav('sidebar')?.navigateFirst?.()
+ return
+ }
+ if (KEY.SIDEBAR_LAST(e)) {
+ e.preventDefault()
+ if (isMailActive()) {
+ sidebarRef?.selectLastFolder()
+ return
+ }
+ getPaneNav('sidebar')?.navigateLast?.()
+ return
+ }
+
+ // Alt+M / Alt+C — move/copy folder picker for the keyboard-focused
+ // message. Open path only: the picker sets the dialog guard, so the
+ // close/switch press is handled pre-guard near the top of this handler.
+ if (KEY.LIST_MOVE_TO(e)) {
+ if (!isMailActive()) return
+ e.preventDefault()
+ messageListRef?.toggleMoveToDialog()
+ return
+ }
+ if (KEY.LIST_COPY_TO(e)) {
+ if (!isMailActive()) return
+ e.preventDefault()
+ messageListRef?.toggleCopyToDialog()
+ return
+ }
+
// Alt+Enter — mail sidebar expand/collapse. Keep inline switch for
// single residual case.
switch (e.key) {
@@ -1315,15 +1368,35 @@
return
}
+ // g / Shift+G — jump to first / last loaded message (shared predicates —
+ // kit list panes consume the same KEY.LIST_FIRST/LIST_LAST locally)
+ if (KEY.LIST_FIRST(e)) {
+ if (!isMailActive()) return
+ if (focusedPane !== 'messageList') return
+ e.preventDefault()
+ messageListRef?.selectFirst()
+ return
+ }
+ if (KEY.LIST_LAST(e)) {
+ if (!isMailActive()) return
+ if (focusedPane !== 'messageList') return
+ e.preventDefault()
+ messageListRef?.selectLast()
+ return
+ }
+
+ // V — open the keyboard-focused conversation in the viewer (alias of
+ // Enter; shared predicate — kit list panes consume KEY.LIST_VIEW locally)
+ if (KEY.LIST_VIEW(e)) {
+ if (focusedPane === 'messageList') {
+ e.preventDefault()
+ messageListRef?.openSelected()
+ }
+ return
+ }
+
// Single-key shortcuts
switch (e.key) {
- case 'v':
- // Open the keyboard-focused conversation in the viewer (alias of Enter)
- if (focusedPane === 'messageList') {
- e.preventDefault()
- messageListRef?.openSelected()
- }
- return
case 's':
if (messageListRef?.hasCheckedMessages()) {
handleBulkToggleStar(messageListRef.getCheckedMessageIds(), messageListRef.getCheckedHasUnstarred())
diff --git a/frontend/src/lib/components/WhatsNewDialog.svelte b/frontend/src/lib/components/WhatsNewDialog.svelte
index 24192b34..51fa4103 100644
--- a/frontend/src/lib/components/WhatsNewDialog.svelte
+++ b/frontend/src/lib/components/WhatsNewDialog.svelte
@@ -48,14 +48,16 @@
-
🚀 Welcome to Aerion v0.3.2!
+
🚀 Welcome to Aerion v0.3.3!
Here are the highlights of this release:
- ✏️ Spellcheck functionality in the composer has been added
- 📩 Improved sync and pushed changes (IDLE)
- 📑 Improved profile pic support - Requires force re-sync of contacts
+ ⚗️ The message list has been redesigned to have messages cover the checkboxes on the left and will shift to the right to show the checkboxes on mouseover individually. This can be turned off by toggling, "Always show checkbox next to message" to revert to the original design.
+ 🐁 Added left and right swipe gestures. Swipe right to select and swipe left to delete.
+ ✏️ Added manual config of auth mechanism
+ 🚀 Added g, G, alt+g, alt+G, alt+c, alt+m shortcuts
+ 📩 Improved sync guards to improve stability
🐛 Bug fixes and improvements
diff --git a/frontend/src/lib/components/common/MessageContextMenu.svelte b/frontend/src/lib/components/common/MessageContextMenu.svelte
index aa42e515..bc9cb1f8 100644
--- a/frontend/src/lib/components/common/MessageContextMenu.svelte
+++ b/frontend/src/lib/components/common/MessageContextMenu.svelte
@@ -247,6 +247,27 @@
showFolderPicker = true
}
+ // Exported for keyboard access (Alt+M / Alt+C): toggles the picker without
+ // opening the context menu. Same-mode press closes; other-mode press
+ // switches the dialog in place (title is reactive to folderPickerMode).
+ export function isFolderPickerOpen(): boolean {
+ return showFolderPicker
+ }
+
+ export function toggleFolderPicker(mode: 'move' | 'copy') {
+ if (showFolderPicker && folderPickerMode === mode) {
+ showFolderPicker = false
+ return
+ }
+ if (showFolderPicker) {
+ folderPickerMode = mode
+ return
+ }
+ loadAccounts() // normally triggered by menu open; needed when bypassing the menu
+ folderPickerMode = mode
+ showFolderPicker = true
+ }
+
function handleFolderSelected(folderId: string, folderName: string, _destAccountId: string) {
showFolderPicker = false
switch (folderPickerMode) {
diff --git a/frontend/src/lib/components/kit/ListPane.svelte b/frontend/src/lib/components/kit/ListPane.svelte
index a725f1b6..2977b823 100644
--- a/frontend/src/lib/components/kit/ListPane.svelte
+++ b/frontend/src/lib/components/kit/ListPane.svelte
@@ -190,7 +190,23 @@
move(-1)
return
}
- if (KEY.LIST_OPEN(e)) {
+ // g / Shift+G — jump to first / last item. Scroll-into-view rides the
+ // existing selectedId $effect above.
+ if (KEY.LIST_FIRST(e)) {
+ e.preventDefault()
+ e.stopPropagation()
+ if (items.length === 0) return
+ onSelect(items[0].id)
+ return
+ }
+ if (KEY.LIST_LAST(e)) {
+ e.preventDefault()
+ e.stopPropagation()
+ if (items.length === 0) return
+ onSelect(items[items.length - 1].id)
+ return
+ }
+ if (KEY.LIST_OPEN(e) || KEY.LIST_VIEW(e)) {
const id = selectedId
if (!id) return
// No fallback to onSelect — that would couple "highlight changed" with
diff --git a/frontend/src/lib/components/kit/SourceSidebar.svelte b/frontend/src/lib/components/kit/SourceSidebar.svelte
index ff152f04..562ffe95 100644
--- a/frontend/src/lib/components/kit/SourceSidebar.svelte
+++ b/frontend/src/lib/components/kit/SourceSidebar.svelte
@@ -74,6 +74,12 @@
onSelect(allItems[next].id)
}
+ // Jump to first / last item (Alt+G / Alt+Shift+G via paneNav)
+ function jumpTo(index: number) {
+ if (allItems.length === 0) return
+ onSelect(allItems[index].id)
+ }
+
function handleKeyDown(e: KeyboardEvent) {
if (KEY.LIST_NEXT(e)) {
e.preventDefault()
@@ -112,6 +118,8 @@
onMount(() => registerPaneNav(focusSlot, {
navigateNext: () => move(1),
navigatePrev: () => move(-1),
+ navigateFirst: () => jumpTo(0),
+ navigateLast: () => jumpTo(allItems.length - 1),
activate: () => { if (selectedId) onSelect(selectedId) },
}))
diff --git a/frontend/src/lib/components/list/ConversationRow.svelte b/frontend/src/lib/components/list/ConversationRow.svelte
index 0b31207d..c5d8dab9 100644
--- a/frontend/src/lib/components/list/ConversationRow.svelte
+++ b/frontend/src/lib/components/list/ConversationRow.svelte
@@ -1,3 +1,14 @@
+
+
onSelect(e)}
+ : 'hover:bg-muted/50'} {getAccentBarUnread() && hasUnread ? 'border-l-2 border-l-primary' : ''} {swipeAnim === 'select' ? 'swipe-select-anim' : ''} {swipeAnim === 'delete' ? 'swipe-delete-anim' : ''}"
+ onclick={handleRowClick}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() }}}
ondragstart={handleDragStart}
+ ontouchstart={handleTouchStart}
+ ontouchmove={handleTouchMove}
+ ontouchend={handleTouchEnd}
+ ontouchcancel={handleTouchEnd}
+ onwheel={handleWheel}
role="button"
tabindex="0"
>
-
+ {#if swipeAnim === 'select'}
+
+
+ {/if}
+
+ {#if swipeAnim === 'delete'}
+
+
+ {/if}
+
+
+ // Session-only memory of how far the user paginated ("Load more"), where
+ // they scrolled, and which thread was selected (the keyboard-nav anchor),
+ // per folder view. Module scope so it survives folder switches and
+ // component remounts for the life of the app session; gone on quit.
+ // Keyed "accountId:folderId" ("unified:inbox" for the unified view).
+ const sessionListState = new Map<
+ string,
+ { loadedCount: number; scrollTop: number; selectedThreadId: string | null }
+ >()
+
+
-
- {#each $toasts as toast (toast.id)}
+
+ {#each fullCards as toast (toast.id)}
toasts.remove(toast.id)} />
{/each}
+
+ {#if stacked}
+ {@const pile = newestFirst[MAX_VISIBLE - 1]}
+
+ {#if hiddenBehind > 1}
+
(expanded = true)}
+ aria-label={$_('aria.showAllToasts')}
+ >
+ {/if}
+
(expanded = true)}
+ aria-label={$_('aria.showAllToasts')}
+ >
+
+ toasts.remove(pile.id)} />
+ (expanded = true)}
+ aria-label={$_('aria.showAllToasts')}
+ >
+ +{hiddenBehind}
+
+
+
+ {/if}
+
+ {#if expanded}
+
+ (expanded = false)}
+ aria-label={$_('aria.collapseToasts')}
+ >
+
+
+
+ {/if}
diff --git a/frontend/src/lib/config/providers.ts b/frontend/src/lib/config/providers.ts
index a05091e0..2e45f857 100644
--- a/frontend/src/lib/config/providers.ts
+++ b/frontend/src/lib/config/providers.ts
@@ -243,6 +243,17 @@ export const securityOptions = [
{ value: 'none', label: 'None (insecure)', labelKey: 'account.securityNone' },
] as const
+/**
+ * Password auth mechanism options (incoming and outgoing). "auto" negotiates
+ * from server capabilities; the explicit values force a mechanism for servers
+ * with broken advertisements (#355).
+ */
+export const authMechanismOptions = [
+ { value: 'auto', label: 'Auto' },
+ { value: 'plain', label: 'PLAIN' },
+ { value: 'login', label: 'LOGIN' },
+] as const
+
/**
* Common sync period options (in days)
*/
diff --git a/frontend/src/lib/i18n/dateFnsLocale.ts b/frontend/src/lib/i18n/dateFnsLocale.ts
index a167b9ed..8c17a2aa 100644
--- a/frontend/src/lib/i18n/dateFnsLocale.ts
+++ b/frontend/src/lib/i18n/dateFnsLocale.ts
@@ -47,6 +47,11 @@ export async function loadDateFnsLocale(code: string): Promise
import('./locales/en.json'))
register('cs', () => import('./locales/cs.json'))
register('de', () => import('./locales/de.json'))
register('fr', () => import('./locales/fr.json'))
register('it', () => import('./locales/it.json'))
register('nb', () => import('./locales/nb.json'))
+register('pl', () => import('./locales/pl.json'))
register('vi', () => import('./locales/vi.json'))
register('zh-CN', () => import('./locales/zh-CN.json'))
register('zh-HK', () => import('./locales/zh-HK.json'))
@@ -23,6 +25,7 @@ const extensionI18nModules = import.meta.glob<{
}>('../../../../extensions/*/frontend/i18n/index.ts', { eager: true })
// Supported locales for the language picker
+// en must be first — it's the fallback/default.
export const supportedLocales = [
{ code: 'en', name: 'English' },
{ code: 'cs', name: 'Čeština' },
@@ -30,6 +33,7 @@ export const supportedLocales = [
{ code: 'fr', name: 'Français' },
{ code: 'it', name: 'Italiano' },
{ code: 'nb', name: 'Norsk Bokmål' },
+ { code: 'pl', name: 'Polski' },
{ code: 'vi', name: 'Tiếng Việt' },
{ code: 'zh-CN', name: '简体中文 (中国)' },
{ code: 'zh-HK', name: '繁體中文 (香港)' },
diff --git a/frontend/src/lib/i18n/locales/cs.json b/frontend/src/lib/i18n/locales/cs.json
index 21e6072e..5a44913d 100644
--- a/frontend/src/lib/i18n/locales/cs.json
+++ b/frontend/src/lib/i18n/locales/cs.json
@@ -393,6 +393,8 @@
"darkMailContentHelp": "Použít tmavý filtr na obsah e-mailových zpráv, když je Aerion v tmavém režimu.",
"darkComposerBody": "Tmavé pozadí editoru",
"darkComposerBodyHelp": "Použít tmavé pozadí u editoru pro psaní zprávy, když je Aerion v tmavém režimu.",
+ "alwaysShowCheckbox": "Vždy zobrazit zaškrtávací políčko u zprávy",
+ "alwaysShowCheckboxHelp": "Vyhradit v seznamu zpráv stálý sloupec se zaškrtávacím políčkem místo jeho odkrývání při najetí myší nebo přejetí.",
"selectDensity": "Vybrat hustotu",
"readReceipts": "Potvrzení o přečtení",
"readReceiptPolicy": "Když někdo požaduje potvrzení o přečtení",
@@ -595,6 +597,7 @@
"replyForwardWithHelp": "Identita, která bude v editoru předvybrána při odpovídání na zprávy přijaté tímto účtem nebo při jejich přeposílání.",
"replyForwardWithDefault": "Výchozí účet pro odesílání",
"smtpAuthentication": "Ověření SMTP",
+ "authMechanism": "Metoda ověření",
"smtpUseSameAsIncoming": "Stejné jako pro příchozí server",
"smtpUsernamePlaceholder": "uzivatel-smtp@example.com",
"smtpPasswordPlaceholder": "Heslo SMTP",
@@ -924,7 +927,9 @@
"selectPresetColor": "Vybrat {color}",
"backToList": "Zpět na seznam zpráv",
"toggleSidebar": "Přepnout postranní panel",
- "closeSidebar": "Zavřít postranní panel"
+ "closeSidebar": "Zavřít postranní panel",
+ "showAllToasts": "Zobrazit všechna oznámení",
+ "collapseToasts": "Sbalit oznámení"
},
"window": {
"minimize": "Minimalizovat",
diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json
index 26183532..d03b0f58 100644
--- a/frontend/src/lib/i18n/locales/de.json
+++ b/frontend/src/lib/i18n/locales/de.json
@@ -393,6 +393,8 @@
"darkMailContentHelp": "Einen Dunkelfilter auf den E-Mail-Inhalt anwenden, wenn Aerion im Dunkelmodus ist.",
"darkComposerBody": "Dunkler Editor-Hintergrund",
"darkComposerBodyHelp": "Einen dunklen Hintergrund für den Editor-Bereich verwenden, wenn Aerion im Dunkelmodus ist.",
+ "alwaysShowCheckbox": "Kontrollkästchen neben Nachrichten immer anzeigen",
+ "alwaysShowCheckboxHelp": "Eine feste Spalte für Kontrollkästchen in der Nachrichtenliste reservieren, statt sie erst beim Überfahren oder Wischen einzublenden.",
"selectDensity": "Dichte auswählen",
"readReceipts": "Lesebestätigungen",
"readReceiptPolicy": "Wenn jemand eine Lesebestätigung anfordert",
@@ -595,6 +597,7 @@
"replyForwardWithHelp": "Identität, die im Editor vorausgewählt wird, wenn auf Nachrichten geantwortet oder diese weitergeleitet werden, die hier empfangen wurden.",
"replyForwardWithDefault": "Standard-Sendekonto",
"smtpAuthentication": "SMTP-Authentifizierung",
+ "authMechanism": "Authentifizierungsmethode",
"smtpUseSameAsIncoming": "Gleich wie beim Posteingangsserver",
"smtpUsernamePlaceholder": "smtp-user@beispiel.de",
"smtpPasswordPlaceholder": "SMTP-Passwort",
@@ -924,7 +927,9 @@
"selectPresetColor": "{color} auswählen",
"backToList": "Zurück zur Nachrichtenliste",
"toggleSidebar": "Seitenleiste ein-/ausblenden",
- "closeSidebar": "Seitenleiste schließen"
+ "closeSidebar": "Seitenleiste schließen",
+ "showAllToasts": "Alle Benachrichtigungen anzeigen",
+ "collapseToasts": "Benachrichtigungen einklappen"
},
"window": {
"minimize": "Minimieren",
diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json
index fcfea48a..ce1bc09b 100644
--- a/frontend/src/lib/i18n/locales/en.json
+++ b/frontend/src/lib/i18n/locales/en.json
@@ -395,6 +395,8 @@
"darkMailContentHelp": "Apply a dark filter to email message content when Aerion is in dark mode.",
"darkComposerBody": "Dark composer body",
"darkComposerBodyHelp": "Use a dark background for the composer message body when Aerion is in dark mode.",
+ "alwaysShowCheckbox": "Always show checkbox next to message",
+ "alwaysShowCheckboxHelp": "Reserve a fixed checkbox column in the message list instead of revealing it on hover or swipe.",
"selectDensity": "Select density",
"readReceipts": "Read Receipts",
"readReceiptPolicy": "When someone requests a read receipt",
@@ -597,6 +599,7 @@
"replyForwardWithHelp": "Identity to pre-select in the composer when replying to or forwarding messages received here.",
"replyForwardWithDefault": "Default sending account",
"smtpAuthentication": "SMTP authentication",
+ "authMechanism": "Authentication method",
"smtpUseSameAsIncoming": "Same as incoming server",
"smtpUsernamePlaceholder": "smtp-user@example.com",
"smtpPasswordPlaceholder": "SMTP password",
@@ -926,7 +929,9 @@
"selectPresetColor": "Select {color}",
"backToList": "Back to message list",
"toggleSidebar": "Toggle sidebar",
- "closeSidebar": "Close sidebar"
+ "closeSidebar": "Close sidebar",
+ "showAllToasts": "Show all notifications",
+ "collapseToasts": "Collapse notifications"
},
"window": {
"minimize": "Minimize",
diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json
index 28bb90fd..08ce55ef 100644
--- a/frontend/src/lib/i18n/locales/fr.json
+++ b/frontend/src/lib/i18n/locales/fr.json
@@ -393,6 +393,8 @@
"darkMailContentHelp": "Appliquer un filtre sombre au contenu des messages e-mail lorsque Aerion est en mode sombre.",
"darkComposerBody": "Corps de l'éditeur sombre",
"darkComposerBodyHelp": "Utiliser un fond sombre pour le corps du message dans l'éditeur lorsque Aerion est en mode sombre.",
+ "alwaysShowCheckbox": "Toujours afficher la case à cocher à côté du message",
+ "alwaysShowCheckboxHelp": "Réserver une colonne fixe pour les cases à cocher dans la liste des messages au lieu de les révéler au survol ou par balayage.",
"selectDensity": "Sélectionner la densité",
"readReceipts": "Accusés de réception",
"readReceiptPolicy": "Lorsqu'on vous demande un accusé",
@@ -595,6 +597,7 @@
"replyForwardWithHelp": "Identité à pré-sélectionner dans le compositeur lors d'une réponse ou d'un transfert.",
"replyForwardWithDefault": "Compte d'envoi par défaut",
"smtpAuthentication": "Authentification SMTP",
+ "authMechanism": "Méthode d'authentification",
"smtpUseSameAsIncoming": "Identique au serveur entrant",
"smtpUsernamePlaceholder": "utilisateur-smtp@exemple.com",
"smtpPasswordPlaceholder": "Mot de passe SMTP",
@@ -917,7 +920,9 @@
"selectPresetColor": "Choisir {color}",
"backToList": "Retour à la liste",
"toggleSidebar": "Afficher/Masquer la barre latérale",
- "closeSidebar": "Fermer la barre latérale"
+ "closeSidebar": "Fermer la barre latérale",
+ "showAllToasts": "Afficher toutes les notifications",
+ "collapseToasts": "Réduire les notifications"
},
"window": {
"minimize": "Minimiser",
diff --git a/frontend/src/lib/i18n/locales/it.json b/frontend/src/lib/i18n/locales/it.json
index d3244f03..62799568 100644
--- a/frontend/src/lib/i18n/locales/it.json
+++ b/frontend/src/lib/i18n/locales/it.json
@@ -393,6 +393,8 @@
"darkMailContentHelp": "Applica un filtro scuro ai contenuti dei messaggi di posta elettronica quando Aerion è in modalità scura.",
"darkComposerBody": "Corpo dell'editor in modalità scura",
"darkComposerBodyHelp": "Usa uno sfondo scuro per il corpo del messaggio nell'editor quando Aerion è in modalità scura.",
+ "alwaysShowCheckbox": "Mostra sempre la casella di selezione accanto al messaggio",
+ "alwaysShowCheckboxHelp": "Riserva una colonna fissa per le caselle di selezione nell'elenco dei messaggi invece di rivelarle al passaggio del mouse o con lo scorrimento.",
"selectDensity": "Seleziona dimensione",
"readReceipts": "Conferme di lettura",
"readReceiptPolicy": "Quando qualcuno richiede una conferma di lettura",
@@ -595,6 +597,7 @@
"replyForwardWithHelp": "Identità da preselezionare nell'editor quando si risponde o si inoltrano i messaggi ricevuti qui.",
"replyForwardWithDefault": "Account di invio predefinito",
"smtpAuthentication": "Autenticazione SMTP",
+ "authMechanism": "Metodo di autenticazione",
"smtpUseSameAsIncoming": "Come per il server in entrata",
"smtpUsernamePlaceholder": "utente-smtp@esempio.com",
"smtpPasswordPlaceholder": "Password SMTP",
@@ -924,7 +927,9 @@
"selectPresetColor": "Seleziona {color}",
"backToList": "Torna all'elenco messaggi",
"toggleSidebar": "Attiva/disattiva la barra laterale",
- "closeSidebar": "Chiudi barra laterale"
+ "closeSidebar": "Chiudi barra laterale",
+ "showAllToasts": "Mostra tutte le notifiche",
+ "collapseToasts": "Comprimi le notifiche"
},
"window": {
"minimize": "Riduci",
diff --git a/frontend/src/lib/i18n/locales/nb.json b/frontend/src/lib/i18n/locales/nb.json
index 27f7130d..cdc606f7 100644
--- a/frontend/src/lib/i18n/locales/nb.json
+++ b/frontend/src/lib/i18n/locales/nb.json
@@ -1,4 +1,17 @@
{
+ "spellcheck": {
+ "title": "Stavekontroll",
+ "enable": "Kontroller stavemåte",
+ "enableHelp": "Strek under feilstavede ord mens du skriver.",
+ "languages": "Ordbøker",
+ "languagesHelp": "Et ord godtas hvis det er gyldig i minst ett av de aktiverte språkene.",
+ "addToDictionary": "Legg til i ordboken",
+ "ignore": "Ignorer",
+ "noSuggestions": "Ingen forslag",
+ "addedWords": "Tillagte ord",
+ "noAddedWords": "Ingen ord er lagt til ennå.",
+ "removeWord": "Fjern ord"
+ },
"common": {
"save": "Lagre",
"cancel": "Avbryt",
@@ -108,17 +121,17 @@
"processingSMIME": "Behandler S/MIME-melding…",
"smimeEncryptedWith": "Denne meldingen er S/MIME-kryptert",
"smimeSignedBy": "Signert av {email} med S/MIME",
- "smimeUnknownSigner": "Signert med S/MIME — ukjent signatur ({email})",
- "smimeSelfSigned": "Signert av {email} med S/MIME — Egensignert sertifikat",
- "smimeExpiredCert": "Signert av {email} med S/MIME — Sertfikat utløpt",
+ "smimeUnknownSigner": "Signert med S/MIME – ukjent signatur ({email})",
+ "smimeSelfSigned": "Signert av {email} med S/MIME – Egensignert sertifikat",
+ "smimeExpiredCert": "Signert av {email} med S/MIME – Sertifikat utløpt",
"smimeInvalid": "S/MIME-signatur ugyldig",
"smimeDecryptFailed": "S/MIME dekryptering feilet",
"processingPGP": "Behandler PGP-melding…",
"pgpEncryptedWith": "Denne meldingen er PGP-kryptert",
"pgpSignedBy": "Signert av {email} med PGP",
- "pgpUnknownKey": "Signert med PGP — ukjent nøkkel {keyId}",
- "pgpExpiredKey": "Signert av {email} med PGP — nøkkel utløpt",
- "pgpRevokedKey": "Signert av {email} med PGP — nøkkel tilbakekalt",
+ "pgpUnknownKey": "Signert med PGP – ukjent nøkkel {keyId}",
+ "pgpExpiredKey": "Signert av {email} med PGP – nøkkel utløpt",
+ "pgpRevokedKey": "Signert av {email} med PGP – nøkkel tilbakekalt",
"pgpInvalid": "PGP-signatur ugyldig",
"pgpDecryptFailed": "PGP dekryptering feilet",
"decryptingMessage": "Dekrypterer melding…",
@@ -187,7 +200,7 @@
"failedToSend": "Klarte ikke å sende.",
"noRecipients": "Vennligst legg til minst én mottaker",
"failedToLoadDraft": "Klarte ikke å laste inn utkast",
- "certImported": "Sertfikat importert for {email}",
+ "certImported": "Sertifikat importert for {email}",
"pgpKeyImported": "PGP-nøkkel importert for {email}",
"close": "Lukk",
"opening": "Åpner…",
@@ -272,6 +285,8 @@
"settingsSaved": "Innstillinger lagret",
"undone": "Angret: {description}",
"contactSourceSynced": "Kontaktkilde synkronisert",
+ "contactSourceForceSynced": "Kontaktkilde synkronisert på nytt fra tjeneren",
+ "contactSourceForceSyncFailed": "Tvunget ny synkronisering mislyktes",
"contactSourceDeleted": "Kontaktkilde slettet",
"contactSourceAdded": "Kontaktkilde lagt til",
"contactSourceUpdated": "Kontaktkilde oppdatert",
@@ -310,6 +325,7 @@
"fillRequiredFields": "Vennligst fyll inn alle de påkrevde feltene",
"passwordRequired": "Passord er påkrevd for nye kilder",
"selectAddressbook": "Vennligst velg mist en adressebok",
+ "selectOAuthAccount": "Velg en OAuth-konto",
"linkAccountOrSignIn": "Vennligst koble til en konto eller logg inn",
"accountSaved": "Kontoinnstillinger lagret",
"failedToSaveAccount": "Klarte ikke å lagre konto.",
@@ -319,10 +335,7 @@
"failedToReauthorize": "Klarte ikke å autorisere konto.",
"oauthExpired": "{name}: OAuth-sesjon utløpt. Vennligst logg inn på nytt i kontoinstillingene.",
"failedToDeleteIdentity": "Klarte ikke å slette identitet.",
- "linkOpened": "Lenke åpnet i nettleser",
- "contactSourceForceSynced": "Kontaktkilde synkronisert på nytt fra tjeneren",
- "contactSourceForceSyncFailed": "Tvunget ny synkronisering mislyktes",
- "selectOAuthAccount": "Velg en OAuth-konto"
+ "linkOpened": "Lenke åpnet i nettleser"
},
"settings": {
"title": "Innstillinger",
@@ -331,6 +344,16 @@
"accounts": "Kontoer",
"contacts": "Kontakter",
"about": "Om Aerion",
+ "extensions": "Utvidelser",
+ "extensionsCoreHeading": "Kjerneutvidelser",
+ "extensionsCoreDescription": "Innebygde utvidelser du kan aktivere eller deaktivere.",
+ "extensionsEnabled": "Aktivert",
+ "extensionsDisabled": "Deaktivert",
+ "extensionsEnable": "Aktiver",
+ "extensionsDisable": "Deaktiver",
+ "extensionsCapabilities": "Funksjoner",
+ "extensionsVersion": "Versjon",
+ "extensionsNone": "Ingen utvidelser er tilgjengelige i denne versjonen.",
"images": "Bilder",
"composer": "Skriving",
"composerMode": "Standard skrivemodus",
@@ -342,17 +365,7 @@
"composerFormat": "Standard meldingsformat",
"composerFormatRich": "Rik tekst",
"composerFormatPlain": "Standardtekst",
- "composerFormatDescription": "Nye meldinger vil bruke det angitte formatet som standard. Du kan fortsatt veksle mellom dem når du skriver nye meldinger.",
- "extensions": "Utvidelser",
- "extensionsCoreHeading": "Kjerneutvidelser",
- "extensionsCoreDescription": "Innebygde utvidelser du kan aktivere eller deaktivere.",
- "extensionsEnabled": "Aktivert",
- "extensionsDisabled": "Deaktivert",
- "extensionsEnable": "Aktiver",
- "extensionsDisable": "Deaktiver",
- "extensionsCapabilities": "Funksjoner",
- "extensionsVersion": "Versjon",
- "extensionsNone": "Ingen utvidelser er tilgjengelige i denne versjonen."
+ "composerFormatDescription": "Nye meldinger vil bruke det angitte formatet som standard. Du kan fortsatt veksle mellom dem når du skriver nye meldinger."
},
"settingsGeneral": {
"display": "Visning",
@@ -374,10 +387,16 @@
"showMessageListCirclesHelp": "Vis fargerike sirkler med avsenderen sine initialer i meldingslisten",
"showViewerCircles": "Vis fargerike sirkler i samtalevisningen",
"showViewerCirclesHelp": "Vis fargerike sirkler med avsenderen sine initialer i samtalevisningen",
+ "showMessageListProfilePics": "Vis kontaktbilder i meldingslisten",
+ "showMessageListProfilePicsHelp": "Vis kontaktens profilbilde i meldingslisten, eller en farget sirkel hvis bildet mangler. Krever at «Vis fargerike sirkler i meldingslisten» er aktivert.",
"accentBarUnread": "Merkelinje for uleste meldinger",
"accentBarUnreadHelp": "Viser en linje på venstesiden av uleste meldinger for enklere identifisering.",
"darkMailContent": "Mørkt e-postinnhold",
"darkMailContentHelp": "Bruk et mørkt filter på innhold i e-postmeldinger når Aerion er i mørk modus.",
+ "darkComposerBody": "Mørkt skriveinnhold",
+ "darkComposerBodyHelp": "Bruk en mørk bakgrunn for meldingsteksten i skrivevinduet når Aerion er i mørk modus.",
+ "alwaysShowCheckbox": "Vis alltid avkrysningsboksen ved meldingen",
+ "alwaysShowCheckboxHelp": "Reserver en fast kolonne for avkrysningsbokser i meldingslisten i stedet for å vise dem når du holder pekeren over eller sveiper.",
"selectDensity": "Velg tetthet",
"readReceipts": "Lesekvitteringer",
"readReceiptPolicy": "Når noen ber om en lesekvittering",
@@ -417,9 +436,7 @@
"alwaysLoadImagesHelp": "Deaktiverer blokkering av bilder for alle eposter. Eksterne bilder lastes inn automatisk.",
"alwaysLoadImagesWarningTitle": "Personvern- og sikkerthetsrisiko",
"alwaysLoadImagesWarningDescription": "Innlasting av eksterne bilder kan la avsendere se IP-adressen din, bekrefte at du har lest en e-post og spore deg. Er du sikker på at du vil deaktivere blokkering av bilder?",
- "disable": "Deaktiver",
- "darkComposerBody": "Mørkt skriveinnhold",
- "darkComposerBodyHelp": "Bruk en mørk bakgrunn for meldingsteksten i skrivevinduet når Aerion er i mørk modus."
+ "disable": "Deaktiver"
},
"settingsAccounts": {
"emailAccounts": "E-postkontoer",
@@ -461,8 +478,25 @@
"accountIdentification": "Kontodetaljer",
"password": "Passord",
"enterAppPassword": "Skriv inn app-passord",
+ "enterPassword": "Skriv inn passord",
"reauthorize": "Godkjenn på nytt",
"authentication": "Autentifisering",
+ "customOAuthHelp": "Bruk din egen OAuth-app. Skriv inn utsteder-URL-en til tilbyderen din, så oppdager Aerion endepunktene automatisk.",
+ "customOAuthIssuer": "Utsteder-URL",
+ "customOAuthIssuerHelp": "Grunn-URL-en til tilbyderen din. Aerion leser dens /.well-known/openid-configuration-dokument.",
+ "customOAuthDiscover": "Oppdag",
+ "customOAuthDiscovered": "Endepunkter oppdaget",
+ "customOAuthManual": "Skriv inn endepunkter manuelt",
+ "customOAuthAuthUrl": "Autoriseringsendepunkt",
+ "customOAuthTokenUrl": "Token-endepunkt",
+ "customOAuthUserinfoUrl": "Brukerinfo-endepunkt",
+ "customOAuthClientId": "Klient-ID",
+ "customOAuthClientSecret": "Klienthemmelighet",
+ "customOAuthClientSecretPlaceholder": "La stå tom for en offentlig klient (PKCE)",
+ "customOAuthScopes": "Scopes",
+ "customOAuthScopesHelp": "Atskilt med mellomrom. De fleste tjenere trenger offline_access for en oppdateringstoken.",
+ "customOAuthRedirectUri": "Redirect URI",
+ "customOAuthRedirectUriHelp": "Registrer denne i OAuth-appen din med regex/mønstertreff – loopback-porten tildeles dynamisk ved innlogging (RFC 8252).",
"oauthConnected": "Tilkoblet via OAuth",
"folderSync": "Synkroniserte mapper",
"manageFolderSync": "Behandle mappesynkronisering",
@@ -519,7 +553,7 @@
"testConnection": "Test tilkobling",
"connectionSuccessful": "Vellykket tilkobling!",
"connectionFailed": "Tilkobling feilet",
- "certificateDeclined": "Sertfikat avvist",
+ "certificateDeclined": "Sertifikat avvist",
"connectionTestFailed": "Tilkoblingstest feilet.",
"saveFailed": "Klarte ikke å lagre konto.",
"addAccount": "Legg til konto",
@@ -555,45 +589,29 @@
"emailRequired": "E-post er påbudt",
"invalidEmail": "Ugyldig e-postadresse",
"passwordRequired": "Passord er påkrevd",
+ "usernameRequired": "Brukernavn er påkrevd",
"imapHostRequired": "IMAP-vert er påkrevd",
"smtpHostRequired": "SMTP-vert er påkrevd",
"invalidPort": "Ugyldig port",
- "trustedCertificates": "Fortrodde sertifikat",
- "trustedCertsHelp": "Manuelt fortrodde TLS-sertifikat for denne kontoen sine servere.",
- "loadingCerts": "Laster inn sertifikat…",
- "noTrustedCerts": "Ingen manuelt fortrodde sertifikat for denne kontoen sine servere.",
- "certFingerprint": "Fingeravtrykk:",
- "certExpires": "Utløpsdato:",
- "removeTrustedCert": "Fjern tiltrodd sertifikat",
- "removeTrustedCertDescription": "Dette sertifikatet vil ikke lenger bli tiltrodd. Fremtidige tilkoblinger til denne tjeneren kan kreve at du aksepterer sertifikatet igjen.",
- "enterPassword": "Skriv inn passord",
- "customOAuthHelp": "Bruk din egen OAuth-app. Skriv inn utsteder-URL-en til tilbyderen din, så oppdager Aerion endepunktene automatisk.",
- "customOAuthIssuer": "Utsteder-URL",
- "customOAuthIssuerHelp": "Grunn-URL-en til tilbyderen din. Aerion leser dens /.well-known/openid-configuration-dokument.",
- "customOAuthDiscover": "Oppdag",
- "customOAuthDiscovered": "Endepunkter oppdaget",
- "customOAuthManual": "Skriv inn endepunkter manuelt",
- "customOAuthAuthUrl": "Autoriseringsendepunkt",
- "customOAuthTokenUrl": "Token-endepunkt",
- "customOAuthUserinfoUrl": "Brukerinfo-endepunkt",
- "customOAuthClientId": "Client ID",
- "customOAuthClientSecret": "Client secret",
- "customOAuthClientSecretPlaceholder": "La stå tom for en offentlig klient (PKCE)",
- "customOAuthScopes": "Scopes",
- "customOAuthScopesHelp": "Atskilt med mellomrom. De fleste tjenere trenger offline_access for en oppdateringstoken.",
- "customOAuthRedirectUri": "Redirect URI",
- "customOAuthRedirectUriHelp": "Registrer denne i OAuth-appen din med regex/mønstertreff — loopback-porten tildeles dynamisk ved innlogging (RFC 8252).",
- "usernameRequired": "Brukernavn er påkrevd",
"noOutgoingServer": "Ingen utgående tjener",
"noOutgoingServerHelp": "Deaktiverer sending for denne kontoen. Adressen vises ikke i Fra-menyen i skrivevinduet. Svar på meldinger mottatt her bruker den valgte identiteten din eller standard sendekonto.",
"replyForwardWith": "Svar/videresend med",
"replyForwardWithHelp": "Identiteten som forhåndsvelges i skrivevinduet når du svarer på eller videresender meldinger mottatt her.",
"replyForwardWithDefault": "Standard sendekonto",
"smtpAuthentication": "SMTP-autentisering",
+ "authMechanism": "Autentiseringsmetode",
"smtpUseSameAsIncoming": "Samme som innkommende tjener",
- "smtpUsernamePlaceholder": "smtp-bruker@eksempel.com",
+ "smtpUsernamePlaceholder": "smtp-bruker@eksempel.no",
"smtpPasswordPlaceholder": "SMTP-passord",
- "smtpCredsNote": "Hvis du angir et annet brukernavn og passord for SMTP, kan du endre det i «SMTP-autentisering»-delen under «Tjener»-fanen."
+ "smtpCredsNote": "Hvis du angir et annet brukernavn og passord for SMTP, kan du endre det i «SMTP-autentisering»-delen under «Tjener»-fanen.",
+ "trustedCertificates": "Fortrodde sertifikat",
+ "trustedCertsHelp": "Manuelt fortrodde TLS-sertifikat for denne kontoen sine servere.",
+ "loadingCerts": "Laster inn sertifikat…",
+ "noTrustedCerts": "Ingen manuelt fortrodde sertifikat for denne kontoen sine servere.",
+ "certFingerprint": "Fingeravtrykk:",
+ "certExpires": "Utløpsdato:",
+ "removeTrustedCert": "Fjern tiltrodd sertifikat",
+ "removeTrustedCertDescription": "Dette sertifikatet vil ikke lenger bli tiltrodd. Fremtidige tilkoblinger til denne tjeneren kan kreve at du aksepterer sertifikatet igjen."
},
"identity": {
"emailAddresses": "E-postadresser",
@@ -690,13 +708,13 @@
"validLabel": "Gyldig:",
"removeCertificate": "Fjern sertifikat",
"recipientCertificates": "Mottakersertifikat",
- "noRecipientCertsHelp": "Ingen mottakersertifikat lagret enda. Sertfikater blir automatisk lagret når du mottar meldinger, eller så kan du importere dem manuelt.",
+ "noRecipientCertsHelp": "Ingen mottakersertifikat lagret enda. Sertifikater blir automatisk lagret når du mottar meldinger, eller så kan du importere dem manuelt.",
"importCertificateTitle": "Importer sertifikat",
"importRecipientCertTitle": "Importer mottakersertifikat",
"importPGPKeyTitle": "Importer PGP-nøkkel",
"importRecipientPGPKeyTitle": "Importer mottakerens PGP-nøkkel",
"fileLabel": "Fil",
- "certificatePassword": "Sertfikatpassord",
+ "certificatePassword": "Sertifikatpassord",
"certificatePasswordPlaceholder": "Skriv in passordet til denne .p12-filen",
"certificatePasswordHelp": "Passordet brukt da sertifikatet var eksportert.",
"recipientEmailAddress": "Mottakerens e-postadresse",
@@ -708,14 +726,14 @@
"enterRecipientEmail": "Vennligst skriv in mottakerens e-postadresse",
"keysCount": "{count} nøkler",
"certsCount": "{count} sertifikat",
- "certImported": "Sertfikat importert ({count} sertifikat i kjede)",
- "certRemoved": "Sertfikat fjernet",
+ "certImported": "Sertifikat importert ({count} sertifikat i kjede)",
+ "certRemoved": "Sertifikat fjernet",
"failedToRemoveCert": "Klarte ikke å fjerne sertifikat",
"defaultCertUpdated": "Standard sertifikat oppdatert",
"failedToSetDefaultCert": "Klarte ikke å angi standard sertifikat",
"failedToUpdateSignPolicy": "Klarte ikke å oppdatere policy for signering",
"failedToUpdateEncryptPolicy": "Klarte ikke å oppdatere policy for kryptering",
- "recipientCertImported": "Sertfikat importert for {email}",
+ "recipientCertImported": "Sertifikat importert for {email}",
"senderCertRemoved": "Avsendersertifikat fjernet",
"failedToRemoveSenderCert": "Klarte ikke å fjerne avsendersertifikat",
"pgpKeyImported": "Vellykket importering av PGP-nøkkel",
@@ -752,6 +770,9 @@
"editSource": "Redigér kontaktkilde",
"deleteTitle": "Slett kontaktkilde",
"deleteConfirmName": "Slett «{name}»? Dette vil fjerne alle synkroniserte kontakter fra denne kilden.",
+ "forceSync": "Tving ny synkronisering",
+ "forceSyncConfirmTitle": "Tving ny synkronisering av «{name}»?",
+ "forceSyncConfirmDescription": "Hent alle kontakter fra «{name}» på nytt fra tjeneren. Dette fyller inn felter (telefon, adresse, organisasjon, notater osv.) som kan mangle for kontakter som opprinnelig ble synkronisert under en eldre Aerion-versjon. Kan ta noen minutter for store adressebøker.",
"name": "Navn",
"namePlaceholder": "f.eks Nextcloud, Arbeid, Personlig",
"serverUrl": "Tjener URL",
@@ -780,6 +801,12 @@
"noAddressbooksFound": "Ingen adressebøker funnet på denne URLen",
"discoveryFailed": "Feilet å finne adressebøker.",
"fillUrlUserPass": "Vennligst fyll inn URL, brukernavn og passord",
+ "fillUrlAccount": "Fyll inn tjener-URL og velg en OAuth-konto",
+ "authentication": "Autentisering",
+ "authPassword": "Passord",
+ "authOAuth": "OAuth-konto",
+ "oauthAccount": "OAuth-konto",
+ "noCustomAccounts": "Ingen egendefinerte OAuth-kontoer. Legg til en e-postkonto med en egendefinert OAuth-tilbyder først, så kan den ligge til grunn for CardDAV her.",
"linkToExisting": "Lenke til en eksisterende {provider}-konto",
"reauthNeeded": "Ny autorisering påkrevd",
"or": "Eller",
@@ -792,15 +819,6 @@
"linked": "tilkoblet",
"autoName": "{name} Kontakter",
"minutesFallback": "{value} minutter",
- "forceSync": "Tving ny synkronisering",
- "forceSyncConfirmTitle": "Tving ny synkronisering av «{name}»?",
- "forceSyncConfirmDescription": "Hent alle kontakter fra «{name}» på nytt fra tjeneren. Dette fyller inn felter (telefon, adresse, organisasjon, notater osv.) som kan mangle for kontakter som opprinnelig ble synkronisert under en eldre Aerion-versjon. Kan ta noen minutter for store adressebøker.",
- "fillUrlAccount": "Fyll inn tjener-URL og velg en OAuth-konto",
- "authentication": "Autentisering",
- "authPassword": "Passord",
- "authOAuth": "OAuth-konto",
- "oauthAccount": "OAuth-konto",
- "noCustomAccounts": "Ingen egendefinerte OAuth-kontoer. Legg til en e-postkonto med en egendefinert OAuth-tilbyder først, så kan den ligge til grunn for CardDAV her.",
"enableWriteAccess": "Aktiver skrivetilgang til denne kilden",
"enableWriteAccessDescription": "Tillat å redigere, opprette og slette kontakter på denne CardDAV-tjeneren. Bruker legitimasjonen ovenfor."
},
@@ -828,6 +846,13 @@
"agreeLabel": "Jeg har lest og godtar vilkårene og personvernspolicyen",
"accept": "Godta & Fortsett"
},
+ "oauthMissing": {
+ "title": "OAuth-legitimasjon mangler",
+ "intro": "Denne versjonen mangler Aerion OAuth-legitimasjon for:",
+ "implication": "Dette betyr at tilbyderne som mangler legitimasjon ikke vil fungere skikkelig. Dette skyldes sannsynligvis at du ikke installerte Aerion fra en offisiell kilde.",
+ "installInstruction": "For å installere Aerion fra en offisiell kilde, se:",
+ "dontShowAgain": "Ikke vis dette igjen"
+ },
"dialog": {
"deletePermanently": "Slett permanent?",
"deleteDescription": "Dette vil slette de valgte meldingene for alltid. Dette kan ikke angres.",
@@ -904,7 +929,9 @@
"selectPresetColor": "Velg {color}",
"backToList": "Tilbake til meldingsliste",
"toggleSidebar": "Veksle sidelinje",
- "closeSidebar": "Lukk sidelinje"
+ "closeSidebar": "Lukk sidelinje",
+ "showAllToasts": "Vis alle varsler",
+ "collapseToasts": "Skjul varsler"
},
"window": {
"minimize": "Minimer",
@@ -939,37 +966,17 @@
},
"oauth": {
"writeAccessPicker": {
- "title": "Enable write access",
- "description": "Choose the {provider} account to authorize for write access on {sourceName}.",
+ "title": "Aktiver skrivetilgang",
+ "description": "Velg {provider}-kontoen å godkjenne skrivetilgang for {sourceName}.",
"providerGoogle": "Google",
"providerMicrosoft": "Microsoft",
- "loadFailed": "Failed to load accounts: {message}",
- "emptyHeading": "No {provider} accounts found.",
- "emptyHint": "Add a {provider} account in Mail (Settings → Accounts) or Contacts (Settings → Contacts → Add source) first, then return here.",
- "cancel": "Cancel",
- "continue": "Continue",
- "successToast": "Write access enabled for {name}",
- "failureToast": "Failed to enable write access: {message}"
+ "loadFailed": "Klarte ikke å laste inn kontoer: {message}",
+ "emptyHeading": "Ingen {provider}-kontoer funnet.",
+ "emptyHint": "Legg til en {provider}-konto i Mail (Innstillinger → Kontoer) eller Kontakter (Innstillinger → Kontakter → Ny kilde) først, deretter returner her.",
+ "cancel": "Avbryt",
+ "continue": "Fortsett",
+ "successToast": "Skrivetilgang aktivert for {name}",
+ "failureToast": "Klarte ikke å aktivere skrivetilgang: {message}"
}
- },
- "spellcheck": {
- "title": "Stavekontroll",
- "enable": "Kontroller stavemåte",
- "enableHelp": "Strek under feilstavede ord mens du skriver.",
- "languages": "Ordbøker",
- "languagesHelp": "Et ord godtas hvis det er gyldig i minst ett av de aktiverte språkene.",
- "addToDictionary": "Legg til i ordboken",
- "ignore": "Ignorer",
- "noSuggestions": "Ingen forslag",
- "addedWords": "Tillagte ord",
- "noAddedWords": "Ingen ord er lagt til ennå.",
- "removeWord": "Fjern ord"
- },
- "oauthMissing": {
- "title": "OAuth-legitimasjon mangler",
- "intro": "Denne versjonen mangler Aerion OAuth-legitimasjon for:",
- "implication": "Dette betyr at tilbyderne som mangler legitimasjon ikke vil fungere skikkelig. Dette skyldes sannsynligvis at du ikke installerte Aerion fra en offisiell kilde.",
- "installInstruction": "For å installere Aerion fra en offisiell kilde, se:",
- "dontShowAgain": "Ikke vis dette igjen"
}
}
diff --git a/frontend/src/lib/i18n/locales/pl.json b/frontend/src/lib/i18n/locales/pl.json
new file mode 100644
index 00000000..7d463ac3
--- /dev/null
+++ b/frontend/src/lib/i18n/locales/pl.json
@@ -0,0 +1,982 @@
+{
+ "spellcheck": {
+ "title": "Sprawdzanie pisowni",
+ "enable": "Sprawdzaj pisownię",
+ "enableHelp": "Podkreślaj błędnie napisane słowa podczas tworzenia wiadomości.",
+ "languages": "Słowniki",
+ "languagesHelp": "Słowo jest uznawane za poprawne, jeśli występuje w dowolnym z włączonych języków.",
+ "addToDictionary": "Dodaj do słownika",
+ "ignore": "Ignoruj",
+ "noSuggestions": "Brak sugestii",
+ "addedWords": "Dodane słowa",
+ "noAddedWords": "Brak jeszcze dodanych słów.",
+ "removeWord": "Usuń słowo"
+ },
+ "common": {
+ "save": "Zapisz",
+ "cancel": "Anuluj",
+ "close": "Zamknij",
+ "delete": "Usuń",
+ "undo": "Cofnij",
+ "retry": "Spróbuj ponownie",
+ "loading": "Ładowanie...",
+ "add": "Dodaj",
+ "edit": "Edytuj",
+ "remove": "Usuń",
+ "search": "Szukaj",
+ "sync": "Synchronizuj",
+ "seconds": "sekund",
+ "ok": "OK",
+ "yes": "Tak",
+ "no": "Nie",
+ "back": "Wstecz",
+ "next": "Dalej",
+ "done": "Gotowe",
+ "confirm": "Potwierdź",
+ "saveChanges": "Zapisz zmiany",
+ "none": "Brak"
+ },
+ "sidebar": {
+ "compose": "Nowa wiadomość",
+ "allInboxes": "Wszystkie skrzynki",
+ "addAccount": "Dodaj konto",
+ "noAccountsYet": "Brak kont",
+ "addFirstAccount": "Dodaj pierwsze konto e-mail aby zacząć",
+ "settings": "Ustawienia",
+ "syncing": "Synchronizowanie...",
+ "offline": "Bez połączenia",
+ "notSynced": "Nie zsynchronizowane",
+ "synced": "Zsynchronizowane {time}",
+ "clickToCancel": "Kliknij aby anulować synchronizację",
+ "syncAllAccounts": "Synchronizuj wszystkie konta",
+ "syncNow": "Synchronizuj teraz",
+ "editAccount": "Edytuj konto",
+ "deleteAccount": "Usuń konto",
+ "syncError": "Błąd synchronizacji. Spróbuj ponownie...",
+ "syncingFolders": "Synchronizowanie folderów...",
+ "fetchingMessageList": "Pobieranie listy wiadomości...",
+ "fetchingHeaders": "Pobieranie nagłówków {percentage}%",
+ "syncingContent": "Synchronizowanie zawartości {percentage}%",
+ "loadingFolders": "Ładowanie folderów...",
+ "noFoldersSynced": "Brak zsynchronizowanych folderów"
+ },
+ "messageList": {
+ "searchMessages": "Szukaj wiadomości...",
+ "noMessages": "Brak wiadomości w tym folderze",
+ "selectFolder": "Wybierz folder aby zobaczyć wiadomości",
+ "emptyTrash": "Opróżnij kosz",
+ "ftsBuilding": "Budowanie indeksu wyszukiwania... ({percentage}%)",
+ "syncFolder": "Synchronizuj folder",
+ "forceResync": "Wymuś ponowną synchronizację",
+ "syncNow": "Synchronizuj teraz",
+ "tryAgain": "Spróbuj ponownie",
+ "unread": "({count} nieprzeczytanych)",
+ "clearSearch": "Wyczyść wyszukiwanie",
+ "noResults": "Brak wyników dla \"{query}\"",
+ "indexBuilding": "Indeks wyszukiwania jest wciąż budowany...",
+ "foundResults": "Znaleziono {count} wynik(ów) dla \"{query}\"",
+ "loadMore": "Załaduj więcej ({remaining} pozostało)",
+ "showingNewest": "Wyświetlanie najnowszych pierwszych",
+ "showingOldest": "Wyświetlanie najstarszych pierwszych",
+ "foundIn": "Znaleziono w {folder}",
+ "encryptedContent": "(Zaszyfrowana zawartość)",
+ "noContent": "(Brak zawartości)",
+ "filter": "Filtruj",
+ "filterLabel": "Filtruj",
+ "filterAll": "Wszystkie wiadomości",
+ "filterUnread": "Nieprzeczytane",
+ "filterStarred": "Oznaczone gwiazdką",
+ "filterAttachments": "Posiada załączniki",
+ "noFilteredMessages": "Żadne wiadomości nie pasują do bieżącego filtra"
+ },
+ "viewer": {
+ "selectConversation": "Wybierz rozmowę do przeczytania",
+ "failedToLoad": "Nie udało się załadować rozmowy",
+ "tryAgain": "Spróbuj ponownie",
+ "noSubject": "(Brak tematu)",
+ "messagesInConversation": "{count} wiadomości w tej rozmowie",
+ "unknown": "Nieznany",
+ "reply": "Odpowiedz",
+ "replyAll": "Odpowiedz wszystkim",
+ "forward": "Przekaż dalej",
+ "archive": "Zarchiwizuj",
+ "delete": "Usuń",
+ "deletePermanently": "Usuń permanentnie",
+ "markAsSpam": "Oznacz jako spam",
+ "markAsNotSpam": "Oznacz jako bez spamu",
+ "star": "Oznacz gwiazdką",
+ "removeStar": "Usuń gwiazdkę",
+ "markAsRead": "Oznacz jako przeczytane",
+ "markAsUnread": "Oznacz jako nieprzeczytane",
+ "expandAll": "Rozwiń wszystko",
+ "collapseAll": "Zwiń wszystko",
+ "print": "Drukuj",
+ "editDraft": "Edytuj szkic",
+ "copyEmail": "Kliknij aby skopiować adres e-mail",
+ "copySource": "Kopiuj źródło",
+ "readReceiptRequested": "Nadawca zażądał potwierdzenia przeczytania.",
+ "sendReceipt": "Wyślij potwierdzenie",
+ "ignoreReceipt": "Ignoruj",
+ "sendingReadReceipt": "Wysyłanie potwierdzenia przeczytania...",
+ "processingSMIME": "Przetwarzanie wiadomości S/MIME...",
+ "smimeEncryptedWith": "Ta wiadomość została zaszyfrowana za pomocą S/MIME",
+ "smimeSignedBy": "Podpisane przez {email} za pomocą S/MIME",
+ "smimeUnknownSigner": "Podpisano za pomocą S/MIME — Nieznany podpisujący ({email})",
+ "smimeSelfSigned": "Podpisano przez {email} za pomocą S/MIME — Certyfikat podpisany przez samego siebie",
+ "smimeExpiredCert":"Podpisano przez {email} za pomocą S/MIME — Certyfikat wygasł",
+ "smimeInvalid": "Podpis S/MIME jest nieprawidłowy",
+ "smimeDecryptFailed": "Odszyfrowanie S/MIME nie powiodło się",
+ "processingPGP": "Przetwarzanie wiadomości PGP...",
+ "pgpEncryptedWith": "Ta wiadomość została zaszyfrowana za pomocą PGP",
+ "pgpSignedBy": "Podpisane przez {email} za pomocą PGP",
+ "pgpExpiredKey": "Podpisane przez {email} za pomocą PGP — Klucz wygasł",
+ "pgpRevokedKey": "Podpisane przez {email} za pomocą PGP — Klucz został odwołany",
+ "pgpUnknownKey": "Nieznany klucz PGP ({keyId})",
+ "pgpInvalid": "Podpis PGP jest nieprawidłowy",
+ "pgpDecryptFailed": "Odszyfrowanie PGP nie powiodło się",
+ "decryptingMessage": "Odszyfrowywanie wiadomości...",
+ "viewSource": "Pokaż źródło",
+ "hideSource": "Ukryj źródło",
+ "loadingSource": "Ładowanie źródła wiadomości...",
+ "deleteConversationTitle": "Usuń permanentnie?",
+ "deleteConversationDescription": "Ta rozmowa zostanie permanentnie usunięta. Tej czynności nie można cofnąć.",
+ "to": "Do",
+ "cc": "DW",
+ "bcc": "UDW",
+ "replyTo": "Odpowiedz do",
+ "from": "Od",
+ "attachments": "Załączniki",
+ "copiedToClipboard": "{label} skopiowany do schowka",
+ "failedToCopy": "Nie udało się skopiować do schowka",
+ "readReceiptSent": "Potwierdzenie przeczytania wysłane",
+ "failedToSendReceipt": "Nie udało się wysłać potwierdzenia przeczytania",
+ "failedToLoadSource": "Nie udało się załadować źródła wiadomości",
+ "failedToLoadMessages": "Nie udało się załadować wiadomości.",
+ "noContent": "Brak zawartości do wyświetlenia",
+ "downloadingContent": "Pobieranie zawartości wiadomości...",
+ "copy": "Kopiuj",
+ "copyLink": "Kopiuj link",
+ "selectAll": "Wybierz wszystko",
+ "remoteImagesBlocked": "Obrazy zdalne są blokowane ze względu na prywatność.",
+ "loadImages": "Załaduj obrazy",
+ "alwaysLoad": "Zawsze ładuj",
+ "forDomain": "Dla domeny {domain}",
+ "forSender": "Dla {email}",
+ "focusThread": "Skupienie na wątku",
+ "focusMessage": "Skupienie na wiadomości",
+ "exitFocus": "Wyjście z trybu skupienia",
+ "darkMailToLight": "Pokaż tę wiadomość bez ciemnego filtra",
+ "darkMailToDark": "Zastosuj ciemny filtr do tej wiadomości"
+ },
+ "composer": {
+ "newMessage": "Nowa wiadomość",
+ "reply": "Odpowiedz",
+ "replyAll": "Odpowiedz wszystkim",
+ "forward": "Przekaż dalej",
+ "editDraft": "Edytuj szkic",
+ "to": "Do",
+ "cc": "DW",
+ "bcc": "UDW",
+ "subject": "Temat",
+ "from": "Od",
+ "send": "Wyślij",
+ "sending": "Wysyłanie...",
+ "addRecipients": "Dodaj adresatów...",
+ "addCcRecipients": "Dodaj adresatów DW...",
+ "addBccRecipients": "Dodaj adresatów UDW...",
+ "selectIdentity": "Wybierz tożsamość",
+ "writePlaceholder": "Wpisz swoją wiadomość...",
+ "openInNewWindow": "Otwórz w nowym oknie",
+ "requestReadReceipt": "Zażądaj potwierdzenia przeczytania",
+ "pgpSign": "Podpisz tę wiadomość PGP",
+ "pgpEncrypt": "Zaszyfruj tę wiadomość PGP",
+ "smimeSign": "Podpisz tę wiadomość S/MIME",
+ "smimeEncrypt": "Zaszyfruj tę wiadomość S/MIME",
+ "closeTitle": "Zamknąć tę wiadomość?",
+ "closeDescription": "Co chciałbyś zrobić z tym szkicem?",
+ "discardDraft": "Odrzuć",
+ "keepEditing": "Kontynuuj edycję",
+ "messageSent": "Wiadomość wysłana",
+ "failedToSend": "Nie udało się wysłać.",
+ "noRecipients": "Dodaj co najmniej jednego adresata",
+ "failedToLoadDraft": "Nie udało się załadować szkicu",
+ "certImported": "Certyfikat zaimportowany dla {email}",
+ "pgpKeyImported": "Klucz PGP zaimportowany dla {email}",
+ "close": "Zamknij",
+ "opening": "Otwieranie...",
+ "attachFiles": "Dołącz pliki",
+ "filesAttached": "{count} plik(ów) dołączony(ch)",
+ "ctrlEnterToSend": "Ctrl+Enter aby wysłać",
+ "dropToAttach": "Upuść pliki aby wstawić lub dołączyć",
+ "flatpakDndTitle": "Przeciąganie i upuszczanie niedostępne",
+ "flatpakDndDescription": "Przeciąganie i upuszczanie nie jest dostępne w wersji Flatpak aplikacji Aerion, chyba że ręcznie przyznasz jej uprawnienia dostępu do systemu plików.",
+ "flatpakDndGrantExample": "Na przykład, aby przyznać dostęp do katalogu domowego, uruchom w terminalu:",
+ "flatpakDndSecurityWarning": "Przyznanie takich uprawnień ma implikacje bezpieczeństwa. Nie przyznawaj uprawnień bez pełnego zrozumienia ich działania.",
+ "flatpakDndAlternative": "Alternatywnie możesz użyć przycisków wstawiania obrazu lub dołączania bez dodatkowej konfiguracji.",
+ "encrypting": "Szyfrowanie...",
+ "saving": "Zapisywanie...",
+ "saveFailed": "Zapis nie powiódł się",
+ "encryptedSynced": "Zaszyfrowana i zsynchronizowana",
+ "encryptedDraft": "Zaszyfrowany szkic",
+ "encryptedOffline": "Zaszyfrowana (offline)",
+ "synced": "Zsynchronizowana",
+ "savedLocally": "Zapisana lokalnie",
+ "savedLocallyOffline": "Zapisana lokalnie (offline)",
+ "failedToInsertImage": "Nie udało się wstawić obrazu",
+ "imageTooLarge": "Obraz jest zbyt duży (maks. 10 MB). Dołącz go jako zwykły załącznik.",
+ "attachmentTooLarge": "Plik jest zbyt duży (maks. 100 MB).",
+ "failedToAttachFiles": "Nie udało się dołączyć plików",
+ "failedToOpenComposer": "Nie udało się otworzyć okna składania",
+ "selectSenderIdentity": "Wybierz tożsamość nadawcy",
+ "cannotEncryptMissingCert": "Nie można zaszyfrować: brakujący certyfikat S/MIME dla {emails}",
+ "cannotEncryptMissingPGPKey": "Nie można zaszyfrować: brakujący klucz PGP dla {emails}",
+ "noCertFor": "Nie można zaszyfrować: brak certyfikatu S/MIME dla {emails}",
+ "noPGPKeyFor": "Nie można zaszyfrować: brak klucza PGP dla {emails}",
+ "import": "Zaimportuj",
+ "sign": "Podpisz",
+ "encrypt": "Zaszyfruj",
+ "securityModeHint": "S = podpisz · E = zaszyfruj · Esc = wyjście",
+ "emptySubjectTitle": "Wysłać bez tematu?",
+ "emptySubjectDescription": "Ta wiadomość nie ma linii tematu. Czy na pewno chcesz ją wysłać?",
+ "missingAttachmentTitle": "Brakujący załącznik?",
+ "missingAttachmentDescription": "Twoja wiadomość wspomina o załączeniu, ale żadne pliki nie są dołączone. Czy chcesz wysłać mimo to?",
+ "saveAndClose": "Zapisz i zamknij",
+ "failedToImportCert": "Nie udało się zaimportować certyfikatu",
+ "failedToImportPGPKey": "Nie udało się zaimportować klucza PGP",
+ "sendAnywayGeneric": "Wyślij mimo to"
+ },
+ "contextMenu": {
+ "reply": "Odpowiedz",
+ "replyAll": "Odpowiedz wszystkim",
+ "forward": "Przekaż dalej",
+ "archive": "Zarchiwizuj",
+ "delete": "Usuń",
+ "deletePermanently": "Usuń permanentnie",
+ "markAsSpam": "Oznacz jako spam",
+ "markAsNotSpam": "Oznacz jako bez spamu",
+ "moveTo": "Przenieś do",
+ "copyTo": "Skopiuj do",
+ "star": "Oznacz gwiazdką",
+ "removeStar": "Usuń gwiazdkę",
+ "markAsRead": "Oznacz jako przeczytane",
+ "markAsUnread": "Oznacz jako nieprzeczytane",
+ "markAllAsRead": "Oznacz wszystko jako przeczytane",
+ "markAllAsUnread": "Oznacz wszystko jako nieprzeczytane",
+ "noFoldersAvailable": "Brak dostępnych folderów",
+ "searchFolders": "Szukaj folderów...",
+ "account": "Konto"
+ },
+ "toast": {
+ "archived": "Zarchiwizowano",
+ "conversationArchived": "Rozmowa zarchiwizowana",
+ "movedToTrash": "Przeniesiono do kosza",
+ "deletedFromFolder": "Usunięto z folderu",
+ "permanentlyDeleted": "Permanentnie usunięto",
+ "trashEmptied": "Kosz opróżniony",
+ "markedAsRead": "Oznaczono jako przeczytane",
+ "markedAsUnread": "Oznaczono jako nieprzeczytane",
+ "markedAsSpam": "Oznaczono jako spam",
+ "markedAsNotSpam": "Oznaczono jako bez spamu",
+ "starred": "Oznaczono gwiazdką",
+ "starRemoved": "Usunięto gwiazdkę",
+ "removedStar": "Usunięto gwiazdkę",
+ "markedAllAsRead": "Oznaczono wszystko jako przeczytane",
+ "markedAllAsUnread": "Oznaczono wszystko jako nieprzeczytane",
+ "settingsSaved": "Ustawienia zapisane",
+ "undone": "Cofnięto: {description}",
+ "contactSourceSynced": "Źródło kontaktu zsynchronizowane",
+ "contactSourceForceSynced": "Źródło kontaktów zsynchronizowano ponownie z serwera",
+ "contactSourceForceSyncFailed": "Wymuszenie ponownej synchronizacji nie powiodło się",
+ "contactSourceDeleted": "Źródło kontaktu usunięte",
+ "contactSourceAdded": "Źródło kontaktu dodane",
+ "contactSourceUpdated": "Źródło kontaktu zaktualizowane",
+ "contactSourceLinked": "Źródło kontaktu powiązane z kontem",
+ "contactSourceCreated": "Źródło kontaktu utworzone",
+ "failedToArchive": "Nie udało się zarchiwizować.",
+ "failedToDelete": "Nie udało się usunąć.",
+ "failedToEmptyTrash": "Nie udało się opróżnić kosza.",
+ "failedToMarkAsRead": "Nie udało się oznaczyć jako przeczytane.",
+ "failedToMarkAsUnread": "Nie udało się oznaczyć jako nieprzeczytane.",
+ "failedToMarkAsSpam": "Nie udało się oznaczyć jako spam.",
+ "failedToMarkAsNotSpam": "Nie udało się oznaczyć jako bez spamu.",
+ "failedToMove": "Nie udało się przenieść.",
+ "failedToCopy": "Nie udało się skopiować.",
+ "failedToUpdateStar": "Nie udało się zaktualizować gwiazdki.",
+ "failedToUpdateReadStatus": "Nie udało się zaktualizować statusu przeczytania.",
+ "failedToMarkAllAsRead": "Nie udało się oznaczyć wszystkiego jako przeczytane.",
+ "failedToMarkAllAsUnread": "Nie udało się oznaczyć wszystkiego jako nieprzeczytane.",
+ "failedToSaveSettings": "Nie udało się zapisać ustawień.",
+ "failedToSave": "Nie udało się zapisać.",
+ "undoFailed": "Cofnięcie nie powiodło się.",
+ "failedToPrepare": "Nie udało się przygotować {mode}. Otwieranie pustego składacza.",
+ "noAccountConfigured": "Brak skonfigurowanego konta e-mail. Najpierw dodaj konto.",
+ "movedTo": "Przeniesiono do {folder}",
+ "copyingTo": "Kopiowanie do {folder}...",
+ "attachmentSaved": "Zapisano \"{filename}\"",
+ "attachmentsSaved": "Zapisano {count} załączników",
+ "failedToSaveAttachment": "Nie udało się zapisać \"{filename}\"",
+ "failedToOpenAttachment": "Nie udało się otworzyć \"{filename}\"",
+ "failedToSaveAttachments": "Nie udało się zapisać załączników",
+ "mainWindowClosing": "Główne okno się zamyka. Twój szkic zostanie zapisany.",
+ "syncFailed": "Synchronizacja nie powiodła się.",
+ "failedToDeleteContactSource": "Nie udało się usunąć źródła kontaktu.",
+ "oauthFailed": "Uwierzytelnianie OAuth nie powiodło się.",
+ "failedToStartOAuth": "Nie udało się uruchomić OAuth.",
+ "fillRequiredFields": "Wypełnij wszystkie wymagane pola",
+ "passwordRequired": "Hasło jest wymagane dla nowych źródeł",
+ "selectAddressbook": "Wybierz co najmniej jedną książkę adresową",
+ "selectOAuthAccount": "Wybierz konto OAuth",
+ "linkAccountOrSignIn": "Powiąż konto lub zaloguj się",
+ "accountSaved": "Ustawienia konta zapisane",
+ "failedToSaveAccount": "Nie udało się zapisać konta.",
+ "accountEmailExists": "Konto z tym adresem e-mail już istnieje",
+ "reauthorized": "{name} pomyślnie ponownie autoryzowany! Synchronizowanie...",
+ "syncCompleted": "Synchronizacja {name} ukończona",
+ "failedToReauthorize": "Nie udało się ponownie autoryzować konta.",
+ "oauthExpired": "{name}: Token OAuth wygasł. Ponownie autoryzuj w ustawieniach konta.",
+ "failedToDeleteIdentity": "Nie udało się usunąć tożsamości.",
+ "linkOpened": "Link otwarty w przeglądarce"
+ },
+ "settings": {
+ "title": "Ustawienia",
+ "description": "Skonfiguruj preferencje aplikacji",
+ "general": "Ogólne",
+ "accounts": "Konta",
+ "contacts": "Kontakty",
+ "about": "O aplikacji",
+ "extensions": "Rozszerzenia",
+ "extensionsCoreHeading": "Rdzeń",
+ "extensionsCoreDescription": "Podstawowe funkcje aplikacji. Nie można wyłączyć.",
+ "extensionsEnabled": "Włączone",
+ "extensionsDisabled": "Wyłączone",
+ "extensionsEnable": "Włącz",
+ "extensionsDisable": "Wyłącz",
+ "extensionsCapabilities": "Uprawnienia",
+ "extensionsVersion": "Wersja",
+ "extensionsNone": "Brak zainstalowanych rozszerzeń",
+ "images": "Obrazy",
+ "composer": "Składacz",
+ "composerMode": "Domyślny tryb tworzenia",
+ "composerModeInline": "W oknie",
+ "composerModeDetached": "Oddzielne okno",
+ "composerModeDescription": "Wybierz, czy tworzenie otwiera się w głównym oknie czy w osobnym oknie",
+ "mailtoMode": "Zewnętrzne linki mailto",
+ "mailtoModeDescription": "Wybierz, jak obsługiwane są linki mailto z innych aplikacji",
+ "composerFormat": "Domyślny format wiadomości",
+ "composerFormatRich": "Tekst sformatowany",
+ "composerFormatPlain": "Zwykły tekst",
+ "composerFormatDescription": "Nowe wiadomości będą domyślnie używać tego formatu. Możesz zmienić go dla każdej wiadomości."
+ },
+ "settingsGeneral": {
+ "display": "Wyświetlanie",
+ "titleBar": "Pasek tytułu",
+ "titleBarHelp": "Wybierz styl paska tytułu (natywny wymaga ponownego uruchomienia)",
+ "titleBarAerion": "Aerion",
+ "titleBarAerionDesc": "Użyj paska tytułu aplikacji",
+ "titleBarNative": "Natywny",
+ "titleBarNativeDesc": "Użyj natywnego paska tytułu systemu operacyjnego (Wymaga ponownego uruchomienia)",
+ "titleBarDisable": "Wyłącz",
+ "titleBarDisableDesc": "Wyłącz dla mniej zaśmiecenia interfejsu",
+ "language": "Język",
+ "theme": "Motyw",
+ "themeHelp": "Wybierz jasny, ciemny lub śledź preferencę systemu",
+ "selectTheme": "Wybierz motyw",
+ "messageListDensity": "Gęstość listy wiadomości",
+ "messageListDensityHelp": "Dostosuj odstępy i rozmiar tekstu w liście wiadomości",
+ "showMessageListCircles": "Pokaż kolorowe kółka na liście wiadomości",
+ "showMessageListCirclesHelp": "Pokaż kolorowe kółka z inicjałami nadawcy na liście wiadomości",
+ "showViewerCircles": "Pokaż kolorowe kółka w przeglądzie rozmowy",
+ "showViewerCirclesHelp": "Pokaż kolorowe kółka z inicjałami nadawcy w przeglądzie rozmowy",
+ "showMessageListProfilePics": "Zdjęcia profilowe na liście wiadomości",
+ "showMessageListProfilePicsHelp": "Pokazuj awatary nadawców na liście wiadomości.",
+ "accentBarUnread": "Pasek akcentu dla nieprzeczytanych wiadomości",
+ "accentBarUnreadHelp": "Pokazuje pasek po lewej stronie nieprzeczytanych wiadomości dla łatwiejszej identyfikacji.",
+ "darkMailContent": "Ciemna zawartość wiadomości",
+ "darkMailContentHelp": "Zastosuj ciemny filtr do zawartości wiadomości e-mail, gdy Aerion jest w trybie ciemnym.",
+ "darkComposerBody": "Ciemne tło treści w edytorze",
+ "darkComposerBodyHelp": "Użyj ciemnego tła w oknie tworzenia wiadomości przy ciemnym motywie.",
+ "selectDensity": "Wybierz gęstość",
+ "readReceipts": "Potwierdzenia przeczytania",
+ "readReceiptPolicy": "Gdy ktoś zażąda potwierdzenia przeczytania",
+ "readReceiptPolicyHelp": "Potwierdzenia przeczytania informują nadawcę, gdy przeczytałeś jego wiadomość",
+ "selectPolicy": "Wybierz politykę",
+ "neverSendReceipts": "Nigdy nie wysyłaj potwierdzeń przeczytania",
+ "askEachTime": "Pytaj każdorazowo",
+ "alwaysSendReceipts": "Zawsze wysyłaj potwierdzenia przeczytania",
+ "markAsRead": "Oznacz wiadomości jako przeczytane",
+ "markAsReadAfter": "Oznacz jako przeczytane po",
+ "markAsReadHelp": "-1 = ręcznie, 0 = natychmiast, 0.1-5 = opóźnienie w sekundach",
+ "background": "Tło",
+ "runInBackground": "Uruchamiaj w tle",
+ "runInBackgroundHelp": "Utrzymuj Aerion uruchomiony, gdy okno jest zamknięte",
+ "startHidden": "Uruchom ukryte",
+ "startHiddenHelp": "Uruchom z ukrytym oknem",
+ "startup": "Uruchamianie",
+ "autostartOnLogin": "Automatyczne uruchomienie przy logowaniu",
+ "autostartHelp": "Automatycznie uruchom Aerion po zalogowaniu",
+ "densityMicro": "Mikro",
+ "densityCompact": "Kompaktowa",
+ "densityStandard": "Standardowa",
+ "densityLarge": "Duża",
+ "themeSystem": "Systemowy",
+ "themeLight": "Jasny",
+ "themeLightBlue": "Jasny (Niebieski)",
+ "themeLightOrange": "Jasny (Pomarańczowy)",
+ "themeLightBalanced": "Jasny (Zbalansowany)",
+ "themeDark": "Ciemny",
+ "themeDarkGray": "Ciemny (Szary)",
+ "themeDarkBalanced": "Ciemny (Zbalansowany)",
+ "restartRequired": "Wymagane ponowne uruchomienie",
+ "restartRequiredDescription": "Zmiana paska tytułu zacznie obowiązywać po ponownym uruchomieniu Aerionu.",
+ "quitNow": "Zamknij teraz",
+ "restartLater": "Później",
+ "alwaysLoadImages": "Zawsze ładuj obrazy zdalne",
+ "alwaysLoadImagesHelp": "Wyłącza blokowanie obrazów dla wszystkich wiadomości e-mail. Obrazy zdalne będą ładować się automatycznie.",
+ "alwaysLoadImagesWarningTitle": "Zagrożenie prywatności i bezpieczeństwa",
+ "alwaysLoadImagesWarningDescription": "Automatyczne ładowanie obrazów zdalnych może ujawnić Twój adres IP, potwierdzić, gdy przeczytasz e-mail i umożliwić śledzenie przez nadawców. Czy na pewno chcesz wyłączyć blokowanie obrazów?",
+ "alwaysShowCheckbox": "Zawsze pokazuj pole wyboru",
+ "alwaysShowCheckboxHelp": "Pokazuj pole wyboru nawet gdy nie ma wiadomości",
+ "disable": "Wyłącz"
+ },
+ "settingsAccounts": {
+ "emailAccounts": "Konta e-mail",
+ "noAccountsConfigured": "Brak skonfigurowanych kont e-mail",
+ "addAccount": "Dodaj konto",
+ "moveUp": "Przenieś w górę",
+ "moveDown": "Przenieś w dół",
+ "editAccount": "Edytuj konto",
+ "deleteAccount": "Usuń konto"
+ },
+ "settingsAbout": {
+ "version": "Wersja {version}",
+ "github": "GitHub",
+ "privacyPolicy": "Polityka prywatności",
+ "termsOfUse": "Warunki użytkowania",
+ "failedToLoad": "Nie udało się załadować informacji aplikacji"
+ },
+ "account": {
+ "addTitle": "Dodaj konto e-mail",
+ "editTitle": "Edytuj konto",
+ "editSharedMailboxTitle": "Edytuj udostępnioną skrzynkę",
+ "addDescription": "Połącz swoje konto e-mail, aby zacząć odbierać wiadomości.",
+ "editDescription": "Zarządzaj ustawieniami konta e-mail, tożsamościami i podpisami.",
+ "general": "Ogólne",
+ "identity": "Tożsamość",
+ "server": "Serwer",
+ "security": "Bezpieczeństwo",
+ "accountName": "Nazwa konta",
+ "accountNamePlaceholder": "np. Osobiste, Praca",
+ "colorHelp": "Kolor jest używany do identyfikacji tego konta w ujednoliconej skrzynce",
+ "displayName": "Domyślna nazwa wyświetlana",
+ "displayNamePlaceholder": "np. Jan Kowalski",
+ "displayNameHelp": "Nazwa wyświetlana odbiorcom e-maila (można dostosować dla każdego adresu e-mail w karcie Tożsamość)",
+ "emailAddress": "Adres e-mail",
+ "emailReadOnly": "Główny adres e-mail (nie można zmieniać)",
+ "username": "Nazwa użytkownika",
+ "usernamePlaceholder": "Zwykle Twój adres e-mail",
+ "credentials": "Poświadczenia",
+ "accountIdentification": "Identyfikacja konta",
+ "password": "Hasło",
+ "enterAppPassword": "Wpisz hasło aplikacji",
+ "enterPassword": "Wprowadź hasło",
+ "reauthorize": "Ponownie autoryzuj",
+ "authentication": "Uwierzytelnianie",
+ "customOAuthHelp": "Użyj własnego dostawcy OAuth2 (np. Stalwart) zamiast wbudowanych.",
+ "customOAuthIssuer": "Issuer (URL)",
+ "customOAuthIssuerHelp": "Bazowy adres URL wystawcy OIDC, np. https://mail.example.com",
+ "customOAuthDiscover": "Wykryj automatycznie",
+ "customOAuthDiscovered": "Wykryto ustawienia OAuth",
+ "customOAuthManual": "Konfiguracja ręczna",
+ "customOAuthAuthUrl": "Adres URL autoryzacji",
+ "customOAuthTokenUrl": "Adres URL tokenu",
+ "customOAuthUserinfoUrl": "Adres URL userinfo",
+ "customOAuthClientId": "Identyfikator klienta (Client ID)",
+ "customOAuthClientSecret": "Sekret klienta (Client Secret)",
+ "customOAuthClientSecretPlaceholder": "Wklej sekret klienta",
+ "customOAuthScopes": "Zakresy (scopes)",
+ "customOAuthScopesHelp": "Zakresy oddzielone spacją, wymagane przez serwer.",
+ "customOAuthRedirectUri": "Redirect URI",
+ "customOAuthRedirectUriHelp": "Musi być zarejestrowany u dostawcy OAuth.",
+ "oauthConnected": "Połączony za pośrednictwem OAuth",
+ "folderSync": "Subskrypcje synchronizacji folderów",
+ "manageFolderSync": "Zarządzaj synchronizacją folderów",
+ "manageFolderSyncHelp": "Po włączeniu wybierz, które foldery synchronizują się automatycznie. Po wyłączeniu synchronizują się tylko Odbiór, Szkice i Wysłane.",
+ "syncAllFolders": "Synchronizuj wszystkie foldery",
+ "alwaysSynced": "zawsze synchronizowane",
+ "folderMapping": "Mapowanie folderów",
+ "neverRequest": "Nigdy nie żądaj",
+ "askEachTime": "Pytaj każdorazowo",
+ "alwaysRequest": "Zawsze żądaj",
+ "folderSent": "Wysłane",
+ "folderDrafts": "Szkice",
+ "folderTrash": "Kosz",
+ "folderSpam": "Spam/Niechciany",
+ "folderArchive": "Archiwum",
+ "folderAllMail": "Wszystkie wiadomości",
+ "folderStarred": "Oznaczone gwiazdką",
+ "deleteTitle": "Usuń konto",
+ "deleteConfirm": "Czy na pewno chcesz usunąć {name} ({email})?",
+ "deleteWarning": "Spowoduje to trwałe usunięcie wszystkich lokalnych wiadomości, folderów i ustawień dla tego konta. Twoje wiadomości e-mail na serwerze nie będą dotknięte.",
+ "deleteAccount": "Usuń konto",
+ "chooseProvider": "Wybierz swojego dostawcę e-maila",
+ "chooseProviderHelp": "Wybierz dostawcę do automatycznej konfiguracji",
+ "changeProvider": "Zmień dostawcę",
+ "advancedSettings": "Ustawienia zaawansowane",
+ "incomingMail": "Poczta przychodząca (IMAP)",
+ "outgoingMail": "Poczta wychodząca (SMTP)",
+ "port": "Port",
+ "syncPeriod": "Okres synchronizacji",
+ "syncPeriodHelp": "Jak daleko wstecz synchronizować wiadomości",
+ "sync1week": "1 tydzień",
+ "sync2weeks": "2 tygodnie",
+ "sync1month": "1 miesiąc",
+ "sync2months": "2 miesiące",
+ "sync3months": "3 miesiące",
+ "sync6months": "6 miesięcy",
+ "sync1year": "1 rok",
+ "syncAllMessages": "Wszystkie wiadomości",
+ "manualOnly": "Tylko ręcznie",
+ "every5Min": "Co 5 minut",
+ "every15Min": "Co 15 minut",
+ "every30Min": "Co 30 minut",
+ "everyHour": "Co godzinę",
+ "checkNewMail": "Sprawdzaj nowe wiadomości",
+ "checkNewMailHelp": "Jak często sprawdzać nowe wiadomości (używany jest też push IDLE, gdy dostępny)",
+ "requestReadReceipts": "Żądaj potwierdzeń przeczytania",
+ "requestReadReceiptsHelp": "Kiedy żądać potwierdzeń przeczytania dla wiadomości wychodzących",
+ "folderMappingHelp2": "Mapuj typy folderów do konkretnych folderów IMAP na swoim serwerze.",
+ "saveAccountFirst": "(najpierw zapisz konto)",
+ "loadingFolders": "Ładowanie folderów...",
+ "noFoldersAvailable": "Brak dostępnych folderów.",
+ "detected": "(wykryte)",
+ "none": "Brak",
+ "testConnection": "Testuj połączenie",
+ "connectionSuccessful": "Połączenie pomyślne!",
+ "connectionFailed": "Połączenie nie powiodło się",
+ "certificateDeclined": "Certyfikat odrzucony",
+ "connectionTestFailed": "Test połączenia nie powiódł się.",
+ "saveFailed": "Nie udało się zapisać konta.",
+ "addAccount": "Dodaj konto",
+ "appPassword": "Hasło aplikacji",
+ "signIn": "Zaloguj się",
+ "signInWith": "Zaloguj się za pomocą {provider}",
+ "redirectToSignIn": "Zostaniesz przekierowany, aby bezpiecznie się zalogować",
+ "waitingForAuth": "Oczekiwanie na uwierzytelnianie...",
+ "completeSignIn": "Dokończ logowanie w przeglądarce",
+ "linkCopied": "Skopiowano",
+ "connectedSuccessfully": "Pomyślnie połączono",
+ "authFailed": "Uwierzytelnianie nie powiodło się",
+ "tryAgain": "Spróbuj ponownie",
+ "leaveEmptyToKeep": "Pozostaw puste aby zachować aktualne",
+ "notesGoogle": "Zaloguj się za pośrednictwem Google lub użyj hasła aplikacji",
+ "notesYahoo": "Wymaga hasła aplikacji (najpierw włącz weryfikację dwuetapową)",
+ "notesIcloud": "Wymaga hasła specyficznego dla aplikacji z appleid.apple.com",
+ "notesProton": "Wymaga ProtonMail Bridge uruchomionego lokalnie",
+ "notesFastmail": "Użyj hasła aplikacji z Ustawienia > Prywatność i bezpieczeństwo",
+ "notesAppPassword": "Wymaga hasła aplikacji",
+ "syncSettings": "Ustawienia synchronizacji",
+ "syncOptions": "Opcje synchronizacji",
+ "oauthReauthorized": "Pomyślnie ponownie autoryzowany!",
+ "oauthFreshToken": "Twoje konto ma świeży token OAuth",
+ "oauthSecurelyConnected": "Twoje konto jest bezpiecznie połączone",
+ "authorizing": "Autoryzowanie...",
+ "reauthorizeHelp": "Jeśli masz problemy z synchronizacją, spróbuj ponownie autoryzować, aby uzyskać świeży token",
+ "signInAgainHelp": "Zaloguj się ponownie, aby odświeżyć poświadczenia, jeśli potrzeba",
+ "usernameHelp": "Pozostaw puste aby użyć adresu e-mail",
+ "pleaseCompleteSignIn": "Proszę dokończyć proces logowania",
+ "accountNameRequired": "Nazwa konta jest wymagana",
+ "displayNameRequired": "Nazwa wyświetlana jest wymagana",
+ "emailRequired": "E-mail jest wymagany",
+ "invalidEmail": "Nieprawidłowy format e-maila",
+ "passwordRequired": "Hasło jest wymagane",
+ "usernameRequired": "Nazwa użytkownika jest wymagana",
+ "imapHostRequired": "Host IMAP jest wymagany",
+ "smtpHostRequired": "Host SMTP jest wymagany",
+ "invalidPort": "Nieprawidłowy port",
+ "noOutgoingServer": "Brak serwera wychodzącego",
+ "noOutgoingServerHelp": "Nie konfiguruj SMTP — tylko odbieranie poczty.",
+ "replyForwardWith": "Odpowiadaj / przekaż jako",
+ "replyForwardWithHelp": "Której tożsamości używać przy odpowiedzi i przekazywaniu.",
+ "replyForwardWithDefault": "Domyślna tożsamość",
+ "smtpAuthentication": "Uwierzytelnianie SMTP",
+ "authMechanism": "Mechanizm uwierzytelniania",
+ "smtpUseSameAsIncoming": "Takie same jak dla poczty przychodzącej",
+ "smtpUsernamePlaceholder": "Nazwa użytkownika SMTP",
+ "smtpPasswordPlaceholder": "Hasło SMTP",
+ "smtpCredsNote": "Osobne dane logowania do SMTP",
+ "trustedCertificates": "Zaufane certyfikaty",
+ "trustedCertsHelp": "Ręcznie zaufane certyfikaty TLS dla serwerów tego konta.",
+ "loadingCerts": "Ładowanie certyfikatów...",
+ "noTrustedCerts": "Brak ręcznie zaufanych certyfikatów dla serwerów tego konta.",
+ "certFingerprint": "Odcisk:",
+ "certExpires": "Wygasa:",
+ "removeTrustedCert": "Usuń zaufany certyfikat",
+ "removeTrustedCertDescription": "Ten certyfikat nie będzie już zaufany. Przyszłe połączenia do tego serwera mogą wymagać ponownego zaakceptowania certyfikatu."
+ },
+ "identity": {
+ "emailAddresses": "Adresy e-mail",
+ "emailAddressesHelp": "Zarządzaj adresami e-mail, z których możesz wysyłać, i ich podpisami",
+ "addEmailAddress": "Dodaj adres e-mail",
+ "noEmailAddresses": "Brak skonfigurowanych adresów e-mail",
+ "defaultAddress": "Domyślny adres e-mail",
+ "setAsDefaultAddress": "Ustaw jako domyślne",
+ "default": "Domyślny",
+ "noSignature": "Brak podpisu",
+ "emptySignature": "Pusty podpis",
+ "cannotDeleteDefault": "Nie można usunąć domyślnego adresu e-mail",
+ "emailUpdated": "Adres e-mail zaktualizowany",
+ "emailAdded": "Adres e-mail dodany",
+ "emailDeleted": "Adres e-mail usunięty",
+ "isNowDefault": "{email} jest teraz domyślnym",
+ "failedToLoadAddresses": "Nie udało się załadować adresów e-mail",
+ "failedToSetDefault": "Nie udało się ustawić domyślnego adresu e-mail",
+ "defaultHelp": "Kliknij na kółko, aby ustawić adres e-mail jako domyślny dla nowych wiadomości. Domyślnego adresu nie można usunąć.",
+ "cannotDeleteDefaultTitle": "Nie można usunąć domyślnego",
+ "editEmailTitle": "Edytuj adres e-mail",
+ "addEmailTitle": "Dodaj adres e-mail",
+ "editEmailDescription": "Zaktualizuj ten adres e-mail i ustawienia jego podpisu.",
+ "addEmailDescription": "Dodaj nowy adres e-mail, z którego możesz wysyłać.",
+ "emailAddressLabel": "Adres e-mail",
+ "displayNameLabel": "Nazwa wyświetlana",
+ "displayNameHelp": "Nazwa wyświetlana odbiorcom e-maila",
+ "emailRequired": "Adres e-mail jest wymagany",
+ "invalidEmailFormat": "Nieprawidłowy format e-maila",
+ "displayNameRequired": "Nazwa wyświetlana jest wymagana",
+ "useSignature": "Użyj podpisu",
+ "htmlSignature": "Podpis HTML",
+ "signatureHelp": "Możesz wkleić zawartość HTML lub dodać obrazy za pomocą adresu URL lub przesyłania pliku",
+ "plainTextSignature": "Podpis w zwykłym tekście (opcjonalnie)",
+ "generateFromHtml": "Utwórz na podstawie HTML",
+ "plainTextHelp": "Używane przy wysyłaniu wiadomości w zwykłym tekście",
+ "appendSignatureTo": "Dołącz podpis do:",
+ "newMessages": "Nowych wiadomości",
+ "replies": "Odpowiedzi",
+ "forwards": "Przekazy",
+ "signaturePlacementLabel": "Umieszczenie podpisu w odpowiedziach/przekazach:",
+ "aboveQuotedText": "Nad cytowanym tekstem",
+ "belowQuotedText": "Pod cytowanym tekstem",
+ "addSeparator": "Dodaj linię separacyjną przed podpisem",
+ "saveIdentityChanges": "Zapisz zmiany",
+ "saveFailed": "Nie udało się zapisać tożsamości.",
+ "addEmailAddressButton": "Dodaj adres e-mail",
+ "sharedMailboxes": "Udostępnione skrzynki",
+ "sharedMailboxesHelp": "Dodaj udostępnione skrzynki Microsoft 365, do których masz dostęp.",
+ "addSharedMailbox": "Dodaj udostępnioną skrzynkę",
+ "noSharedMailboxes": "Brak skonfigurowanych udostępnionych skrzynek.",
+ "sharedMailboxEmail": "E-mail udostępnionej skrzynki",
+ "sharedMailboxDisplayName": "Nazwa wyświetlana",
+ "sharedMailboxAdded": "Udostępniona skrzynka dodana pomyślnie.",
+ "sharedMailboxRemoved": "Udostępniona skrzynka usunięta.",
+ "failedToAddSharedMailbox": "Nie udało się dodać udostępnionej skrzynki",
+ "failedToDeleteSharedMailbox": "Nie udało się usunąć udostępnionej skrzynki."
+ },
+ "security": {
+ "smime": "S/MIME",
+ "pgp": "PGP",
+ "yourKeys": "Twoje klucze",
+ "importSecretKey": "Importuj klucz tajny",
+ "noPGPKeysHelp": "Brak zaimportowanych kluczy PGP. Importuj plik klucza ASCII-armored (.asc) lub binarny (.gpg), aby włączyć podpisywanie i szyfrowanie PGP.",
+ "defaultBadge": "Domyślny",
+ "expiredBadge": "Wygasły",
+ "selfSignedBadge": "Samo-podpisany",
+ "created": "Utworzono:",
+ "expires": "Wygasa:",
+ "setAsDefault": "Ustaw jako domyślne",
+ "removeKey": "Usuń klucz",
+ "signingPolicy": "Polityka podpisywania",
+ "encryptionPolicy": "Polityka szyfrowania",
+ "neverSignByDefault": "Nigdy nie podpisuj domyślnie",
+ "alwaysSignByDefault": "Zawsze podpisuj domyślnie",
+ "neverEncryptByDefault": "Nigdy nie szyfruj domyślnie",
+ "alwaysEncryptByDefault": "Zawsze szyfruj domyślnie",
+ "policyOverrideHint": "Możesz zmienić to dla każdej wiadomości w składacu.",
+ "encryptRequiresRecipientKeys": "Możesz zmienić to dla każdej wiadomości w składacu. Szyfrowanie wymaga publicznych kluczy odbiorcy.",
+ "encryptRequiresRecipientCerts": "Możesz zmienić to dla każdej wiadomości w składacu. Szyfrowanie wymaga certyfikatów odbiorcy.",
+ "keyServersLabel": "SERWERY KLUCZY",
+ "keyServersHelp": "Dodaj serwery kluczy do wyszukiwania kluczy PGP odbiorcy. Serwery są przeszukiwane w kolejności podczas wyszukiwania kluczy.",
+ "removeServer": "Usuń serwer",
+ "addButton": "Dodaj",
+ "recipientKeys": "Klucze odbiorcy",
+ "importButton": "Importuj",
+ "searchByEmail": "Wyszukaj po e-mailu (WKD + serwery kluczy)...",
+ "noRecipientPGPKeysHelp": "Brak zebranych kluczy odbiorcy. Klucze są automatycznie zapisywane, gdy odbierasz podpisane wiadomości PGP, odkryte za pośrednictwem WKD lub serwerów kluczy, lub importowane ręcznie.",
+ "removeButton": "Usuń",
+ "yourCertificates": "Twoje certyfikaty",
+ "importP12": "Importuj .p12",
+ "noSMIMECertsHelp": "Brak zaimportowanych certyfikatów. Importuj certyfikat PKCS#12 (.p12/.pfx), aby włączyć podpisywanie wiadomości.",
+ "issuerLabel": "Wystawca:",
+ "validLabel": "Ważny:",
+ "removeCertificate": "Usuń certyfikat",
+ "recipientCertificates": "Certyfikaty odbiorcy",
+ "noRecipientCertsHelp": "Brak zebranych certyfikatów odbiorcy. Certyfikaty są automatycznie zapisywane, gdy odbierasz podpisane wiadomości, lub można je importować ręcznie.",
+ "importCertificateTitle": "Importuj certyfikat",
+ "importRecipientCertTitle": "Importuj certyfikat odbiorcy",
+ "importPGPKeyTitle": "Importuj klucz PGP",
+ "importRecipientPGPKeyTitle": "Importuj klucz PGP odbiorcy",
+ "fileLabel": "Plik",
+ "certificatePassword": "Hasło certyfikatu",
+ "certificatePasswordPlaceholder": "Wpisz hasło do tego pliku .p12",
+ "certificatePasswordHelp": "Hasło użyte podczas eksportowania certyfikatu.",
+ "recipientEmailAddress": "Adres e-mail odbiorcy",
+ "recipientEmailHelp": "Adres e-mail osoby, do której należy ten certyfikat.",
+ "recipientEmailHelpKey": "Adres e-mail osoby, do której należy ten klucz.",
+ "keyPassphrase": "Hasło dostępu klucza (opcjonalnie)",
+ "keyPassphrasePlaceholder": "Wpisz hasło dostępu, jeśli klucz jest zaszyfrowany",
+ "keyPassphraseHelp": "Pozostaw puste, jeśli klucz prywatny nie jest chroniony hasłem dostępu.",
+ "enterRecipientEmail": "Proszę wpisać adres e-mail odbiorcy",
+ "keysCount": "{count} klucze",
+ "certsCount": "{count} certyfikaty",
+ "certImported": "Certyfikat zaimportowany ({count} certyfikaty w łańcuchu)",
+ "certRemoved": "Certyfikat usunięty",
+ "failedToRemoveCert": "Nie udało się usunąć certyfikatu",
+ "defaultCertUpdated": "Domyślny certyfikat zaktualizowany",
+ "failedToSetDefaultCert": "Nie udało się ustawić domyślnego certyfikatu",
+ "failedToUpdateSignPolicy": "Nie udało się zaktualizować polityki podpisywania",
+ "failedToUpdateEncryptPolicy": "Nie udało się zaktualizować polityki szyfrowania",
+ "recipientCertImported": "Certyfikat zaimportowany dla {email}",
+ "senderCertRemoved": "Certyfikat nadawcy usunięty",
+ "failedToRemoveSenderCert": "Nie udało się usunąć certyfikatu nadawcy",
+ "pgpKeyImported": "Klucz PGP pomyślnie zaimportowany",
+ "pgpKeyRemoved": "Klucz PGP usunięty",
+ "failedToRemovePGPKey": "Nie udało się usunąć klucza PGP",
+ "defaultPGPKeyUpdated": "Domyślny klucz PGP zaktualizowany",
+ "failedToSetDefaultPGPKey": "Nie udało się ustawić domyślnego klucza PGP",
+ "failedToUpdatePGPSignPolicy": "Nie udało się zaktualizować polityki podpisywania PGP",
+ "failedToUpdatePGPEncryptPolicy": "Nie udało się zaktualizować polityki szyfrowania PGP",
+ "recipientPGPKeyImported": "Klucz PGP zaimportowany dla {email}",
+ "recipientPGPKeyRemoved": "Klucz PGP odbiorcy usunięty",
+ "failedToRemoveRecipientPGPKey": "Nie udało się usunąć klucza PGP odbiorcy",
+ "keyServerAdded": "Serwer kluczy dodany",
+ "failedToAddKeyServer": "Nie udało się dodać serwera kluczy",
+ "keyServerRemoved": "Serwer kluczy usunięty",
+ "failedToRemoveKeyServer": "Nie udało się usunąć serwera kluczy",
+ "pgpKeyFound": "Klucz PGP znaleziony i zaimportowany dla {email}",
+ "pgpKeyNotFound": "Brak klucza PGP dla {email}",
+ "keyLookupFailed": "Wyszukiwanie kluczy nie powiodło się",
+ "certEmailMismatch": "E-mail certyfikatu nie pasuje do tego konta lub żadnego z jego aliasów.",
+ "keyEmailMismatch": "E-mail klucza nie pasuje do tego konta lub żadnego z jego aliasów.",
+ "certImportFailed": "Nie udało się zaimportować certyfikatu. Sprawdź format pliku i hasło.",
+ "keyImportFailed": "Nie udało się zaimportować klucza. Sprawdź format pliku i hasło dostępu.",
+ "recipientCertImportFailed": "Nie udało się zaimportować certyfikatu odbiorcy. Sprawdź format pliku.",
+ "recipientKeyImportFailed": "Nie udało się zaimportować klucza odbiorcy. Sprawdź format pliku.",
+ "berConversionTitle": "Starszy format certyfikatu",
+ "berConversionMessage": "Ten certyfikat używa starszego kodowania BER, które wymaga konwersji do standardowego formatu DER przed importem. Konwersja nie powoduje utraty danych i nie zmienia danych certyfikatu. Czy chcesz kontynuować?",
+ "berConversionConfirm": "Konwertuj i importuj"
+ },
+ "contactSource": {
+ "title": "Źródła kontaktów",
+ "noSources": "Brak skonfigurowanych źródeł kontaktów",
+ "addSource": "Dodaj źródło kontaktu",
+ "editSource": "Edytuj źródło kontaktu",
+ "deleteTitle": "Usuń źródło kontaktu",
+ "deleteConfirmName": "Usunąć \"{name}\"? Spowoduje to usunięcie wszystkich zsynchronizowanych kontaktów z tego źródła.",
+ "forceSync": "Wymuś ponowną synchronizację",
+ "forceSyncConfirmTitle": "Wymusić ponowną synchronizację źródła „{name}”?",
+"forceSyncConfirmDescription": "Ponownie pobierz wszystkie kontakty z „{name}” z serwera. Uzupełni brakujące pola (telefon, adres, organizacja, notatki itd.), które mogły nie zostać zsynchronizowane w starszych wersjach Aerion. Może to potrwać kilka minut w przypadku dużych książek adresowych.",
+ "name": "Nazwa",
+ "namePlaceholder": "np. Nextcloud, Praca, Osobiste",
+ "serverUrl": "Adres URL serwera",
+ "username": "Nazwa użytkownika",
+ "password": "Hasło",
+ "syncInterval": "Interwał synchronizacji",
+ "selectInterval": "Wybierz interwał",
+ "manualOnly": "Tylko ręcznie",
+ "every15Min": "Co 15 minut",
+ "every30Min": "Co 30 minut",
+ "everyHour": "Co godzinę",
+ "every2Hours": "Co 2 godziny",
+ "every6Hours": "Co 6 godzin",
+ "daily": "Codziennie",
+ "discovering": "Odkrywanie...",
+ "syncFailed": "Synchronizacja nie powiodła się",
+ "neverSynced": "Nigdy nie zsynchronizowane",
+ "syncedAgo": "zsynchronizowane {time}",
+ "sourceName": "Nazwa źródła kontaktu",
+ "description": "Synchronizuj kontakty dla automatycznego uzupełniania e-maila",
+ "serverUrlHelp": "Wpisz adres URL serwera lub pełny adres URL książki adresowej CardDAV",
+ "passwordKeepCurrent": "Hasło (pozostaw puste aby zachować aktualne)",
+ "connectDiscover": "Połącz i odkryj",
+ "reDiscover": "Odkryj na nowo książki adresowe",
+ "addressbooksToSync": "Książki adresowe do synchronizacji",
+ "noAddressbooksFound": "Brak książek adresowych znalezionych na tym adresie URL",
+ "discoveryFailed": "Odkrywanie nie powiodło się.",
+ "fillUrlUserPass": "Wypełnij adres URL, nazwę użytkownika i hasło",
+ "fillUrlAccount": "Wypełnij z konta",
+ "authentication": "Uwierzytelnianie",
+ "authPassword": "Hasło",
+ "authOAuth": "OAuth",
+ "oauthAccount": "Konto OAuth",
+ "noCustomAccounts": "Brak kont z własnym OAuth",
+ "linkToExisting": "Powiąż z istniejącym kontem {provider}",
+ "reauthNeeded": "Wymagana ponowna autoryzacja",
+ "or": "Lub",
+ "signInDifferent": "Zaloguj się za pomocą innego konta",
+ "signInProvider": "Zaloguj się za pomocą {provider}",
+ "waitingForSignIn": "Oczekiwanie na logowanie... Sprawdź przeglądarkę",
+ "signedInAs": "Zalogowany jako",
+ "change": "Zmień",
+ "update": "Zaktualizuj",
+ "linked": "powiązane",
+ "autoName": "Kontakty {name}",
+ "minutesFallback": "{value} minut",
+ "enableWriteAccess": "Włącz zapis",
+ "enableWriteAccessDescription": "Pozwól aplikacji tworzyć i edytować kontakty w tym źródle."
+ },
+ "certificate": {
+ "title": "Niezaufany certyfikat",
+ "description": "Serwer przedstawił certyfikat, który nie mógł być zweryfikowany.",
+ "subject": "Temat:",
+ "issuer": "Wystawca:",
+ "fingerprint": "Odcisk:",
+ "validPeriod": "Ważny:",
+ "dnsNames": "Nazwy DNS:",
+ "reason": "Powód:",
+ "expired": "(wygasł)",
+ "to": "do",
+ "decline": "Odrzuć",
+ "acceptOnce": "Zaakceptuj raz",
+ "acceptAlways": "Zaakceptuj zawsze"
+ },
+ "terms": {
+ "title": "Warunki użytkowania i polityka prywatności",
+ "description": "Proszę przejrzeć i zaakceptować nasze warunki, aby kontynuować korzystanie z Aerionu.",
+ "content": "Dokumenty te wyjaśniają, jak aplikacja obsługuje Twoje dane i warunki, na których możesz korzystać z tego oprogramowania.",
+ "privacyPolicy": "Polityka prywatności",
+ "termsOfUse": "Warunki użytkowania",
+ "agreeLabel": "Przeczytałem i zgadzam się z Warunkami użytkowania i polityką prywatności",
+ "accept": "Zaakceptuj i kontynuuj"
+ },
+ "oauthMissing": {
+ "title": "Brak obsługi OAuth",
+ "intro": "W systemie brakuje portalu pulpitu wymaganego do logowania OAuth.",
+ "implication": "Bez tego logowanie OAuth (Google/Microsoft) nie będzie działać.",
+ "installInstruction": "Zainstaluj xdg-desktop-portal i uruchom ponownie sesję graficzną.",
+ "dontShowAgain": "Nie pokazuj ponownie"
+ },
+ "dialog": {
+ "deletePermanently": "Usunąć permanentnie?",
+ "deleteDescription": "Spowoduje to trwałe usunięcie wybranych wiadomości. Tej czynności nie można cofnąć.",
+ "emptyTrash": "Opróżnić kosz?",
+ "emptyTrashDescription": "Spowoduje to permanentne usunięcie wszystkich wiadomości w folderze Kosz. Tej czynności nie można cofnąć.",
+ "confirmDeletePermanently": "Usuń permanentnie",
+ "confirmEmptyTrash": "Opróżnij kosz"
+ },
+ "date": {
+ "justNow": "właśnie teraz",
+ "yesterday": "Wczoraj",
+ "todayAt": "Dzisiaj o {time}",
+ "yesterdayAt": "Wczoraj o {time}"
+ },
+ "editor": {
+ "bold": "Pogrubienie (Ctrl+B)",
+ "italic": "Kursywa (Ctrl+I)",
+ "underline": "Podkreślenie (Ctrl+U)",
+ "strikethrough": "Przekreślenie",
+ "textColor": "Kolor tekstu",
+ "customColor": "Kolor niestandardowy",
+ "fontSize": "Rozmiar czcionki",
+ "alignLeft": "Wyrównaj do lewej",
+ "alignCenter": "Wyrównaj do środka",
+ "alignRight": "Wyrównaj do prawej",
+ "bulletList": "Lista punktowa",
+ "numberedList": "Lista numerowana",
+ "quote": "Cytat",
+ "insertLink": "Wstaw link",
+ "insertImage": "Wstaw obraz",
+ "reset": "Przywróć domyślnie",
+ "richText": "Tekst sformatowany",
+ "plainText": "Zwykły tekst",
+ "switchToRichText": "Przełącz na tekst sformatowany",
+ "switchToPlainText": "Przełącz na zwykły tekst",
+ "insertImageUrl": "Wstaw obraz z adresu URL",
+ "insertImageFile": "Wstaw obraz z pliku",
+ "removeLink": "Usuń link",
+ "insertTable": "Wstaw tabelę",
+ "deleteTable": "Usuń tabelę",
+ "htmlMode": "Edytuj źródło HTML",
+ "visualMode": "Edytor wizualny"
+ },
+ "attachment": {
+ "open": "Otwórz",
+ "download": "Pobierz",
+ "openFile": "Otwórz plik",
+ "showInFolder": "Pokaż w folderze",
+ "openFolder": "Otwórz folder",
+ "removeAttachment": "Usuń załącznik",
+ "saving": "Zapisywanie...",
+ "saveAll": "Zapisz wszystkie ({count})",
+ "inline": "(osadzony)",
+ "flatpakOpenTitle": "Ograniczony dostęp do plików",
+ "flatpakOpenDescription": "Otwieranie pliku z powiadomienia nie działa prawidłowo w Flatpak. Aby ta funkcja działała prawidłowo, przyznaj Aerionowi dostęp do katalogu domowego, uruchamiając następujące polecenie w terminalu:",
+ "flatpakOpenSecurityWarning": "Przyznanie takich uprawnień ma implikacje bezpieczeństwa. Nie przyznawaj uprawnień bez pełnego zrozumienia ich działania.",
+ "flatpakOpenAlternative": "Alternatywnie, bez żadnej dodatkowej konfiguracji, możesz kliknąć \"Pokaż w folderze\", aby pokazać folder, w którym znajduje się plik, abyś mógł go stamtąd otworzyć."
+ },
+ "aria": {
+ "windowControls": "Przyciski okna",
+ "minimizeWindow": "Minimalizuj okno",
+ "maximizeWindow": "Maksymalizuj okno",
+ "restoreWindow": "Przywróć okno",
+ "closeWindow": "Zamknij okno",
+ "resizeSidebar": "Zmień rozmiar paska bocznego",
+ "resizeMessageList": "Zmień rozmiar listy wiadomości",
+ "emailComposer": "Edytor wiadomości",
+ "textFormatting": "Formatowanie tekstu",
+ "dismiss": "Odrzuć",
+ "selectColor": "Wybierz kolor",
+ "emailContent": "Zawartość e-maila",
+ "closeContextMenu": "Zamknij menu kontekstowe",
+ "signatureEditor": "Edytor podpisu",
+ "selectPresetColor": "Wybierz {color}",
+ "backToList": "Powrót do listy wiadomości",
+ "toggleSidebar": "Przełącz pasek boczny",
+ "closeSidebar": "Zamknij pasek boczny",
+ "showAllToasts": "Pokaż wszystkie powiadomienia",
+ "collapseToasts": "Zwiń powiadomienia"
+ },
+ "window": {
+ "minimize": "Minimalizuj",
+ "maximize": "Maksymalizuj",
+ "restore": "Przywróć",
+ "close": "Zamknij",
+ "closeWindow": "Zamknij okno",
+ "shuttingDown": "Zamykanie aplikacji..."
+ },
+ "search": {
+ "server": "Serwer",
+ "searchOnServer": "Szukaj na serwerze",
+ "serverResults": "Znaleziono {count} wyników na serwerze dla \"{query}\"",
+ "serverSearch": "Wyszukiwanie serwera",
+ "localSearch": "Wyszukiwanie lokalne",
+ "serverSearching": "Wyszukiwanie na serwerze...",
+ "notSyncedLocally": "(nie zsynchronizowane lokalnie)",
+ "serverResultsCapped": "Pokazywanie {shown} z {total} wyników dla \"{query}\"",
+ "showAllResults": "Pokaż wszystkie {total} wyniki"
+ },
+ "responsive": {
+ "back": "Wstecz",
+ "folders": "Foldery"
+ },
+ "images": {
+ "addresses": "Adresy",
+ "domains": "Domeny",
+ "noAddresses": "Brak adresów na liście zaufanych. Użyj \"Zawsze ładuj > Dla nadawcy\" w przeglądarce e-maila, aby dodać adresy.",
+ "noDomains": "Brak domen na liście zaufanych. Użyj \"Zawsze ładuj > Dla domeny\" w przeglądarce e-maila, aby dodać domeny.",
+ "removeButton": "Usuń z listy zaufanych",
+ "removed": "Usunięto z listy zaufanych"
+ },
+ "oauth": {
+ "writeAccessPicker": {
+ "title": "Włącz możliwość zapisu",
+ "description": "Wybierz konto {provider}, któremu chcesz przyznać uprawnienia do zapisu w źródle {sourceName}.",
+ "providerGoogle": "Google",
+ "providerMicrosoft": "Microsoft",
+ "loadFailed": "Nie udało się wczytać kont: {message}",
+ "emptyHeading": "Brak kont {provider}.",
+ "emptyHint": "Najpierw dodaj konto {provider} w Poczcie (Ustawienia → Konta) lub w Kontaktach (Ustawienia → Kontakty → Dodaj źródło), a następnie wróć tutaj.",
+ "cancel": "Anuluj",
+ "continue": "Kontynuuj",
+ "successToast": "Włączono uprawnienia do zapisu dla {name}",
+ "failureToast": "Nie udało się włączyć uprawnień do zapisu: {message}"
+ }
+ }
+}
diff --git a/frontend/src/lib/i18n/locales/vi.json b/frontend/src/lib/i18n/locales/vi.json
index feb95849..2b54c3c9 100644
--- a/frontend/src/lib/i18n/locales/vi.json
+++ b/frontend/src/lib/i18n/locales/vi.json
@@ -393,6 +393,8 @@
"darkMailContentHelp": "Áp dụng bộ lọc tối cho nội dung thư khi Aerion ở chế độ tối.",
"darkComposerBody": "Nền tối cho trình soạn thảo",
"darkComposerBodyHelp": "Dùng nền tối cho nội dung soạn thư khi Aerion ở chế độ tối.",
+ "alwaysShowCheckbox": "Luôn hiện ô chọn cạnh thư",
+ "alwaysShowCheckboxHelp": "Giữ một cột ô chọn cố định trong danh sách thư thay vì chỉ hiện khi di chuột hoặc vuốt.",
"selectDensity": "Chọn mật độ",
"readReceipts": "Xác nhận đã đọc",
"readReceiptPolicy": "Khi ai đó yêu cầu xác nhận đã đọc",
@@ -595,6 +597,7 @@
"replyForwardWithHelp": "Tài khoản được chọn sẵn trong cửa sổ soạn thư khi trả lời hoặc chuyển tiếp thư nhận ở đây.",
"replyForwardWithDefault": "Tài khoản gửi mặc định",
"smtpAuthentication": "Xác thực SMTP",
+ "authMechanism": "Phương thức xác thực",
"smtpUseSameAsIncoming": "Giống máy chủ nhận thư",
"smtpUsernamePlaceholder": "nguoi-dung-smtp@example.com",
"smtpPasswordPlaceholder": "Mật khẩu SMTP",
@@ -924,7 +927,9 @@
"selectPresetColor": "Chọn {color}",
"backToList": "Quay lại danh sách thư",
"toggleSidebar": "Bật/tắt thanh bên",
- "closeSidebar": "Đóng thanh bên"
+ "closeSidebar": "Đóng thanh bên",
+ "showAllToasts": "Hiển thị tất cả thông báo",
+ "collapseToasts": "Thu gọn thông báo"
},
"window": {
"minimize": "Thu nhỏ",
diff --git a/frontend/src/lib/i18n/locales/zh-CN.json b/frontend/src/lib/i18n/locales/zh-CN.json
index fc351e5d..3b1d1a88 100644
--- a/frontend/src/lib/i18n/locales/zh-CN.json
+++ b/frontend/src/lib/i18n/locales/zh-CN.json
@@ -393,6 +393,8 @@
"darkMailContentHelp": "当 Aerion 处于深色模式时,对邮件内容应用深色滤镜。",
"darkComposerBody": "编辑器背景深色化",
"darkComposerBodyHelp": "当 Aerion 处于深色模式时,为编辑器正文使用深色背景。",
+ "alwaysShowCheckbox": "始终显示邮件旁的复选框",
+ "alwaysShowCheckboxHelp": "在邮件列表中保留固定的复选框栏位,而不是在悬停或滑动时才显示。",
"selectDensity": "选择密度",
"readReceipts": "回执",
"readReceiptPolicy": "当有人请求回执时",
@@ -595,6 +597,7 @@
"replyForwardWithHelp": "回复或转发在此收到的邮件时,在撰写邮件中预先选择的身份。",
"replyForwardWithDefault": "默认发送账户",
"smtpAuthentication": "SMTP 身份验证",
+ "authMechanism": "身份验证方式",
"smtpUseSameAsIncoming": "与收件服务器相同",
"smtpUsernamePlaceholder": "smtp-user@example.com",
"smtpPasswordPlaceholder": "SMTP 密码",
@@ -924,7 +927,9 @@
"selectPresetColor": "选择 {color}",
"backToList": "返回邮件列表",
"toggleSidebar": "切换侧边栏",
- "closeSidebar": "关闭侧边栏"
+ "closeSidebar": "关闭侧边栏",
+ "showAllToasts": "显示全部通知",
+ "collapseToasts": "收起通知"
},
"window": {
"minimize": "最小化",
diff --git a/frontend/src/lib/i18n/locales/zh-HK.json b/frontend/src/lib/i18n/locales/zh-HK.json
index ff1725d7..57c5fea0 100644
--- a/frontend/src/lib/i18n/locales/zh-HK.json
+++ b/frontend/src/lib/i18n/locales/zh-HK.json
@@ -393,6 +393,8 @@
"darkMailContentHelp": "當 Aerion 處於深色模式時,對郵件內容套用深色濾鏡。",
"darkComposerBody": "編輯器背景深色化",
"darkComposerBodyHelp": "當 Aerion 處於深色模式時,為編輯器正文使用深色背景。",
+ "alwaysShowCheckbox": "永遠顯示郵件旁的核取方塊",
+ "alwaysShowCheckboxHelp": "在郵件列表中保留固定的核取方塊欄位,而不是在懸停或滑動時才顯示。",
"selectDensity": "選擇密度",
"readReceipts": "讀取回條",
"readReceiptPolicy": "當有人要求讀取回條時",
@@ -595,6 +597,7 @@
"replyForwardWithHelp": "回覆或轉寄在此收到的郵件時,在撰寫郵件中預先選擇的身分。",
"replyForwardWithDefault": "預設寄件帳戶",
"smtpAuthentication": "SMTP 身分驗證",
+ "authMechanism": "身分驗證方式",
"smtpUseSameAsIncoming": "與收件伺服器相同",
"smtpUsernamePlaceholder": "smtp-user@example.com",
"smtpPasswordPlaceholder": "SMTP 密碼",
@@ -924,7 +927,9 @@
"selectPresetColor": "選擇 {color}",
"backToList": "返回郵件列表",
"toggleSidebar": "切換側邊欄",
- "closeSidebar": "關閉側邊欄"
+ "closeSidebar": "關閉側邊欄",
+ "showAllToasts": "顯示全部通知",
+ "collapseToasts": "收起通知"
},
"window": {
"minimize": "最小化",
diff --git a/frontend/src/lib/i18n/locales/zh-TW.json b/frontend/src/lib/i18n/locales/zh-TW.json
index 7fa564fa..46a4a393 100644
--- a/frontend/src/lib/i18n/locales/zh-TW.json
+++ b/frontend/src/lib/i18n/locales/zh-TW.json
@@ -393,6 +393,8 @@
"darkMailContentHelp": "當 Aerion 處於深色模式時,對郵件內容套用深色濾鏡。",
"darkComposerBody": "編輯器背景深色化",
"darkComposerBodyHelp": "當 Aerion 處於深色模式時,為編輯器正文使用深色背景。",
+ "alwaysShowCheckbox": "永遠顯示郵件旁的核取方塊",
+ "alwaysShowCheckboxHelp": "在郵件列表中保留固定的核取方塊欄位,而不是在懸停或滑動時才顯示。",
"selectDensity": "選擇密度",
"readReceipts": "讀取回條",
"readReceiptPolicy": "當有人要求讀取回條時",
@@ -595,6 +597,7 @@
"replyForwardWithHelp": "回覆或轉寄在此收到的郵件時,在撰寫郵件中預先選擇的身分。",
"replyForwardWithDefault": "預設寄件帳號",
"smtpAuthentication": "SMTP 身分驗證",
+ "authMechanism": "身分驗證方式",
"smtpUseSameAsIncoming": "與收件伺服器相同",
"smtpUsernamePlaceholder": "smtp-user@example.com",
"smtpPasswordPlaceholder": "SMTP 密碼",
@@ -924,7 +927,9 @@
"selectPresetColor": "選擇 {color}",
"backToList": "返回郵件清單",
"toggleSidebar": "切換側邊欄",
- "closeSidebar": "關閉側邊欄"
+ "closeSidebar": "關閉側邊欄",
+ "showAllToasts": "顯示全部通知",
+ "collapseToasts": "收起通知"
},
"window": {
"minimize": "最小化",
diff --git a/frontend/src/lib/keyboard/shortcuts.ts b/frontend/src/lib/keyboard/shortcuts.ts
index 193d5ca3..4870521f 100644
--- a/frontend/src/lib/keyboard/shortcuts.ts
+++ b/frontend/src/lib/keyboard/shortcuts.ts
@@ -43,12 +43,23 @@ export const LIST_PREV_CHECK = (e: KeyboardEvent): boolean =>
e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey &&
(e.key === 'K' || e.key === 'k' || e.key === 'ArrowUp')
+// Jump to first / last item in a list — g / Shift+G (vim-style)
+export const LIST_FIRST = (e: KeyboardEvent): boolean =>
+ e.key === 'g' && noMods(e)
+
+export const LIST_LAST = (e: KeyboardEvent): boolean =>
+ e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 'G' || e.key === 'g')
+
export const LIST_TOGGLE_CHECK = (e: KeyboardEvent): boolean =>
e.key === ' ' && noMods(e)
export const LIST_OPEN = (e: KeyboardEvent): boolean =>
e.key === 'Enter' && noMods(e)
+// V — open/view the focused list item (alias of Enter, mail parity)
+export const LIST_VIEW = (e: KeyboardEvent): boolean =>
+ e.key === 'v' && noMods(e)
+
export const LIST_SELECT_ALL = (e: KeyboardEvent): boolean =>
e.key.toLowerCase() === 'a' && ctrlOrMeta(e) && !e.shiftKey
@@ -72,20 +83,41 @@ export const SIDEBAR_NEXT = (e: KeyboardEvent): boolean =>
export const SIDEBAR_PREV = (e: KeyboardEvent): boolean =>
altOnly(e) && (e.key === 'k' || e.key === 'ArrowUp')
+// Jump to first / last visible sidebar item — Alt+G / Alt+Shift+G
+export const SIDEBAR_FIRST = (e: KeyboardEvent): boolean =>
+ altOnly(e) && e.key === 'g'
+
+export const SIDEBAR_LAST = (e: KeyboardEvent): boolean =>
+ altOnly(e) && e.shiftKey && (e.key === 'G' || e.key === 'g')
+
+// Move/Copy folder-picker dialog for the focused message-list row — Alt+M / Alt+C
+export const LIST_MOVE_TO = (e: KeyboardEvent): boolean =>
+ altOnly(e) && e.key === 'm'
+
+export const LIST_COPY_TO = (e: KeyboardEvent): boolean =>
+ altOnly(e) && e.key === 'c'
+
// Convenience: namespace export for predicates that the kit imports as a group.
export const KEY = {
LIST_NEXT,
LIST_PREV,
LIST_NEXT_CHECK,
LIST_PREV_CHECK,
+ LIST_FIRST,
+ LIST_LAST,
LIST_TOGGLE_CHECK,
LIST_OPEN,
+ LIST_VIEW,
LIST_SELECT_ALL,
LIST_DELETE,
PANE_FOCUS_NEXT,
PANE_FOCUS_PREV,
SIDEBAR_NEXT,
SIDEBAR_PREV,
+ SIDEBAR_FIRST,
+ SIDEBAR_LAST,
+ LIST_MOVE_TO,
+ LIST_COPY_TO,
}
// matchesAny is a small helper for cases where several predicates should be
diff --git a/frontend/src/lib/stores/keyboard.svelte.ts b/frontend/src/lib/stores/keyboard.svelte.ts
index 2fce20aa..10a822e8 100644
--- a/frontend/src/lib/stores/keyboard.svelte.ts
+++ b/frontend/src/lib/stores/keyboard.svelte.ts
@@ -145,6 +145,9 @@ export function createKeyboardState() {
export interface PaneNavTarget {
navigateNext?: () => void
navigatePrev?: () => void
+ /** Jump to the first / last item (Alt+G / Alt+Shift+G). */
+ navigateFirst?: () => void
+ navigateLast?: () => void
activate?: () => void
/** Move keyboard focus to this pane's search input (Ctrl+S). */
focusSearch?: () => void
diff --git a/frontend/src/lib/stores/settings.svelte.ts b/frontend/src/lib/stores/settings.svelte.ts
index eacb114f..b9b431ca 100644
--- a/frontend/src/lib/stores/settings.svelte.ts
+++ b/frontend/src/lib/stores/settings.svelte.ts
@@ -2,7 +2,7 @@
// Provides reactive state for application settings
// @ts-ignore - wailsjs path
-import { GetMessageListDensity, GetMessageListSortOrder, GetThemeMode, GetShowTitleBar, GetRunBackground, GetStartHidden, GetAutostart, GetLanguage, GetComposerMode, GetMailtoMode, GetComposerFormat, GetNativeTitleBar, GetAlwaysLoadImages, GetDarkMailContent, GetDarkComposerBody, GetAccentBarUnread, GetShowMessageListCircles, GetShowMessageListProfilePics, GetShowViewerCircles, GetSpellcheckEnabled, GetSpellcheckLanguages, GetSpellcheckCustomWords } from '../../../wailsjs/go/app/App'
+import { GetMessageListDensity, GetMessageListSortOrder, GetThemeMode, GetShowTitleBar, GetRunBackground, GetStartHidden, GetAutostart, GetLanguage, GetComposerMode, GetMailtoMode, GetComposerFormat, GetNativeTitleBar, GetAlwaysLoadImages, GetDarkMailContent, GetDarkComposerBody, GetAccentBarUnread, GetShowMessageListCircles, GetShowMessageListProfilePics, GetAlwaysShowMessageCheckbox, GetShowViewerCircles, GetSpellcheckEnabled, GetSpellcheckLanguages, GetSpellcheckCustomWords } from '../../../wailsjs/go/app/App'
import { setLocale as setI18nLocale } from '$lib/i18n'
import { loadDateFnsLocale, getDateFnsLocale } from '$lib/i18n/dateFnsLocale'
import type { Locale } from 'date-fns'
@@ -44,6 +44,7 @@ let darkComposerBody = $state(false)
let accentBarUnread = $state(false)
let showMessageListCircles = $state(true)
let showMessageListProfilePics = $state(false)
+let alwaysShowMessageCheckbox = $state(false)
let showViewerCircles = $state(true)
// Getter functions to access the state
@@ -131,6 +132,10 @@ export function getShowMessageListProfilePics(): boolean {
return showMessageListProfilePics
}
+export function getAlwaysShowMessageCheckbox(): boolean {
+ return alwaysShowMessageCheckbox
+}
+
export function getShowViewerCircles(): boolean {
return showViewerCircles
}
@@ -228,6 +233,10 @@ export function setShowMessageListProfilePics(v: boolean) {
showMessageListProfilePics = v
}
+export function setAlwaysShowMessageCheckbox(v: boolean) {
+ alwaysShowMessageCheckbox = v
+}
+
export function setShowViewerCircles(v: boolean) {
showViewerCircles = v
}
@@ -235,7 +244,7 @@ export function setShowViewerCircles(v: boolean) {
// Load settings from backend (call on app startup)
export async function loadSettings(): Promise {
try {
- const [density, sortOrder, theme, titleBar, runBg, startHid, autoSt, lang, compMode, mailMode, compFormat, nativeTB, alwaysImages, darkMail, darkComposer, accentBar, listCircles, listProfilePics, viewerCircles, scEnabled, scLangs, scWords] = await Promise.all([
+ const [density, sortOrder, theme, titleBar, runBg, startHid, autoSt, lang, compMode, mailMode, compFormat, nativeTB, alwaysImages, darkMail, darkComposer, accentBar, listCircles, listProfilePics, alwaysCheckbox, viewerCircles, scEnabled, scLangs, scWords] = await Promise.all([
GetMessageListDensity(),
GetMessageListSortOrder(),
GetThemeMode(),
@@ -254,6 +263,7 @@ export async function loadSettings(): Promise {
GetAccentBarUnread(),
GetShowMessageListCircles(),
GetShowMessageListProfilePics(),
+ GetAlwaysShowMessageCheckbox(),
GetShowViewerCircles(),
GetSpellcheckEnabled(),
GetSpellcheckLanguages(),
@@ -276,6 +286,7 @@ export async function loadSettings(): Promise {
accentBarUnread = accentBar ?? false
showMessageListCircles = listCircles ?? true
showMessageListProfilePics = listProfilePics ?? false
+ alwaysShowMessageCheckbox = alwaysCheckbox ?? false
showViewerCircles = viewerCircles ?? true
spellcheckEnabled = scEnabled ?? true
spellcheckLanguages = scLangs ?? []
diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts
index 55a28532..c68ca013 100755
--- a/frontend/wailsjs/go/app/App.d.ts
+++ b/frontend/wailsjs/go/app/App.d.ts
@@ -213,6 +213,8 @@ export function GetAllAccountIdentities():Promise;
+export function GetAlwaysShowMessageCheckbox():Promise;
+
export function GetAppInfo():Promise;
export function GetAttachment(arg1:string):Promise;
@@ -549,6 +551,8 @@ export function SetAddressbookEnabled(arg1:string,arg2:boolean):Promise;
export function SetAlwaysLoadImages(arg1:boolean):Promise;
+export function SetAlwaysShowMessageCheckbox(arg1:boolean):Promise;
+
export function SetAutostart(arg1:boolean):Promise;
export function SetComposerFormat(arg1:string):Promise;
@@ -653,7 +657,7 @@ export function TestConnection(arg1:account.AccountConfig):Promise;
-export function TestSMTPConnection(arg1:string,arg2:number,arg3:string,arg4:string,arg5:string):Promise;
+export function TestSMTPConnection(arg1:string,arg2:number,arg3:string,arg4:string,arg5:string,arg6:string):Promise;
export function Trash(arg1:Array):Promise;
diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js
index cbf9cce7..6a77983f 100755
--- a/frontend/wailsjs/go/app/App.js
+++ b/frontend/wailsjs/go/app/App.js
@@ -390,6 +390,10 @@ export function GetAlwaysLoadImages() {
return window['go']['app']['App']['GetAlwaysLoadImages']();
}
+export function GetAlwaysShowMessageCheckbox() {
+ return window['go']['app']['App']['GetAlwaysShowMessageCheckbox']();
+}
+
export function GetAppInfo() {
return window['go']['app']['App']['GetAppInfo']();
}
@@ -1062,6 +1066,10 @@ export function SetAlwaysLoadImages(arg1) {
return window['go']['app']['App']['SetAlwaysLoadImages'](arg1);
}
+export function SetAlwaysShowMessageCheckbox(arg1) {
+ return window['go']['app']['App']['SetAlwaysShowMessageCheckbox'](arg1);
+}
+
export function SetAutostart(arg1) {
return window['go']['app']['App']['SetAutostart'](arg1);
}
@@ -1270,8 +1278,8 @@ export function TestOAuthConnection(arg1) {
return window['go']['app']['App']['TestOAuthConnection'](arg1);
}
-export function TestSMTPConnection(arg1, arg2, arg3, arg4, arg5) {
- return window['go']['app']['App']['TestSMTPConnection'](arg1, arg2, arg3, arg4, arg5);
+export function TestSMTPConnection(arg1, arg2, arg3, arg4, arg5, arg6) {
+ return window['go']['app']['App']['TestSMTPConnection'](arg1, arg2, arg3, arg4, arg5, arg6);
}
export function Trash(arg1) {
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 9007dc78..c450effb 100755
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -8,9 +8,11 @@ export namespace account {
imapHost: string;
imapPort: number;
imapSecurity: string;
+ imapAuthMechanism: string;
smtpHost: string;
smtpPort: number;
smtpSecurity: string;
+ smtpAuthMechanism: string;
noOutgoingServer: boolean;
smtpUsername: string;
replyForwardIdentityId: string;
@@ -49,9 +51,11 @@ export namespace account {
this.imapHost = source["imapHost"];
this.imapPort = source["imapPort"];
this.imapSecurity = source["imapSecurity"];
+ this.imapAuthMechanism = source["imapAuthMechanism"];
this.smtpHost = source["smtpHost"];
this.smtpPort = source["smtpPort"];
this.smtpSecurity = source["smtpSecurity"];
+ this.smtpAuthMechanism = source["smtpAuthMechanism"];
this.noOutgoingServer = source["noOutgoingServer"];
this.smtpUsername = source["smtpUsername"];
this.replyForwardIdentityId = source["replyForwardIdentityId"];
@@ -102,9 +106,11 @@ export namespace account {
imapHost: string;
imapPort: number;
imapSecurity: string;
+ imapAuthMechanism: string;
smtpHost: string;
smtpPort: number;
smtpSecurity: string;
+ smtpAuthMechanism: string;
noOutgoingServer: boolean;
smtpUsername: string;
smtpPassword: string;
@@ -139,9 +145,11 @@ export namespace account {
this.imapHost = source["imapHost"];
this.imapPort = source["imapPort"];
this.imapSecurity = source["imapSecurity"];
+ this.imapAuthMechanism = source["imapAuthMechanism"];
this.smtpHost = source["smtpHost"];
this.smtpPort = source["smtpPort"];
this.smtpSecurity = source["smtpSecurity"];
+ this.smtpAuthMechanism = source["smtpAuthMechanism"];
this.noOutgoingServer = source["noOutgoingServer"];
this.smtpUsername = source["smtpUsername"];
this.smtpPassword = source["smtpPassword"];
@@ -1514,6 +1522,7 @@ export namespace carddav {
writable: boolean;
sync_interval: number;
enabled_addressbooks?: string[];
+ addressbook_names?: Record;
static createFrom(source: any = {}) {
return new SourceConfig(source);
@@ -1531,6 +1540,7 @@ export namespace carddav {
this.writable = source["writable"];
this.sync_interval = source["sync_interval"];
this.enabled_addressbooks = source["enabled_addressbooks"];
+ this.addressbook_names = source["addressbook_names"];
}
}
export class SourceError {
diff --git a/go.mod b/go.mod
index 27587473..f3340daa 100644
--- a/go.mod
+++ b/go.mod
@@ -14,16 +14,17 @@ require (
github.com/emersion/go-webdav v0.7.0
github.com/godbus/dbus/v5 v5.2.2
github.com/google/uuid v1.6.0
+ github.com/icholy/digest v1.2.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/rs/zerolog v1.34.0
github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32
- github.com/wailsapp/wails/v2 v2.12.0
+ github.com/wailsapp/wails/v2 v2.13.0
github.com/zalando/go-keyring v0.2.6
go.mozilla.org/pkcs7 v0.9.0
- golang.org/x/crypto v0.46.0
+ golang.org/x/crypto v0.51.0
golang.org/x/image v0.41.0
- golang.org/x/net v0.48.0
- golang.org/x/sys v0.39.0
+ golang.org/x/net v0.54.0
+ golang.org/x/sys v0.44.0
golang.org/x/text v0.37.0
modernc.org/sqlite v1.42.2
software.sslmate.com/src/go-pkcs12 v0.7.0
@@ -33,7 +34,7 @@ require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
- github.com/cloudflare/circl v1.6.2 // indirect
+ github.com/cloudflare/circl v1.6.3 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
diff --git a/go.sum b/go.sum
index 11fbc89b..577cab1b 100644
--- a/go.sum
+++ b/go.sum
@@ -11,8 +11,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
-github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
-github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
+github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
+github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
@@ -40,6 +40,8 @@ github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
@@ -50,6 +52,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/icholy/digest v1.2.0 h1:oTbG4IsNOmidJ+421ehG7Ty93yt1yotq13kFMG569yw=
+github.com/icholy/digest v1.2.0/go.mod h1:1P1+LzUv48ybX7bu8tVpZ2QWdd+xRuePNuGawHjwRUE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
@@ -98,8 +102,8 @@ github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/teamwork/test v0.0.0-20190410143529-8897d82f8d46/go.mod h1:TIbx7tx6WHBjQeLRM4eWQZBL7kmBZ7/KI4x4v7Y5YmA=
@@ -119,8 +123,8 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
-github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
-github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
+github.com/wailsapp/wails/v2 v2.13.0 h1:S7OgXWpj72V91unF8iDWJKbcS9ZpwCT3R0QVru4v2Mg=
+github.com/wailsapp/wails/v2 v2.13.0/go.mod h1:nVr/wSIEZ7xxKPkzK65mjpKpaOPQI2k4pvLwGR/i4kc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
@@ -128,8 +132,8 @@ go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI=
go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
-golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
@@ -143,8 +147,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
-golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
+golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -162,8 +166,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
-golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -184,6 +188,8 @@ golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
+gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
diff --git a/internal/account/model.go b/internal/account/model.go
index f7fcba53..7e230adb 100644
--- a/internal/account/model.go
+++ b/internal/account/model.go
@@ -22,6 +22,20 @@ const (
AuthOAuth2 AuthType = "oauth2"
)
+// AuthMechanism selects the password auth mechanism for a server connection.
+// Auto negotiates from the server's capabilities (EHLO AUTH advertisement for
+// SMTP, LOGINDISABLED capability for IMAP); the explicit values force a
+// mechanism for servers with broken advertisements (#355). For IMAP, "login"
+// is the LOGIN command and "plain" is AUTHENTICATE PLAIN; for SMTP they are
+// the LOGIN and PLAIN SASL mechanisms.
+type AuthMechanism string
+
+const (
+ AuthMechAuto AuthMechanism = "auto"
+ AuthMechPlain AuthMechanism = "plain"
+ AuthMechLogin AuthMechanism = "login"
+)
+
// Account represents an email account configuration
type Account struct {
ID string `json:"id"`
@@ -36,11 +50,23 @@ type Account struct {
IMAPPort int `json:"imapPort"`
IMAPSecurity SecurityType `json:"imapSecurity"`
+ // IMAPAuthMechanism forces the incoming password auth mechanism
+ // ("plain"/"login"); "auto" (default) negotiates from capabilities.
+ IMAPAuthMechanism AuthMechanism `json:"imapAuthMechanism"`
+
// SMTP settings
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
SMTPSecurity SecurityType `json:"smtpSecurity"`
+ // SMTPAuthMechanism forces the outgoing password SASL mechanism
+ // ("plain"/"login"); "auto" (default) picks from the EHLO AUTH
+ // advertisement. Only consulted when SMTP uses separate credentials
+ // (SMTPUsername non-empty); with "same as incoming" credentials the
+ // effective SMTP mechanism follows IMAPAuthMechanism — see
+ // EffectiveSMTPAuthMechanism.
+ SMTPAuthMechanism AuthMechanism `json:"smtpAuthMechanism"`
+
// NoOutgoingServer disables SMTP for this account. When true, the
// SMTP host/port/security fields are ignored, no SMTP client is
// started, and the composer hides this account (and its identities)
@@ -96,6 +122,17 @@ type Account struct {
UpdatedAt time.Time `json:"updatedAt"`
}
+// EffectiveSMTPAuthMechanism returns the auth mechanism SMTP should use. With
+// "same as incoming server" credentials (empty SMTPUsername) the whole auth
+// config follows the incoming side; with separate SMTP credentials the
+// account's own SMTP mechanism applies.
+func (a *Account) EffectiveSMTPAuthMechanism() AuthMechanism {
+ if a.SMTPUsername == "" {
+ return a.IMAPAuthMechanism
+ }
+ return a.SMTPAuthMechanism
+}
+
// GetFolderMapping returns the mapped folder path for a folder type, or empty string if not mapped
func (a *Account) GetFolderMapping(folderType string) string {
switch folderType {
@@ -184,10 +221,16 @@ type AccountConfig struct {
IMAPPort int `json:"imapPort"`
IMAPSecurity SecurityType `json:"imapSecurity"`
+ // IMAPAuthMechanism — see Account.IMAPAuthMechanism.
+ IMAPAuthMechanism AuthMechanism `json:"imapAuthMechanism"`
+
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
SMTPSecurity SecurityType `json:"smtpSecurity"`
+ // SMTPAuthMechanism — see Account.SMTPAuthMechanism.
+ SMTPAuthMechanism AuthMechanism `json:"smtpAuthMechanism"`
+
// NoOutgoingServer marks this account as receive-only. SMTP fields
// are ignored when true. See Account.NoOutgoingServer.
NoOutgoingServer bool `json:"noOutgoingServer"`
@@ -259,6 +302,12 @@ func (c *AccountConfig) Validate() error {
if c.SMTPSecurity == "" {
c.SMTPSecurity = SecurityStartTLS
}
+ if c.IMAPAuthMechanism == "" {
+ c.IMAPAuthMechanism = AuthMechAuto
+ }
+ if c.SMTPAuthMechanism == "" {
+ c.SMTPAuthMechanism = AuthMechAuto
+ }
if c.AuthType == "" {
c.AuthType = AuthPassword
}
diff --git a/internal/account/model_test.go b/internal/account/model_test.go
new file mode 100644
index 00000000..afcefcf1
--- /dev/null
+++ b/internal/account/model_test.go
@@ -0,0 +1,36 @@
+package account
+
+import "testing"
+
+func TestEffectiveSMTPAuthMechanism(t *testing.T) {
+ tests := []struct {
+ name string
+ acc Account
+ want AuthMechanism
+ }{
+ {
+ name: "same-as-incoming creds follow IMAP mechanism",
+ acc: Account{IMAPAuthMechanism: AuthMechLogin, SMTPAuthMechanism: AuthMechAuto},
+ want: AuthMechLogin,
+ },
+ {
+ name: "separate creds use SMTP mechanism",
+ acc: Account{SMTPUsername: "smtp-user", IMAPAuthMechanism: AuthMechLogin, SMTPAuthMechanism: AuthMechPlain},
+ want: AuthMechPlain,
+ },
+ {
+ name: "defaults stay auto",
+ acc: Account{IMAPAuthMechanism: AuthMechAuto, SMTPAuthMechanism: AuthMechAuto},
+ want: AuthMechAuto,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := tt.acc.EffectiveSMTPAuthMechanism()
+ if got != tt.want {
+ t.Errorf("EffectiveSMTPAuthMechanism() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/account/store.go b/internal/account/store.go
index 1e74c548..79e62fcf 100644
--- a/internal/account/store.go
+++ b/internal/account/store.go
@@ -89,9 +89,11 @@ func (s *Store) Create(config *AccountConfig) (*Account, error) {
IMAPHost: config.IMAPHost,
IMAPPort: config.IMAPPort,
IMAPSecurity: config.IMAPSecurity,
+ IMAPAuthMechanism: config.IMAPAuthMechanism,
SMTPHost: config.SMTPHost,
SMTPPort: config.SMTPPort,
SMTPSecurity: config.SMTPSecurity,
+ SMTPAuthMechanism: config.SMTPAuthMechanism,
NoOutgoingServer: config.NoOutgoingServer,
SMTPUsername: config.SMTPUsername,
ReplyForwardIdentityID: config.ReplyForwardIdentityID,
@@ -120,8 +122,8 @@ func (s *Store) Create(config *AccountConfig) (*Account, error) {
_, err = s.db.Exec(`
INSERT INTO accounts (
id, name, email, shared_mailbox_parent_id,
- imap_host, imap_port, imap_security,
- smtp_host, smtp_port, smtp_security,
+ imap_host, imap_port, imap_security, imap_auth_mechanism,
+ smtp_host, smtp_port, smtp_security, smtp_auth_mechanism,
no_outgoing_server, smtp_username, reply_forward_identity_id,
auth_type, username,
enabled, order_index, color, sync_period_days, sync_interval, sync_all_folders, sync_folders_enabled,
@@ -130,11 +132,11 @@ func (s *Store) Create(config *AccountConfig) (*Account, error) {
spam_folder_path, archive_folder_path, all_mail_folder_path,
starred_folder_path,
created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
account.ID, account.Name, account.Email, nullableString(account.SharedMailboxParentID),
- account.IMAPHost, account.IMAPPort, account.IMAPSecurity,
- account.SMTPHost, account.SMTPPort, account.SMTPSecurity,
+ account.IMAPHost, account.IMAPPort, account.IMAPSecurity, account.IMAPAuthMechanism,
+ account.SMTPHost, account.SMTPPort, account.SMTPSecurity, account.SMTPAuthMechanism,
boolToInt(account.NoOutgoingServer), account.SMTPUsername, account.ReplyForwardIdentityID,
account.AuthType, account.Username,
account.Enabled, account.OrderIndex, account.Color, account.SyncPeriodDays, account.SyncInterval, boolToInt(account.SyncAllFolders), boolToInt(account.SyncFoldersEnabled),
@@ -177,8 +179,8 @@ func (s *Store) Get(id string) (*Account, error) {
var syncAllFolders, syncFoldersEnabled, noOutgoingServer int
err := s.db.QueryRow(`
SELECT id, name, email, shared_mailbox_parent_id,
- imap_host, imap_port, imap_security,
- smtp_host, smtp_port, smtp_security,
+ imap_host, imap_port, imap_security, imap_auth_mechanism,
+ smtp_host, smtp_port, smtp_security, smtp_auth_mechanism,
no_outgoing_server, smtp_username, reply_forward_identity_id,
auth_type, username,
enabled, order_index, color, sync_period_days, sync_interval, sync_all_folders, sync_folders_enabled,
@@ -190,8 +192,8 @@ func (s *Store) Get(id string) (*Account, error) {
FROM accounts WHERE id = ?
`, id).Scan(
&account.ID, &account.Name, &account.Email, &sharedMailboxParentID,
- &account.IMAPHost, &account.IMAPPort, &account.IMAPSecurity,
- &account.SMTPHost, &account.SMTPPort, &account.SMTPSecurity,
+ &account.IMAPHost, &account.IMAPPort, &account.IMAPSecurity, &account.IMAPAuthMechanism,
+ &account.SMTPHost, &account.SMTPPort, &account.SMTPSecurity, &account.SMTPAuthMechanism,
&noOutgoingServer, &account.SMTPUsername, &account.ReplyForwardIdentityID,
&account.AuthType, &account.Username,
&account.Enabled, &account.OrderIndex, &account.Color, &account.SyncPeriodDays, &account.SyncInterval, &syncAllFolders, &syncFoldersEnabled,
@@ -226,8 +228,8 @@ func (s *Store) Get(id string) (*Account, error) {
func (s *Store) List() ([]*Account, error) {
rows, err := s.db.Query(`
SELECT id, name, email, shared_mailbox_parent_id,
- imap_host, imap_port, imap_security,
- smtp_host, smtp_port, smtp_security,
+ imap_host, imap_port, imap_security, imap_auth_mechanism,
+ smtp_host, smtp_port, smtp_security, smtp_auth_mechanism,
no_outgoing_server, smtp_username, reply_forward_identity_id,
auth_type, username,
enabled, order_index, color, sync_period_days, sync_interval, sync_all_folders, sync_folders_enabled,
@@ -250,8 +252,8 @@ func (s *Store) List() ([]*Account, error) {
var syncAllFolders, syncFoldersEnabled, noOutgoingServer int
err := rows.Scan(
&account.ID, &account.Name, &account.Email, &sharedMailboxParentID,
- &account.IMAPHost, &account.IMAPPort, &account.IMAPSecurity,
- &account.SMTPHost, &account.SMTPPort, &account.SMTPSecurity,
+ &account.IMAPHost, &account.IMAPPort, &account.IMAPSecurity, &account.IMAPAuthMechanism,
+ &account.SMTPHost, &account.SMTPPort, &account.SMTPSecurity, &account.SMTPAuthMechanism,
&noOutgoingServer, &account.SMTPUsername, &account.ReplyForwardIdentityID,
&account.AuthType, &account.Username,
&account.Enabled, &account.OrderIndex, &account.Color, &account.SyncPeriodDays, &account.SyncInterval, &syncAllFolders, &syncFoldersEnabled,
@@ -286,8 +288,8 @@ func (s *Store) List() ([]*Account, error) {
func (s *Store) ListBySharedMailboxParent(parentID string) ([]*Account, error) {
rows, err := s.db.Query(`
SELECT id, name, email, shared_mailbox_parent_id,
- imap_host, imap_port, imap_security,
- smtp_host, smtp_port, smtp_security,
+ imap_host, imap_port, imap_security, imap_auth_mechanism,
+ smtp_host, smtp_port, smtp_security, smtp_auth_mechanism,
no_outgoing_server, smtp_username, reply_forward_identity_id,
auth_type, username,
enabled, order_index, color, sync_period_days, sync_interval, sync_all_folders, sync_folders_enabled,
@@ -310,8 +312,8 @@ func (s *Store) ListBySharedMailboxParent(parentID string) ([]*Account, error) {
var syncAllFolders, syncFoldersEnabled, noOutgoingServer int
err := rows.Scan(
&account.ID, &account.Name, &account.Email, &sharedMailboxParentID,
- &account.IMAPHost, &account.IMAPPort, &account.IMAPSecurity,
- &account.SMTPHost, &account.SMTPPort, &account.SMTPSecurity,
+ &account.IMAPHost, &account.IMAPPort, &account.IMAPSecurity, &account.IMAPAuthMechanism,
+ &account.SMTPHost, &account.SMTPPort, &account.SMTPSecurity, &account.SMTPAuthMechanism,
&noOutgoingServer, &account.SMTPUsername, &account.ReplyForwardIdentityID,
&account.AuthType, &account.Username,
&account.Enabled, &account.OrderIndex, &account.Color, &account.SyncPeriodDays, &account.SyncInterval, &syncAllFolders, &syncFoldersEnabled,
@@ -356,8 +358,8 @@ func (s *Store) Update(id string, config *AccountConfig) (*Account, error) {
_, err = s.db.Exec(`
UPDATE accounts SET
name = ?, email = ?,
- imap_host = ?, imap_port = ?, imap_security = ?,
- smtp_host = ?, smtp_port = ?, smtp_security = ?,
+ imap_host = ?, imap_port = ?, imap_security = ?, imap_auth_mechanism = ?,
+ smtp_host = ?, smtp_port = ?, smtp_security = ?, smtp_auth_mechanism = ?,
no_outgoing_server = ?, smtp_username = ?, reply_forward_identity_id = ?,
auth_type = ?, username = ?,
color = ?, sync_period_days = ?, sync_interval = ?, sync_all_folders = ?, sync_folders_enabled = ?,
@@ -369,8 +371,8 @@ func (s *Store) Update(id string, config *AccountConfig) (*Account, error) {
WHERE id = ?
`,
config.Name, config.Email,
- config.IMAPHost, config.IMAPPort, config.IMAPSecurity,
- config.SMTPHost, config.SMTPPort, config.SMTPSecurity,
+ config.IMAPHost, config.IMAPPort, config.IMAPSecurity, config.IMAPAuthMechanism,
+ config.SMTPHost, config.SMTPPort, config.SMTPSecurity, config.SMTPAuthMechanism,
boolToInt(config.NoOutgoingServer), config.SMTPUsername, config.ReplyForwardIdentityID,
config.AuthType, config.Username,
config.Color, config.SyncPeriodDays, config.SyncInterval, boolToInt(config.SyncAllFolders), boolToInt(config.SyncFoldersEnabled),
@@ -397,9 +399,11 @@ func (s *Store) Update(id string, config *AccountConfig) (*Account, error) {
existing.IMAPHost = config.IMAPHost
existing.IMAPPort = config.IMAPPort
existing.IMAPSecurity = config.IMAPSecurity
+ existing.IMAPAuthMechanism = config.IMAPAuthMechanism
existing.SMTPHost = config.SMTPHost
existing.SMTPPort = config.SMTPPort
existing.SMTPSecurity = config.SMTPSecurity
+ existing.SMTPAuthMechanism = config.SMTPAuthMechanism
existing.NoOutgoingServer = config.NoOutgoingServer
existing.SMTPUsername = config.SMTPUsername
existing.ReplyForwardIdentityID = config.ReplyForwardIdentityID
diff --git a/internal/carddav/client.go b/internal/carddav/client.go
index 0be6744a..ec60ea05 100644
--- a/internal/carddav/client.go
+++ b/internal/carddav/client.go
@@ -3,8 +3,10 @@ package carddav
import (
"bytes"
"context"
+ "encoding/xml"
"fmt"
"io"
+ "mime"
"net/http"
"net/url"
"strings"
@@ -18,14 +20,6 @@ import (
"github.com/rs/zerolog"
)
-// newHTTPClient builds an HTTP client with the WebDAV xmlfix transport
-// applied. Both this package and calendar's CalDAV provider use the same
-// shared shape — see internal/kit/davutil. The local helper stays as a
-// thin alias so existing call sites don't churn.
-func newHTTPClient(timeout time.Duration) *http.Client {
- return davutil.NewHTTPClient(timeout)
-}
-
// Client wraps the CardDAV client with discovery and convenience methods
type Client struct {
client *carddav.Client
@@ -42,14 +36,14 @@ type Client struct {
// addressbookHTTPClient returns the go-webdav HTTPClient for a per-addressbook
// operation. When the Client was built with a caller-supplied (bearer) client,
-// that client carries the auth and is reused as-is. Otherwise a Basic-auth
-// client is built from the stored credentials with the requested timeout
-// (preserving the original per-operation timeouts).
+// that client carries the auth and is reused as-is. Otherwise a password-auth
+// (Basic with Digest upgrade) client is built from the stored credentials with
+// the requested timeout (preserving the original per-operation timeouts).
func (c *Client) addressbookHTTPClient(timeout time.Duration) webdav.HTTPClient {
if c.injected != nil {
return c.injected
}
- return webdav.HTTPClientWithBasicAuth(newHTTPClient(timeout), c.username, c.password)
+ return davutil.NewBasicDigestHTTPClient(c.username, c.password, timeout)
}
// normalizeURL parses baseURL and defaults a missing scheme to https.
@@ -71,8 +65,9 @@ func NewClient(baseURL, username, password string) (*Client, error) {
return nil, err
}
- // Create HTTP client with XML-fix transport, wrapped for Basic auth.
- httpClient := webdav.HTTPClientWithBasicAuth(newHTTPClient(30*time.Second), username, password)
+ // Create HTTP client with XML-fix transport and password auth (Basic,
+ // upgrading to Digest on challenge).
+ httpClient := davutil.NewBasicDigestHTTPClient(username, password, 30*time.Second)
client, err := carddav.NewClient(httpClient, parsedURL.String())
if err != nil {
@@ -117,7 +112,7 @@ func NewClientWithHTTPClient(httpClient webdav.HTTPClient, baseURL string) (*Cli
// 2. Direct PROPFIND on the URL
// 3. Common paths (/remote.php/dav for Nextcloud, etc.)
func DiscoverAddressbooks(baseURL, username, password string) ([]AddressbookInfo, error) {
- httpClient := webdav.HTTPClientWithBasicAuth(newHTTPClient(30*time.Second), username, password)
+ httpClient := davutil.NewBasicDigestHTTPClient(username, password, 30*time.Second)
return discoverAddressbooks(baseURL, username, httpClient)
}
@@ -150,6 +145,9 @@ func discoverAddressbooks(baseURL, username string, httpClient webdav.HTTPClient
if err == nil && len(addressbooks) > 0 {
return addressbooks, nil
}
+ // The direct-URL attempt's error is the most user-relevant one — later
+ // fallback rungs can fail for unrelated reasons and would mask it (#363).
+ directErr := err
log.Debug().Err(err).Msg("Direct URL discovery failed, trying .well-known")
// Method 2: Try .well-known/carddav
@@ -178,6 +176,9 @@ func discoverAddressbooks(baseURL, username string, httpClient webdav.HTTPClient
}
}
+ if directErr != nil {
+ return nil, fmt.Errorf("no addressbooks found at %s: %w", baseURL, directErr)
+ }
return nil, fmt.Errorf("no addressbooks found at %s", baseURL)
}
@@ -193,9 +194,17 @@ func tryDiscoverFromURL(ctx context.Context, httpClient webdav.HTTPClient, urlSt
// Try to find the current user's principal
principal, err := client.FindCurrentUserPrincipal(ctx)
if err != nil {
- log.Debug().Err(err).Msg("FindCurrentUserPrincipal failed")
- // Try the URL directly as addressbook home
- return tryListAddressbooksAt(ctx, httpClient, urlStr, log)
+ // go-webdav's probe path.Join-strips a trailing slash from the request
+ // path, 404ing servers that serve the collection only at "/x/"
+ // (SabreDAV/Davis, #363). Retry with a raw PROPFIND that keeps the URL
+ // exactly as given (and once with "/" appended).
+ rawPrincipal, rawErr := davutil.FindCurrentUserPrincipalRaw(ctx, httpClient, urlStr)
+ if rawErr != nil {
+ log.Debug().Err(err).Msg("FindCurrentUserPrincipal failed")
+ // Try the URL directly as addressbook home
+ return tryListAddressbooksAt(ctx, httpClient, urlStr, log)
+ }
+ principal = rawPrincipal
}
log.Debug().Str("principal", principal).Msg("Found principal")
@@ -789,6 +798,279 @@ func (c *Client) SyncAddressbook(addressbookPath, syncToken string) (*SyncResult
return result, nil
}
+// FetchContactsEnumerate fetches all contacts using only baseline WebDAV
+// primitives: a Depth:1 PROPFIND to enumerate the collection's vCard hrefs,
+// then addressbook-multiget for the bodies, then per-href GET when the server
+// rejects multiget REPORTs too. Third sync tier for minimal servers
+// (Mailfence, #366) that 501 sync-collection AND 409 addressbook-query. The
+// enumeration is hand-rolled rather than go-webdav's ReadDir because ReadDir
+// hard-requires getcontentlength on file entries — exactly the kind of
+// property this server class omits.
+func (c *Client) FetchContactsEnumerate(addressbookPath string) ([]*ParsedRecord, error) {
+ ctx := context.Background()
+ fullPath := resolveURL(c.baseURL, addressbookPath)
+ httpClient := c.addressbookHTTPClient(60 * time.Second)
+
+ paths, err := enumerateVCardHrefs(ctx, httpClient, fullPath, addressbookPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to enumerate addressbook: %w", err)
+ }
+ c.log.Debug().Int("count", len(paths)).Str("path", addressbookPath).Msg("Enumerated vCard hrefs via PROPFIND")
+ if len(paths) == 0 {
+ return nil, nil
+ }
+
+ // Hand-rolled multiget rather than go-webdav's: that one unconditionally
+ // requests getlastmodified and hard-fails on unparseable values — exactly
+ // what this server class returns (Mailfence emits non-RFC1123 dates,
+ // #366). Requesting only getetag + address-data sidesteps it entirely.
+ c.log.Debug().Int("count", len(paths)).Msg("Fetching records via minimal multiget")
+ records, err := multigetVCards(ctx, httpClient, fullPath, paths)
+ if err == nil {
+ return records, nil
+ }
+
+ // Last resort: a server that rejects REPORTs entirely — fetch each vCard
+ // with a plain GET.
+ c.log.Debug().Err(err).Msg("Multiget rejected, fetching vCards individually via GET")
+ records = make([]*ParsedRecord, 0, len(paths))
+ for _, p := range paths {
+ parsed, getErr := c.fetchVCardByGET(ctx, httpClient, p)
+ if getErr != nil {
+ return nil, fmt.Errorf("failed to fetch %s: %w", p, getErr)
+ }
+ if parsed == nil {
+ continue
+ }
+ records = append(records, parsed)
+ }
+ return records, nil
+}
+
+// multigetVCards fetches vCard bodies with a minimal addressbook-multiget
+// REPORT requesting only getetag + address-data (RFC 6352 §8.7 — the client
+// chooses the props), parsed tolerantly. Unlike go-webdav's multiget it never
+// requests getlastmodified, so servers with malformed timestamps (#366) can't
+// poison the response.
+func multigetVCards(ctx context.Context, httpClient webdav.HTTPClient, fullPath string, paths []string) ([]*ParsedRecord, error) {
+ var body strings.Builder
+ body.WriteString(``)
+ body.WriteString(``)
+ body.WriteString(` `)
+ for _, p := range paths {
+ body.WriteString(``)
+ if err := xml.EscapeText(&body, []byte(p)); err != nil {
+ return nil, fmt.Errorf("encode multiget href: %w", err)
+ }
+ body.WriteString(` `)
+ }
+ body.WriteString(` `)
+
+ req, err := http.NewRequestWithContext(ctx, "REPORT", fullPath, strings.NewReader(body.String()))
+ if err != nil {
+ return nil, fmt.Errorf("build multiget REPORT: %w", err)
+ }
+ req.Header.Set("Depth", "1")
+ req.Header.Set("Content-Type", "application/xml")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("multiget REPORT: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusMultiStatus {
+ io.Copy(io.Discard, resp.Body) //nolint:errcheck // best-effort drain
+ return nil, fmt.Errorf("multiget REPORT: %s", resp.Status)
+ }
+
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
+ if err != nil {
+ return nil, fmt.Errorf("read multiget response: %w", err)
+ }
+
+ // Tolerant decode: unknown props (e.g. a getlastmodified the server
+ // volunteers anyway) are simply ignored.
+ var ms struct {
+ XMLName xml.Name `xml:"DAV: multistatus"`
+ Response []struct {
+ Href string `xml:"href"`
+ Propstat []struct {
+ Prop struct {
+ ETag string `xml:"getetag"`
+ AddressData string `xml:"urn:ietf:params:xml:ns:carddav address-data"`
+ } `xml:"prop"`
+ } `xml:"propstat"`
+ } `xml:"response"`
+ }
+ if err := xml.Unmarshal(raw, &ms); err != nil {
+ return nil, fmt.Errorf("parse multiget response: %w", err)
+ }
+
+ records := make([]*ParsedRecord, 0, len(ms.Response))
+ for _, r := range ms.Response {
+ href := strings.TrimSpace(r.Href)
+ if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
+ if u, uerr := url.Parse(href); uerr == nil {
+ href = u.Path
+ }
+ }
+ etag := ""
+ data := ""
+ for _, ps := range r.Propstat {
+ if ps.Prop.ETag != "" {
+ etag = ps.Prop.ETag
+ }
+ if ps.Prop.AddressData != "" {
+ data = ps.Prop.AddressData
+ }
+ }
+ if href == "" || data == "" {
+ continue
+ }
+ card, decErr := vcard.NewDecoder(strings.NewReader(data)).Decode()
+ if decErr != nil {
+ continue
+ }
+ parsed := parseVCard(carddav.AddressObject{
+ Path: href,
+ ETag: strings.Trim(etag, `"`),
+ Card: card,
+ })
+ if parsed == nil {
+ continue
+ }
+ records = append(records, parsed)
+ }
+ return records, nil
+}
+
+// fetchVCardByGET fetches a single vCard with a plain GET. Accepts exactly
+// text/vcard and the pre-RFC6350 legacy text/x-vcard (#366, Mailfence) —
+// anything else is rejected. Returns nil (no error) for an unparseable card.
+func (c *Client) fetchVCardByGET(ctx context.Context, httpClient webdav.HTTPClient, path string) (*ParsedRecord, error) {
+ fullURL := resolveURL(c.baseURL, path)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("build GET: %w", err)
+ }
+ req.Header.Set("Accept", "text/vcard")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("GET: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ io.Copy(io.Discard, resp.Body) //nolint:errcheck // best-effort drain
+ return nil, fmt.Errorf("GET: %s", resp.Status)
+ }
+
+ mediaType := resp.Header.Get("Content-Type")
+ if parsed, _, mtErr := mime.ParseMediaType(mediaType); mtErr == nil {
+ mediaType = parsed
+ }
+ mediaType = strings.ToLower(mediaType)
+ if mediaType != "text/vcard" && mediaType != "text/x-vcard" {
+ return nil, fmt.Errorf("unexpected Content-Type %q", resp.Header.Get("Content-Type"))
+ }
+
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
+ if err != nil {
+ return nil, fmt.Errorf("read vCard body: %w", err)
+ }
+ card, err := vcard.NewDecoder(bytes.NewReader(raw)).Decode()
+ if err != nil {
+ return nil, fmt.Errorf("parse vCard: %w", err)
+ }
+
+ return parseVCard(carddav.AddressObject{
+ Path: path,
+ ETag: strings.Trim(resp.Header.Get("ETag"), `"`),
+ Card: card,
+ }), nil
+}
+
+// enumerateVCardHrefs lists a collection's member hrefs with a minimal
+// Depth:1 PROPFIND (resourcetype + getcontenttype only) and a tolerant parse.
+// Collections (including the addressbook itself) are skipped; untyped members
+// are kept — minimal servers often omit getcontenttype for vCards.
+func enumerateVCardHrefs(ctx context.Context, httpClient webdav.HTTPClient, fullPath, addressbookPath string) ([]string, error) {
+ const propfindBody = `` +
+ ` `
+ req, err := http.NewRequestWithContext(ctx, "PROPFIND", fullPath, strings.NewReader(propfindBody))
+ if err != nil {
+ return nil, fmt.Errorf("build enumeration PROPFIND: %w", err)
+ }
+ req.Header.Set("Depth", "1")
+ req.Header.Set("Content-Type", "application/xml")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("enumeration PROPFIND: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusMultiStatus {
+ io.Copy(io.Discard, resp.Body) //nolint:errcheck // best-effort drain
+ return nil, fmt.Errorf("enumeration PROPFIND: %s", resp.Status)
+ }
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
+ if err != nil {
+ return nil, fmt.Errorf("read enumeration response: %w", err)
+ }
+
+ var ms struct {
+ XMLName xml.Name `xml:"DAV: multistatus"`
+ Response []struct {
+ Href string `xml:"href"`
+ Propstat []struct {
+ Prop struct {
+ ResourceType struct {
+ Collection *struct{} `xml:"collection"`
+ } `xml:"resourcetype"`
+ ContentType string `xml:"getcontenttype"`
+ } `xml:"prop"`
+ } `xml:"propstat"`
+ } `xml:"response"`
+ }
+ if err := xml.Unmarshal(body, &ms); err != nil {
+ return nil, fmt.Errorf("parse enumeration response: %w", err)
+ }
+
+ collection := strings.TrimSuffix(addressbookPath, "/")
+ var paths []string
+ for _, r := range ms.Response {
+ href := strings.TrimSpace(r.Href)
+ if href == "" {
+ continue
+ }
+ // Some servers return absolute URLs in hrefs — reduce to the path.
+ if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
+ if u, uerr := url.Parse(href); uerr == nil {
+ href = u.Path
+ }
+ }
+ isCollection := false
+ contentType := ""
+ for _, ps := range r.Propstat {
+ if ps.Prop.ResourceType.Collection != nil {
+ isCollection = true
+ }
+ if ps.Prop.ContentType != "" {
+ contentType = ps.Prop.ContentType
+ }
+ }
+ if isCollection || strings.TrimSuffix(href, "/") == collection {
+ continue
+ }
+ if contentType != "" && !strings.Contains(contentType, "vcard") && !strings.HasSuffix(href, ".vcf") {
+ continue
+ }
+ paths = append(paths, href)
+ }
+ return paths, nil
+}
+
// fetchContactsByPath fetches records by their paths using addressbook-multiget.
// Returns one ParsedRecord per vCard.
func (c *Client) fetchContactsByPath(client *carddav.Client, addressbookPath string, paths []string) ([]*ParsedRecord, error) {
diff --git a/internal/carddav/enumerate_test.go b/internal/carddav/enumerate_test.go
new file mode 100644
index 00000000..77c4df78
--- /dev/null
+++ b/internal/carddav/enumerate_test.go
@@ -0,0 +1,204 @@
+package carddav
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// mailfenceServer replicates the minimal DAV behavior from issue #366:
+// sync-collection REPORTs get 501, addressbook-query REPORTs get 409, and
+// only PROPFIND enumeration + (configurably) multiget or plain GET work.
+// Round-2 quirks baked in: multiget responses volunteer a malformed
+// getlastmodified (non-RFC1123 day), and GETs serve the legacy text/x-vcard
+// MIME type (overridable via getContentType).
+type mailfenceServer struct {
+ srv *httptest.Server
+ rejectMultiget bool
+ getContentType string
+ requests []string
+}
+
+const (
+ vcardAlice = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:a1\r\nFN:Alice Test\r\nEMAIL:alice@example.com\r\nEND:VCARD\r\n"
+ vcardBob = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:b1\r\nFN:Bob Test\r\nEMAIL:bob@example.com\r\nEND:VCARD\r\n"
+)
+
+func newMailfenceServer(rejectMultiget bool) *mailfenceServer {
+ m := &mailfenceServer{rejectMultiget: rejectMultiget, getContentType: "text/x-vcard"}
+ m.srv = httptest.NewServer(http.HandlerFunc(m.handle))
+ return m
+}
+
+func (m *mailfenceServer) close() { m.srv.Close() }
+
+func (m *mailfenceServer) handle(w http.ResponseWriter, r *http.Request) {
+ body := make([]byte, 0, 1024)
+ if r.Body != nil {
+ buf := make([]byte, 64<<10)
+ n, _ := r.Body.Read(buf)
+ body = buf[:n]
+ r.Body.Close()
+ }
+ m.requests = append(m.requests, r.Method+" "+r.URL.Path)
+
+ switch {
+ case r.Method == "REPORT" && strings.Contains(string(body), "sync-collection"):
+ w.WriteHeader(http.StatusNotImplemented)
+ case r.Method == "REPORT" && strings.Contains(string(body), "addressbook-query"):
+ w.WriteHeader(http.StatusConflict)
+ case r.Method == "REPORT" && strings.Contains(string(body), "addressbook-multiget"):
+ if m.rejectMultiget {
+ w.WriteHeader(http.StatusConflict)
+ return
+ }
+ w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+ w.WriteHeader(http.StatusMultiStatus)
+ // Mailfence volunteers getlastmodified with a non-RFC1123 day even
+ // when not requested (#366) — the parser must ignore it.
+ w.Write([]byte(`` + //nolint:errcheck
+ `` +
+ `/addressbook/alice.vcf ` +
+ `Tue, 8 Jul 2025 19:41:18 GMT ` +
+ `"e-alice" ` + vcardAlice + ` ` +
+ ` HTTP/1.1 200 OK ` +
+ `/addressbook/bob.vcf ` +
+ `Tue, 8 Jul 2025 19:41:18 GMT ` +
+ `"e-bob" ` + vcardBob + ` ` +
+ ` HTTP/1.1 200 OK ` +
+ ` `))
+ case r.Method == "PROPFIND" && strings.HasPrefix(r.URL.Path, "/addressbook"):
+ w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+ w.WriteHeader(http.StatusMultiStatus)
+ w.Write([]byte(`` + //nolint:errcheck
+ `` +
+ `/addressbook/ ` +
+ ` ` +
+ ` HTTP/1.1 200 OK ` +
+ `/addressbook/alice.vcf ` +
+ `"e-alice" text/vcard ` +
+ ` HTTP/1.1 200 OK ` +
+ `/addressbook/bob.vcf ` +
+ `"e-bob" ` +
+ ` HTTP/1.1 200 OK ` +
+ ` `))
+ case r.Method == "GET" && r.URL.Path == "/addressbook/alice.vcf":
+ w.Header().Set("Content-Type", m.getContentType+"; charset=utf-8")
+ w.Header().Set("ETag", `"e-alice"`)
+ w.Write([]byte(vcardAlice)) //nolint:errcheck
+ case r.Method == "GET" && r.URL.Path == "/addressbook/bob.vcf":
+ w.Header().Set("Content-Type", m.getContentType+"; charset=utf-8")
+ w.Header().Set("ETag", `"e-bob"`)
+ w.Write([]byte(vcardBob)) //nolint:errcheck
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+}
+
+func assertAliceBob(t *testing.T, records []*ParsedRecord) {
+ t.Helper()
+ if len(records) != 2 {
+ t.Fatalf("records = %d, want 2", len(records))
+ }
+ names := map[string]bool{}
+ for _, r := range records {
+ names[r.FN] = true
+ }
+ if !names["Alice Test"] || !names["Bob Test"] {
+ t.Fatalf("parsed FNs = %v, want Alice Test + Bob Test", names)
+ }
+}
+
+func TestFetchContactsEnumerate_Multiget(t *testing.T) {
+ m := newMailfenceServer(false)
+ defer m.close()
+
+ c := newTestClient(t, m.srv.URL)
+ records, err := c.FetchContactsEnumerate("/addressbook/")
+ if err != nil {
+ t.Fatalf("FetchContactsEnumerate: %v", err)
+ }
+ assertAliceBob(t, records)
+}
+
+// TestFetchContactsEnumerate_GetRejectsUnknownContentType proves the GET
+// fallback's MIME whitelist stays strict: text/vcard and legacy text/x-vcard
+// pass, anything else errors.
+func TestFetchContactsEnumerate_GetRejectsUnknownContentType(t *testing.T) {
+ m := newMailfenceServer(true)
+ defer m.close()
+ m.getContentType = "text/html"
+
+ c := newTestClient(t, m.srv.URL)
+ _, err := c.FetchContactsEnumerate("/addressbook/")
+ if err == nil {
+ t.Fatal("expected error for Content-Type text/html, got nil")
+ }
+ if !strings.Contains(err.Error(), "unexpected Content-Type") {
+ t.Fatalf("expected Content-Type rejection, got: %v", err)
+ }
+}
+
+func TestFetchContactsEnumerate_GetFallback(t *testing.T) {
+ m := newMailfenceServer(true)
+ defer m.close()
+
+ c := newTestClient(t, m.srv.URL)
+ records, err := c.FetchContactsEnumerate("/addressbook/")
+ if err != nil {
+ t.Fatalf("FetchContactsEnumerate (GET fallback): %v", err)
+ }
+ assertAliceBob(t, records)
+
+ var gets int
+ for _, req := range m.requests {
+ if strings.HasPrefix(req, "GET ") {
+ gets++
+ }
+ }
+ if gets != 2 {
+ t.Fatalf("server saw %d GETs, want 2 (per-href fallback); requests=%v", gets, m.requests)
+ }
+}
+
+// TestSyncAddressbookLegacy_MailfenceChain drives the full tier chain the way
+// issue #366 hits it: sync-collection 501 → addressbook-query 409 → PROPFIND
+// enumeration succeeds and the records land in the store with href+etag state.
+func TestSyncAddressbookLegacy_MailfenceChain(t *testing.T) {
+ m := newMailfenceServer(false)
+ defer m.close()
+
+ db := openCardDAVTestDB(t)
+ store := NewStore(db.DB)
+ syncer := NewSyncer(store, nil)
+
+ source, err := store.CreateSource(&SourceConfig{Name: "mf", Type: SourceTypeCardDAV, URL: m.srv.URL, Username: "user", Enabled: true})
+ if err != nil {
+ t.Fatalf("CreateSource: %v", err)
+ }
+ ab, err := store.CreateAddressbook(source.ID, "/addressbook/", "Mike Test", true)
+ if err != nil {
+ t.Fatalf("CreateAddressbook: %v", err)
+ }
+
+ client := newTestClient(t, m.srv.URL)
+ if err := syncer.syncAddressbookFull(client, ab); err != nil {
+ t.Fatalf("syncAddressbookFull: %v", err)
+ }
+
+ var stateRows int
+ if err := db.DB.QueryRow(`SELECT COUNT(*) FROM carddav_record_state WHERE addressbook_id = ? AND etag != ''`, ab.ID).Scan(&stateRows); err != nil {
+ t.Fatalf("count record state: %v", err)
+ }
+ if stateRows != 2 {
+ t.Fatalf("carddav_record_state rows = %d, want 2", stateRows)
+ }
+ var recordRows int
+ if err := db.DB.QueryRow(`SELECT COUNT(*) FROM contact_records WHERE source = 'carddav'`).Scan(&recordRows); err != nil {
+ t.Fatalf("count records: %v", err)
+ }
+ if recordRows != 2 {
+ t.Fatalf("contact_records rows = %d, want 2", recordRows)
+ }
+}
diff --git a/internal/carddav/model.go b/internal/carddav/model.go
index e3cc34d5..935a9222 100644
--- a/internal/carddav/model.go
+++ b/internal/carddav/model.go
@@ -46,6 +46,12 @@ type SourceConfig struct {
// Addressbooks to enable (paths) - only used for CardDAV sources
EnabledAddressbooks []string `json:"enabled_addressbooks,omitempty"`
+
+ // AddressbookNames maps enabled addressbook paths to the display names
+ // found during discovery, so the stored rows keep the server's names
+ // ("Mike's Contacts") instead of the path's last segment ("contacts",
+ // #366). Optional — missing entries fall back to the path segment.
+ AddressbookNames map[string]string `json:"addressbook_names,omitempty"`
}
// Addressbook represents a single addressbook within a CardDAV source
diff --git a/internal/carddav/sync.go b/internal/carddav/sync.go
index 3f57cdae..cb61cbed 100644
--- a/internal/carddav/sync.go
+++ b/internal/carddav/sync.go
@@ -532,6 +532,12 @@ func (s *Syncer) syncAddressbookLegacy(client *Client, ab *Addressbook) error {
s.log.Debug().Str("addressbook", ab.Name).Msg("Performing legacy sync (addressbook-query)")
parsedContacts, err := client.FetchContacts(ab.Path)
+ if err != nil {
+ // Minimal servers (Mailfence, #366) reject addressbook-query REPORTs
+ // too — fall back to plain PROPFIND enumeration + multiget/GET.
+ s.log.Debug().Err(err).Msg("Addressbook-query not supported, using PROPFIND enumeration")
+ parsedContacts, err = client.FetchContactsEnumerate(ab.Path)
+ }
if err != nil {
return fmt.Errorf("failed to fetch contacts: %w", err)
}
diff --git a/internal/database/database_test.go b/internal/database/database_test.go
index 0eeb02f8..8f9ee13d 100644
--- a/internal/database/database_test.go
+++ b/internal/database/database_test.go
@@ -185,9 +185,9 @@ func TestMigrationV32_LocalRecordIDsRewrittenToUUIDs(t *testing.T) {
}
}
// Drop v37's SMTP-receive-only / SMTP-creds columns + v38's
- // reply_forward_identity_id on accounts so the re-application's ADD
- // COLUMNs don't collide.
- for _, col := range []string{"no_outgoing_server", "smtp_username", "encrypted_smtp_password", "reply_forward_identity_id", "oauth_stable_id"} {
+ // reply_forward_identity_id + v41's smtp_auth_mechanism on accounts so
+ // the re-application's ADD COLUMNs don't collide.
+ for _, col := range []string{"no_outgoing_server", "smtp_username", "encrypted_smtp_password", "reply_forward_identity_id", "oauth_stable_id", "imap_auth_mechanism", "smtp_auth_mechanism"} {
if _, err := db.Exec(`ALTER TABLE accounts DROP COLUMN ` + col); err != nil {
t.Fatalf("drop accounts.%s for re-migrate: %v", col, err)
}
@@ -338,8 +338,8 @@ func TestMigrationV33_CleansExistingOrphans(t *testing.T) {
t.Fatalf("drop oauth_tokens.%s for re-migrate: %v", col, err)
}
}
- // Same for v37 + v38 + v40's accounts columns.
- for _, col := range []string{"no_outgoing_server", "smtp_username", "encrypted_smtp_password", "reply_forward_identity_id", "oauth_stable_id"} {
+ // Same for v37 + v38 + v40 + v41 + v42's accounts columns.
+ for _, col := range []string{"no_outgoing_server", "smtp_username", "encrypted_smtp_password", "reply_forward_identity_id", "oauth_stable_id", "imap_auth_mechanism", "smtp_auth_mechanism"} {
if _, err := db.Exec(`ALTER TABLE accounts DROP COLUMN ` + col); err != nil {
t.Fatalf("drop accounts.%s for re-migrate: %v", col, err)
}
diff --git a/internal/database/migrations.go b/internal/database/migrations.go
index 1ad92be0..ddcccbaf 100644
--- a/internal/database/migrations.go
+++ b/internal/database/migrations.go
@@ -1315,4 +1315,26 @@ var migrations = []Migration{
ALTER TABLE accounts ADD COLUMN oauth_stable_id TEXT NOT NULL DEFAULT '';
`,
},
+ {
+ Version: 41,
+ SQL: `
+ -- SMTP password SASL mechanism: 'auto' (pick from the server's EHLO
+ -- AUTH advertisement), 'plain', or 'login'. The explicit values let
+ -- users force a mechanism on servers with broken advertisements (#355).
+
+ ALTER TABLE accounts ADD COLUMN smtp_auth_mechanism TEXT NOT NULL DEFAULT 'auto';
+ `,
+ },
+ {
+ Version: 42,
+ SQL: `
+ -- IMAP counterpart of v41: incoming password auth mechanism ('auto' =
+ -- negotiate via LOGINDISABLED capability, 'plain', 'login'). Separate
+ -- migration because v41 had already been applied to dev databases when
+ -- the incoming setting was added. SMTP with "same as incoming"
+ -- credentials (empty smtp_username) follows imap_auth_mechanism.
+
+ ALTER TABLE accounts ADD COLUMN imap_auth_mechanism TEXT NOT NULL DEFAULT 'auto';
+ `,
+ },
}
diff --git a/internal/imap/client.go b/internal/imap/client.go
index 0db11828..b2ed0c9e 100644
--- a/internal/imap/client.go
+++ b/internal/imap/client.go
@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"strconv"
+ "strings"
"time"
"github.com/emersion/go-imap/v2"
@@ -66,6 +67,11 @@ type ClientConfig struct {
AuthType AuthType // "password" or "oauth2" (defaults to "password")
AccessToken string // OAuth2 access token (when AuthType is "oauth2")
+ // AuthMechanism selects the password auth mechanism: "plain"
+ // (AUTHENTICATE PLAIN), "login" (LOGIN command), or ""/"auto"
+ // (negotiate from capabilities). Ignored for OAuth2.
+ AuthMechanism string
+
// Timeouts
ConnectTimeout time.Duration
ReadTimeout time.Duration
@@ -254,22 +260,43 @@ func (c *Client) Login() error {
return nil
}
-// loginPassword authenticates using password (LOGIN or SASL PLAIN)
+// loginPassword authenticates using password (LOGIN or SASL PLAIN). A
+// configured AuthMechanism forces that mechanism; otherwise the choice is
+// negotiated from capabilities.
func (c *Client) loginPassword() error {
- // Use LOGIN by default — it's simpler and more compatible.
+ switch strings.ToLower(c.config.AuthMechanism) {
+ case "plain":
+ c.log.Debug().Msg("Using AUTHENTICATE PLAIN (configured)")
+ return c.authenticatePlain()
+ case "login":
+ c.log.Debug().Msg("Using LOGIN command (configured)")
+ return c.loginCommand()
+ }
+
+ // Auto: use LOGIN by default — it's simpler and more compatible.
// Only use AUTHENTICATE PLAIN if the server advertises LOGINDISABLED,
// since a failed AUTHENTICATE can corrupt the IMAP wire state and
// prevent a fallback LOGIN from working (seen with Proton Bridge).
if c.caps.Has(imap.CapLoginDisabled) {
c.log.Debug().Msg("LOGIN disabled, using AUTHENTICATE PLAIN")
- saslClient := sasl.NewPlainClient("", c.config.Username, c.config.Password)
- if err := c.client.Authenticate(saslClient); err != nil {
- return fmt.Errorf("authentication failed: %w", err)
- }
- return nil
+ return c.authenticatePlain()
}
c.log.Debug().Msg("Using LOGIN command")
+ return c.loginCommand()
+}
+
+// authenticatePlain authenticates with SASL AUTHENTICATE PLAIN.
+func (c *Client) authenticatePlain() error {
+ saslClient := sasl.NewPlainClient("", c.config.Username, c.config.Password)
+ if err := c.client.Authenticate(saslClient); err != nil {
+ return fmt.Errorf("authentication failed: %w", err)
+ }
+ return nil
+}
+
+// loginCommand authenticates with the IMAP LOGIN command.
+func (c *Client) loginCommand() error {
if err := c.client.Login(c.config.Username, c.config.Password).Wait(); err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
diff --git a/internal/kit/davutil/authtransport.go b/internal/kit/davutil/authtransport.go
new file mode 100644
index 00000000..60aed71b
--- /dev/null
+++ b/internal/kit/davutil/authtransport.go
@@ -0,0 +1,152 @@
+package davutil
+
+import (
+ "io"
+ "net/http"
+ "net/url"
+ "sync"
+ "time"
+
+ "github.com/icholy/digest"
+)
+
+// basicDigestTransport authenticates password-based WebDAV requests. It sends
+// Basic preemptively (the historical behavior — zero extra round trips for the
+// common Basic servers), and when a server rejects with a 401 carrying a
+// Digest challenge (RFC 7616; sabre/dav servers like Baïkal default to it,
+// #313/#315) it answers the challenge and retries once. Challenges are cached
+// per host+user so subsequent requests authenticate on the first try; a stale
+// nonce (401 with a fresh challenge) refreshes the cache through the same
+// retry path.
+type basicDigestTransport struct {
+ username string
+ password string
+ base http.RoundTripper
+}
+
+// challengeState is a cached digest challenge plus the nonce-count of the
+// last request made against it.
+type challengeState struct {
+ chal *digest.Challenge
+ count int
+}
+
+// digestChallenges caches challenges per scheme://host + username. Package
+// level because davutil clients are constructed per operation — cache state on
+// the transport itself would be lost between calls.
+var (
+ digestMu sync.Mutex
+ digestChallenges = map[string]*challengeState{}
+)
+
+func challengeKey(u *url.URL, username string) string {
+ return u.Scheme + "://" + u.Host + "\x00" + username
+}
+
+// digestAuthorization builds an Authorization header from the cached challenge
+// for key, incrementing its nonce count. Returns "" when no challenge is
+// cached or the credentials cannot be computed.
+func digestAuthorization(key, method, uri, username, password string) string {
+ digestMu.Lock()
+ defer digestMu.Unlock()
+ st, ok := digestChallenges[key]
+ if !ok {
+ return ""
+ }
+ st.count++
+ cred, err := digest.Digest(st.chal, digest.Options{
+ Method: method,
+ URI: uri,
+ Count: st.count,
+ Username: username,
+ Password: password,
+ })
+ if err != nil {
+ return ""
+ }
+ return cred.String()
+}
+
+func storeChallenge(key string, chal *digest.Challenge) {
+ digestMu.Lock()
+ defer digestMu.Unlock()
+ digestChallenges[key] = &challengeState{chal: chal}
+}
+
+// RoundTrip implements http.RoundTripper.
+func (t *basicDigestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ base := t.base
+ if base == nil {
+ base = defaultBaseTransport()
+ }
+ // Respect an Authorization header the caller set themselves (mirrors
+ // bearerTransport).
+ if req.Header.Get("Authorization") != "" {
+ return base.RoundTrip(req)
+ }
+
+ key := challengeKey(req.URL, t.username)
+ first := req.Clone(req.Context())
+ auth := digestAuthorization(key, req.Method, req.URL.RequestURI(), t.username, t.password)
+ if auth == "" {
+ first.SetBasicAuth(t.username, t.password)
+ }
+ if auth != "" {
+ first.Header.Set("Authorization", auth)
+ }
+
+ resp, err := base.RoundTrip(first)
+ if err != nil {
+ return resp, err
+ }
+ if resp.StatusCode != http.StatusUnauthorized {
+ return resp, nil
+ }
+ chal, err := digest.FindChallenge(resp.Header)
+ if err != nil || !digest.CanDigest(chal) {
+ // 401 without a usable Digest challenge (Basic-only server, bad
+ // credentials, unsupported algorithm) — pass through unchanged.
+ return resp, nil
+ }
+ // A body that was consumed by the first attempt must be replayable.
+ if req.Body != nil && req.GetBody == nil {
+ return resp, nil
+ }
+
+ // Drain so the underlying connection can be reused, then retry once with
+ // the fresh challenge. Covers both first contact and stale nonces.
+ io.Copy(io.Discard, resp.Body) //nolint:errcheck // best-effort drain
+ resp.Body.Close()
+ storeChallenge(key, chal)
+
+ retry := req.Clone(req.Context())
+ auth = digestAuthorization(key, req.Method, req.URL.RequestURI(), t.username, t.password)
+ if auth == "" {
+ // Credential computation failed against a challenge CanDigest accepted
+ // — should not happen; fall back to the original 401 semantics.
+ retry.SetBasicAuth(t.username, t.password)
+ }
+ if auth != "" {
+ retry.Header.Set("Authorization", auth)
+ }
+ if req.GetBody != nil {
+ body, bodyErr := req.GetBody()
+ if bodyErr != nil {
+ return nil, bodyErr
+ }
+ retry.Body = body
+ }
+ return base.RoundTrip(retry)
+}
+
+// NewBasicDigestHTTPClient returns a WebDAV-ready *http.Client that
+// authenticates with Basic, upgrades to HTTP Digest when the server demands
+// it, and applies the XML fixups. Drop-in replacement for
+// webdav.HTTPClientWithBasicAuth wherever a go-webdav HTTPClient is expected.
+func NewBasicDigestHTTPClient(username, password string, timeout time.Duration) *http.Client {
+ return NewWebDAVClient(&basicDigestTransport{
+ username: username,
+ password: password,
+ base: defaultBaseTransport(),
+ }, timeout)
+}
diff --git a/internal/kit/davutil/authtransport_test.go b/internal/kit/davutil/authtransport_test.go
new file mode 100644
index 00000000..d5cef8ad
--- /dev/null
+++ b/internal/kit/davutil/authtransport_test.go
@@ -0,0 +1,268 @@
+package davutil
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/icholy/digest"
+)
+
+// digestHandler is a test server requiring HTTP Digest auth. It records the
+// auth scheme of every request and validates digest responses against the
+// current challenge, re-challenging with stale=true on nonce mismatch.
+type digestHandler struct {
+ mu sync.Mutex
+ chal *digest.Challenge
+ username string
+ password string
+ schemes []string // per-request: "none", "basic", "digest"
+ lastBody string
+ staleSent bool
+}
+
+func newDigestHandler(algorithm, username, password string) *digestHandler {
+ return &digestHandler{
+ chal: &digest.Challenge{
+ Realm: "TestDAV",
+ Nonce: "nonce-1",
+ Opaque: "opaque-1",
+ QOP: []string{"auth"},
+ Algorithm: algorithm,
+ },
+ username: username,
+ password: password,
+ }
+}
+
+// rotateNonce simulates server-side nonce expiry.
+func (h *digestHandler) rotateNonce(nonce string) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.chal.Nonce = nonce
+}
+
+func (h *digestHandler) scheme(r *http.Request) string {
+ auth := r.Header.Get("Authorization")
+ switch {
+ case auth == "":
+ return "none"
+ case strings.HasPrefix(auth, "Basic "):
+ return "basic"
+ case digest.IsDigest(auth):
+ return "digest"
+ default:
+ return "other"
+ }
+}
+
+func (h *digestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.schemes = append(h.schemes, h.scheme(r))
+ if r.Body != nil {
+ body, _ := io.ReadAll(r.Body)
+ if len(body) > 0 {
+ h.lastBody = string(body)
+ }
+ }
+
+ if h.validDigest(r) {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ // Re-challenge. Mark stale when the client presented a digest with an
+ // outdated nonce (it should retry without re-prompting for credentials).
+ chal := *h.chal
+ if cred, err := digest.ParseCredentials(r.Header.Get("Authorization")); err == nil && cred.Nonce != h.chal.Nonce {
+ chal.Stale = true
+ h.staleSent = true
+ }
+ w.Header().Set("WWW-Authenticate", chal.String())
+ w.WriteHeader(http.StatusUnauthorized)
+}
+
+func (h *digestHandler) validDigest(r *http.Request) bool {
+ auth := r.Header.Get("Authorization")
+ if !digest.IsDigest(auth) {
+ return false
+ }
+ cred, err := digest.ParseCredentials(auth)
+ if err != nil {
+ return false
+ }
+ if cred.Nonce != h.chal.Nonce {
+ return false
+ }
+ expected, err := digest.Digest(h.chal, digest.Options{
+ Method: r.Method,
+ URI: cred.URI,
+ Username: h.username,
+ Password: h.password,
+ Cnonce: cred.Cnonce,
+ Count: cred.Nc,
+ })
+ if err != nil {
+ return false
+ }
+ return expected.Response == cred.Response
+}
+
+func TestBasicDigestTransport_DigestServer(t *testing.T) {
+ h := newDigestHandler("", "alice", "secret") // no algorithm = MD5
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ client := NewBasicDigestHTTPClient("alice", "secret", 5*time.Second)
+
+ // First request: preemptive Basic → 401 challenge → digest retry → 200.
+ resp, err := client.Get(srv.URL + "/dav/")
+ if err != nil {
+ t.Fatalf("first request: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("first request status = %d, want 200", resp.StatusCode)
+ }
+ if want := []string{"basic", "digest"}; strings.Join(h.schemes, ",") != strings.Join(want, ",") {
+ t.Fatalf("server saw schemes %v, want %v", h.schemes, want)
+ }
+
+ // Second request: cached challenge → digest first-try, single request.
+ resp, err = client.Get(srv.URL + "/dav/")
+ if err != nil {
+ t.Fatalf("second request: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("second request status = %d, want 200", resp.StatusCode)
+ }
+ if len(h.schemes) != 3 || h.schemes[2] != "digest" {
+ t.Fatalf("server saw schemes %v, want a single digest third request", h.schemes)
+ }
+}
+
+func TestBasicDigestTransport_SHA256(t *testing.T) {
+ h := newDigestHandler("SHA-256", "alice", "secret")
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ client := NewBasicDigestHTTPClient("alice", "secret", 5*time.Second)
+ resp, err := client.Get(srv.URL + "/dav/")
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+}
+
+func TestBasicDigestTransport_BasicServerUnchanged(t *testing.T) {
+ var schemes []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ user, pass, ok := r.BasicAuth()
+ schemes = append(schemes, r.Header.Get("Authorization"))
+ if !ok || user != "alice" || pass != "secret" {
+ w.Header().Set("WWW-Authenticate", `Basic realm="TestDAV"`)
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ client := NewBasicDigestHTTPClient("alice", "secret", 5*time.Second)
+ resp, err := client.Get(srv.URL + "/dav/")
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if len(schemes) != 1 || !strings.HasPrefix(schemes[0], "Basic ") {
+ t.Fatalf("server saw %v, want exactly one Basic request", schemes)
+ }
+}
+
+func TestBasicDigestTransport_WrongPassword(t *testing.T) {
+ h := newDigestHandler("", "alice", "secret")
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ client := NewBasicDigestHTTPClient("alice", "wrong", 5*time.Second)
+ resp, err := client.Get(srv.URL + "/dav/")
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want 401 passthrough", resp.StatusCode)
+ }
+ // Exactly one digest attempt after the basic one — no retry loop.
+ if want := []string{"basic", "digest"}; strings.Join(h.schemes, ",") != strings.Join(want, ",") {
+ t.Fatalf("server saw schemes %v, want %v", h.schemes, want)
+ }
+}
+
+func TestBasicDigestTransport_StaleNonce(t *testing.T) {
+ h := newDigestHandler("", "alice", "secret")
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ client := NewBasicDigestHTTPClient("alice", "secret", 5*time.Second)
+ resp, err := client.Get(srv.URL + "/dav/")
+ if err != nil {
+ t.Fatalf("first request: %v", err)
+ }
+ resp.Body.Close()
+
+ // Server expires the nonce; the cached challenge is now stale.
+ h.rotateNonce("nonce-2")
+
+ resp, err = client.Get(srv.URL + "/dav/")
+ if err != nil {
+ t.Fatalf("post-rotation request: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("post-rotation status = %d, want 200 after stale re-challenge", resp.StatusCode)
+ }
+ if !h.staleSent {
+ t.Fatal("server never sent a stale=true challenge — test setup broken")
+ }
+ // basic, digest (initial), digest (stale nonce-1), digest (fresh nonce-2)
+ if want := []string{"basic", "digest", "digest", "digest"}; strings.Join(h.schemes, ",") != strings.Join(want, ",") {
+ t.Fatalf("server saw schemes %v, want %v", h.schemes, want)
+ }
+}
+
+func TestBasicDigestTransport_BodyReplay(t *testing.T) {
+ h := newDigestHandler("", "alice", "secret")
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ client := NewBasicDigestHTTPClient("alice", "secret", 5*time.Second)
+ const propfind = ` `
+ req, err := http.NewRequest("PROPFIND", srv.URL+"/dav/", strings.NewReader(propfind))
+ if err != nil {
+ t.Fatalf("build request: %v", err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if h.lastBody != propfind {
+ t.Fatalf("digest retry body = %q, want %q", h.lastBody, propfind)
+ }
+}
diff --git a/internal/kit/davutil/principal.go b/internal/kit/davutil/principal.go
new file mode 100644
index 00000000..7fbe3243
--- /dev/null
+++ b/internal/kit/davutil/principal.go
@@ -0,0 +1,92 @@
+package davutil
+
+import (
+ "context"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+)
+
+// httpDoer is the minimal client surface the principal probe needs — both
+// *http.Client and go-webdav's HTTPClient satisfy it.
+type httpDoer interface {
+ Do(req *http.Request) (*http.Response, error)
+}
+
+const principalPropfindBody = `
+ `
+
+// FindCurrentUserPrincipalRaw issues a Depth:0 PROPFIND for
+// DAV:current-user-principal against rawURL EXACTLY as given. go-webdav's own
+// principal probe resolves its request path with path.Join, which strips a
+// trailing slash — on servers that serve the collection only at "/dav/"
+// (SabreDAV/Davis, #363) that turns into a 404. This probe never rewrites the
+// path; when rawURL lacks a trailing slash and the exact attempt fails, it
+// retries once with the slash appended. Returns the principal href.
+func FindCurrentUserPrincipalRaw(ctx context.Context, client httpDoer, rawURL string) (string, error) {
+ principal, err := principalPropfind(ctx, client, rawURL)
+ if err == nil {
+ return principal, nil
+ }
+ if strings.HasSuffix(rawURL, "/") {
+ return "", err
+ }
+ principal, retryErr := principalPropfind(ctx, client, rawURL+"/")
+ if retryErr != nil {
+ // The exact-URL error is the more representative one.
+ return "", err
+ }
+ return principal, nil
+}
+
+func principalPropfind(ctx context.Context, client httpDoer, rawURL string) (string, error) {
+ req, err := http.NewRequestWithContext(ctx, "PROPFIND", rawURL, strings.NewReader(principalPropfindBody))
+ if err != nil {
+ return "", fmt.Errorf("build principal PROPFIND: %w", err)
+ }
+ req.Header.Set("Depth", "0")
+ req.Header.Set("Content-Type", "application/xml")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("principal PROPFIND: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusMultiStatus {
+ io.Copy(io.Discard, resp.Body) //nolint:errcheck // best-effort drain
+ return "", fmt.Errorf("principal PROPFIND at %s: %s", rawURL, resp.Status)
+ }
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
+ if err != nil {
+ return "", fmt.Errorf("read principal PROPFIND response: %w", err)
+ }
+
+ var ms struct {
+ XMLName xml.Name `xml:"DAV: multistatus"`
+ Response []struct {
+ Propstat []struct {
+ Prop struct {
+ CurrentUserPrincipal struct {
+ Href string `xml:"href"`
+ } `xml:"current-user-principal"`
+ } `xml:"prop"`
+ } `xml:"propstat"`
+ } `xml:"response"`
+ }
+ if err := xml.Unmarshal(body, &ms); err != nil {
+ return "", fmt.Errorf("parse principal PROPFIND response: %w", err)
+ }
+ for _, r := range ms.Response {
+ for _, ps := range r.Propstat {
+ href := strings.TrimSpace(ps.Prop.CurrentUserPrincipal.Href)
+ if href != "" {
+ return href, nil
+ }
+ }
+ }
+ return "", fmt.Errorf("no current-user-principal in PROPFIND response from %s", rawURL)
+}
diff --git a/internal/kit/davutil/principal_test.go b/internal/kit/davutil/principal_test.go
new file mode 100644
index 00000000..7992abd2
--- /dev/null
+++ b/internal/kit/davutil/principal_test.go
@@ -0,0 +1,87 @@
+package davutil
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+// Reporter's exact multistatus body from issue #363 (SabreDAV/Davis).
+const davisPrincipalBody = `
+
+
+ /dav/
+
+ /dav/principals/someuser/
+ HTTP/1.1 200 OK
+
+
+ `
+
+// strictSlashServer replicates #363: the collection answers only at /dav/
+// (with trailing slash); /dav is a 404.
+func strictSlashServer() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/dav/" || r.Method != "PROPFIND" {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+ w.WriteHeader(http.StatusMultiStatus)
+ w.Write([]byte(davisPrincipalBody)) //nolint:errcheck
+ })
+}
+
+func TestFindCurrentUserPrincipalRaw(t *testing.T) {
+ srv := httptest.NewServer(strictSlashServer())
+ defer srv.Close()
+ client := &http.Client{Timeout: 5 * time.Second}
+
+ tests := []struct {
+ name string
+ url string
+ }{
+ {"trailing slash preserved", srv.URL + "/dav/"},
+ {"missing slash retried with slash", srv.URL + "/dav"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ principal, err := FindCurrentUserPrincipalRaw(context.Background(), client, tt.url)
+ if err != nil {
+ t.Fatalf("FindCurrentUserPrincipalRaw(%s): %v", tt.url, err)
+ }
+ if principal != "/dav/principals/someuser/" {
+ t.Errorf("principal = %q, want /dav/principals/someuser/", principal)
+ }
+ })
+ }
+}
+
+func TestFindCurrentUserPrincipalRaw_Errors(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/html/", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte("login")) //nolint:errcheck
+ })
+ mux.HandleFunc("/garbage/", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusMultiStatus)
+ w.Write([]byte("not xml at all")) //nolint:errcheck
+ })
+ mux.HandleFunc("/empty/", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusMultiStatus)
+ w.Write([]byte(` `)) //nolint:errcheck
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+ client := &http.Client{Timeout: 5 * time.Second}
+
+ for _, path := range []string{"/html/", "/garbage/", "/empty/"} {
+ if _, err := FindCurrentUserPrincipalRaw(context.Background(), client, srv.URL+path); err == nil {
+ t.Errorf("probe of %s succeeded, want error", path)
+ }
+ }
+}
diff --git a/internal/kit/davutil/redirect.go b/internal/kit/davutil/redirect.go
new file mode 100644
index 00000000..66819b3e
--- /dev/null
+++ b/internal/kit/davutil/redirect.go
@@ -0,0 +1,94 @@
+package davutil
+
+import (
+ "io"
+ "net/http"
+)
+
+// redirectTransport preserves the request method across same-host redirects
+// for the read-only DAV discovery/query methods. Go's net/http downgrades any
+// method to GET on 301/302/303 responses, so a server redirecting
+// `.well-known/caldav` → `/dav/` never sees the follow-up PROPFIND and
+// discovery dead-ends on a non-207 response (#363). Scope is deliberately
+// narrow: only PROPFIND and REPORT are replayed, only to the same scheme+host
+// (no credential leakage), with a bounded hop count. Everything else — other
+// methods, 303 (spec-mandated GET), cross-host Locations — passes through
+// untouched so stdlib redirect semantics stay byte-identical for existing
+// flows.
+type redirectTransport struct {
+ base http.RoundTripper
+}
+
+const redirectMaxHops = 5
+
+func methodPreservesRedirect(method string) bool {
+ switch method {
+ case "PROPFIND", "REPORT":
+ return true
+ }
+ return false
+}
+
+func statusPreservesRedirect(code int) bool {
+ switch code {
+ case http.StatusMovedPermanently, http.StatusFound,
+ http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
+ return true
+ }
+ return false
+}
+
+// RoundTrip implements http.RoundTripper.
+func (t *redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ base := t.base
+ if base == nil {
+ base = defaultBaseTransport()
+ }
+ if !methodPreservesRedirect(req.Method) {
+ return base.RoundTrip(req)
+ }
+
+ current := req
+ for hop := 0; hop < redirectMaxHops; hop++ {
+ resp, err := base.RoundTrip(current)
+ if err != nil {
+ return resp, err
+ }
+ if !statusPreservesRedirect(resp.StatusCode) {
+ return resp, nil
+ }
+ location, err := resp.Location()
+ if err != nil {
+ return resp, nil
+ }
+ if location.Scheme != current.URL.Scheme || location.Host != current.URL.Host {
+ // Cross-host (or scheme-changing) redirect — hand it back so the
+ // stdlib client applies its usual semantics.
+ return resp, nil
+ }
+ // A consumed body must be replayable for the next hop.
+ if current.Body != nil && current.GetBody == nil {
+ return resp, nil
+ }
+
+ io.Copy(io.Discard, resp.Body) //nolint:errcheck // best-effort drain for connection reuse
+ resp.Body.Close()
+
+ next := current.Clone(current.Context())
+ next.URL = location
+ next.Host = ""
+ if current.GetBody != nil {
+ body, bodyErr := current.GetBody()
+ if bodyErr != nil {
+ return nil, bodyErr
+ }
+ next.Body = body
+ next.GetBody = current.GetBody
+ }
+ current = next
+ }
+
+ // Hop limit exhausted — issue the final request and return whatever comes
+ // back (a persistent redirect loop surfaces as the redirect response).
+ return base.RoundTrip(current)
+}
diff --git a/internal/kit/davutil/redirect_test.go b/internal/kit/davutil/redirect_test.go
new file mode 100644
index 00000000..c37ee2f9
--- /dev/null
+++ b/internal/kit/davutil/redirect_test.go
@@ -0,0 +1,160 @@
+package davutil
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+// redirectDAVServer 302-redirects /.well-known/dav to /dav/ and answers
+// PROPFIND /dav/ with 207. Records every (method, path, depth) it sees.
+type redirectDAVServer struct {
+ requests []string
+ bodies []string
+}
+
+func (h *redirectDAVServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ h.requests = append(h.requests, r.Method+" "+r.URL.Path+" depth="+r.Header.Get("Depth"))
+ h.bodies = append(h.bodies, string(body))
+
+ switch r.URL.Path {
+ case "/.well-known/dav":
+ http.Redirect(w, r, "/dav/", http.StatusFound)
+ case "/see-other":
+ http.Redirect(w, r, "/dav/", http.StatusSeeOther)
+ case "/loop":
+ http.Redirect(w, r, "/loop", http.StatusFound)
+ case "/dav/":
+ if r.Method != "PROPFIND" {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusMultiStatus)
+ w.Write([]byte(` `)) //nolint:errcheck
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+}
+
+func davRequest(t *testing.T, method, url string) *http.Request {
+ t.Helper()
+ req, err := http.NewRequest(method, url, strings.NewReader(` `))
+ if err != nil {
+ t.Fatalf("build request: %v", err)
+ }
+ req.Header.Set("Depth", "0")
+ return req
+}
+
+func TestRedirectTransport_ReplaysPropfind(t *testing.T) {
+ h := &redirectDAVServer{}
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ client := NewWebDAVClient(nil, 5*time.Second)
+ resp, err := client.Do(davRequest(t, "PROPFIND", srv.URL+"/.well-known/dav"))
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusMultiStatus {
+ t.Fatalf("status = %d, want 207", resp.StatusCode)
+ }
+ want := []string{
+ "PROPFIND /.well-known/dav depth=0",
+ "PROPFIND /dav/ depth=0",
+ }
+ if strings.Join(h.requests, ",") != strings.Join(want, ",") {
+ t.Fatalf("server saw %v, want %v", h.requests, want)
+ }
+ if h.bodies[1] == "" {
+ t.Fatal("redirected PROPFIND arrived without its body")
+ }
+}
+
+func TestRedirectTransport_SeeOtherUntouched(t *testing.T) {
+ h := &redirectDAVServer{}
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ // 303 must keep stdlib semantics (downgrade to GET) — our transport passes
+ // it through, the stdlib client follows with GET, /dav/ answers 405.
+ client := NewWebDAVClient(nil, 5*time.Second)
+ resp, err := client.Do(davRequest(t, "PROPFIND", srv.URL+"/see-other"))
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusMethodNotAllowed {
+ t.Fatalf("status = %d, want 405 (GET downgrade preserved for 303)", resp.StatusCode)
+ }
+ if !strings.HasPrefix(h.requests[len(h.requests)-1], "GET /dav/") {
+ t.Fatalf("expected stdlib GET follow-up, server saw %v", h.requests)
+ }
+}
+
+func TestRedirectTransport_CrossHostUntouched(t *testing.T) {
+ other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusTeapot)
+ }))
+ defer other.Close()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, other.URL+"/dav/", http.StatusFound)
+ }))
+ defer srv.Close()
+
+ // Cross-host: our transport must not replay; stdlib follows (as GET).
+ client := NewWebDAVClient(nil, 5*time.Second)
+ resp, err := client.Do(davRequest(t, "PROPFIND", srv.URL+"/dav"))
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusTeapot {
+ t.Fatalf("status = %d, want 418 from cross-host follow", resp.StatusCode)
+ }
+}
+
+func TestRedirectTransport_HopLimitTerminates(t *testing.T) {
+ h := &redirectDAVServer{}
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ // /loop 302s to itself forever. Our transport gives up after its hop
+ // budget and hands the redirect back; the stdlib client then applies its
+ // own (GET-downgrading, also bounded) redirect handling. The request must
+ // terminate with an error or response, not hang.
+ client := NewWebDAVClient(nil, 5*time.Second)
+ resp, err := client.Do(davRequest(t, "PROPFIND", srv.URL+"/loop"))
+ if err == nil {
+ resp.Body.Close()
+ }
+}
+
+func TestRedirectTransport_WithDigestAuth(t *testing.T) {
+ // Compose redirect + digest: /.well-known/dav 302s to /dav/, which then
+ // digest-challenges. The replayed hop must re-enter the auth transport.
+ dh := newDigestHandler("", "alice", "secret")
+ mux := http.NewServeMux()
+ mux.HandleFunc("/.well-known/dav", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/dav/", http.StatusFound)
+ })
+ mux.Handle("/dav/", dh)
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ client := NewBasicDigestHTTPClient("alice", "secret", 5*time.Second)
+ resp, err := client.Do(davRequest(t, "PROPFIND", srv.URL+"/.well-known/dav"))
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 after redirect + digest handshake", resp.StatusCode)
+ }
+}
diff --git a/internal/kit/davutil/xmlfix.go b/internal/kit/davutil/xmlfix.go
index 64c8f881..8052b2f5 100644
--- a/internal/kit/davutil/xmlfix.go
+++ b/internal/kit/davutil/xmlfix.go
@@ -82,16 +82,23 @@ func NewHTTPClient(timeout time.Duration) *http.Client {
return NewWebDAVClient(defaultBaseTransport(), timeout)
}
-// NewWebDAVClient wraps base in XMLFixTransport and returns an *http.Client
-// (which satisfies go-webdav's HTTPClient interface). base is the inner
-// transport — http.DefaultTransport for unauthenticated/Basic use, or an
-// auth-injecting transport (e.g. bearerTransport, or the auth broker's
+// NewWebDAVClient wraps base in the shared transport chain and returns an
+// *http.Client (which satisfies go-webdav's HTTPClient interface). base is
+// the inner transport — http.DefaultTransport for unauthenticated/Basic use,
+// or an auth-injecting transport (e.g. bearerTransport, or the auth broker's
// refreshing transport) when the caller supplies one. If base is nil,
// http.DefaultTransport is used.
+//
+// Chain (outer→inner): XMLFix → method-preserving redirect → auth/base. The
+// redirect transport sits outside auth so a replayed hop re-enters
+// Basic/Digest/Bearer injection.
func NewWebDAVClient(base http.RoundTripper, timeout time.Duration) *http.Client {
+ if base == nil {
+ base = defaultBaseTransport()
+ }
return &http.Client{
Timeout: timeout,
- Transport: NewXMLFixTransport(base),
+ Transport: NewXMLFixTransport(&redirectTransport{base: base}),
}
}
diff --git a/internal/platform/autostart_darwin.go b/internal/platform/autostart_darwin.go
index ba1da7b9..8a5ab884 100644
--- a/internal/platform/autostart_darwin.go
+++ b/internal/platform/autostart_darwin.go
@@ -9,13 +9,13 @@ import (
)
const (
- launchAgentLabel = "io.github.hkdb.aerion"
+ launchAgentLabel = "io.github.hkdb.Aerion"
launchAgentPlist = `
Label
- io.github.hkdb.aerion
+ io.github.hkdb.Aerion
ProgramArguments
%s
diff --git a/internal/platform/autostart_linux.go b/internal/platform/autostart_linux.go
index a150e7d8..895ed948 100644
--- a/internal/platform/autostart_linux.go
+++ b/internal/platform/autostart_linux.go
@@ -12,7 +12,7 @@ import (
)
const (
- autostartFilename = "io.github.hkdb.aerion.desktop"
+ autostartFilename = "io.github.hkdb.Aerion.desktop"
desktopEntryTmpl = `[Desktop Entry]
Type=Application
Name=Aerion
diff --git a/internal/settings/store.go b/internal/settings/store.go
index e7e5c06f..3ddf85d7 100644
--- a/internal/settings/store.go
+++ b/internal/settings/store.go
@@ -36,6 +36,7 @@ const (
KeyAccentBarUnread = "accent_bar_unread"
KeyShowMessageListCircles = "show_message_list_circles"
KeyShowMessageListProfilePics = "show_message_list_profile_pics" // render contact photos in the message-list avatar slot (default off)
+ KeyAlwaysShowMessageCheckbox = "always_show_message_checkbox" // reserve a fixed checkbox column instead of the hover/swipe slide-reveal (default off)
KeyShowViewerCircles = "show_viewer_circles"
KeyLastSeenVersion = "last_seen_version" // for "What's new in this version" launch dialog
KeyOAuthWarningDisabled = "oauth_warning_disabled" // user toggled "Don't show again" on the missing-OAuth-creds launch warning
@@ -342,6 +343,26 @@ func (s *Store) SetShowMessageListProfilePics(enabled bool) error {
return s.Set(KeyShowMessageListProfilePics, v)
}
+// GetAlwaysShowMessageCheckbox returns whether the message list reserves a
+// fixed checkbox column (legacy layout) instead of the hover/swipe
+// slide-reveal. Default: false.
+func (s *Store) GetAlwaysShowMessageCheckbox() (bool, error) {
+ value, err := s.Get(KeyAlwaysShowMessageCheckbox)
+ if err != nil {
+ return false, err
+ }
+ return value == "true", nil
+}
+
+// SetAlwaysShowMessageCheckbox enables or disables the always-reserved checkbox column
+func (s *Store) SetAlwaysShowMessageCheckbox(enabled bool) error {
+ v := "false"
+ if enabled {
+ v = "true"
+ }
+ return s.Set(KeyAlwaysShowMessageCheckbox, v)
+}
+
// GetShowViewerCircles returns whether colored sender circles
// are shown in the conversation viewer. Default: true.
func (s *Store) GetShowViewerCircles() (bool, error) {
diff --git a/internal/smtp/client.go b/internal/smtp/client.go
index 78fd0ce5..c2cfa2b1 100644
--- a/internal/smtp/client.go
+++ b/internal/smtp/client.go
@@ -63,6 +63,11 @@ type ClientConfig struct {
AuthType AuthType // "password" or "oauth2" (defaults to "password")
AccessToken string // OAuth2 access token (when AuthType is "oauth2")
+ // AuthMechanism selects the password SASL mechanism: "plain", "login", or
+ // ""/"auto" (pick from the server's EHLO AUTH advertisement). Ignored for
+ // OAuth2.
+ AuthMechanism string
+
// Timeouts
ConnectTimeout time.Duration
ReadTimeout time.Duration
@@ -226,21 +231,63 @@ func (c *Client) Login() error {
return nil
}
-// loginPassword authenticates using password (PLAIN or LOGIN mechanism)
+// loginPassword authenticates using password (PLAIN or LOGIN mechanism). A
+// configured AuthMechanism forces that mechanism; otherwise the choice honors
+// the server's EHLO AUTH advertisement. Some servers (e.g. advertising only
+// "GSSAPI NTLM LOGIN") drop the connection on a rejected AUTH PLAIN, which
+// would make a post-failure LOGIN fallback hit a closed socket (#355).
func (c *Client) loginPassword() error {
- // Try PLAIN first, then LOGIN
+ switch strings.ToLower(c.config.AuthMechanism) {
+ case "plain":
+ return c.authPlain()
+ case "login":
+ return c.authLogin()
+ }
+
+ _, mechanisms := c.client.Extension("AUTH")
+ if !mechanismAdvertised(mechanisms, "PLAIN") && mechanismAdvertised(mechanisms, "LOGIN") {
+ c.log.Debug().Msg("PLAIN not advertised, using LOGIN")
+ return c.authLogin()
+ }
+
+ // PLAIN advertised — or neither PLAIN nor LOGIN advertised (best-effort for
+ // servers with broken advertisements): try PLAIN first, fall back to LOGIN.
+ if err := c.authPlain(); err != nil {
+ c.log.Debug().Msg("PLAIN auth failed, trying LOGIN")
+ return c.authLogin()
+ }
+ return nil
+}
+
+// authPlain authenticates with the PLAIN mechanism.
+func (c *Client) authPlain() error {
auth := smtp.PlainAuth("", c.config.Username, c.config.Password, c.config.Host)
if err := c.client.Auth(auth); err != nil {
- // Try LOGIN auth as fallback
- c.log.Debug().Msg("PLAIN auth failed, trying LOGIN")
- auth := LoginAuth(c.config.Username, c.config.Password)
- if err := c.client.Auth(auth); err != nil {
- return fmt.Errorf("authentication failed: %w", err)
- }
+ return fmt.Errorf("authentication failed: %w", err)
+ }
+ return nil
+}
+
+// authLogin authenticates with the LOGIN mechanism.
+func (c *Client) authLogin() error {
+ auth := LoginAuth(c.config.Username, c.config.Password)
+ if err := c.client.Auth(auth); err != nil {
+ return fmt.Errorf("authentication failed: %w", err)
}
return nil
}
+// mechanismAdvertised reports whether name appears in the space-separated EHLO
+// AUTH parameter list (case-insensitive).
+func mechanismAdvertised(mechanisms, name string) bool {
+ for _, m := range strings.Fields(mechanisms) {
+ if strings.EqualFold(m, name) {
+ return true
+ }
+ }
+ return false
+}
+
// loginOAuth2 authenticates using OAuth2 XOAUTH2 mechanism
func (c *Client) loginOAuth2() error {
if c.config.AccessToken == "" {
diff --git a/internal/smtp/client_test.go b/internal/smtp/client_test.go
new file mode 100644
index 00000000..0dd25da0
--- /dev/null
+++ b/internal/smtp/client_test.go
@@ -0,0 +1,215 @@
+package smtp
+
+import (
+ "bufio"
+ "fmt"
+ "net"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestMechanismAdvertised(t *testing.T) {
+ tests := []struct {
+ name string
+ mechanisms string
+ mechanism string
+ want bool
+ }{
+ {"login-only server advertises LOGIN", "GSSAPI NTLM LOGIN", "LOGIN", true},
+ {"login-only server does not advertise PLAIN", "GSSAPI NTLM LOGIN", "PLAIN", false},
+ {"plain and login both advertised: PLAIN", "PLAIN LOGIN", "PLAIN", true},
+ {"plain and login both advertised: LOGIN", "PLAIN LOGIN", "LOGIN", true},
+ {"empty advertisement", "", "PLAIN", false},
+ {"case-insensitive match", "login plain", "PLAIN", true},
+ {"no substring false positive", "XPLAIN", "PLAIN", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := mechanismAdvertised(tt.mechanisms, tt.mechanism)
+ if got != tt.want {
+ t.Errorf("mechanismAdvertised(%q, %q) = %v, want %v", tt.mechanisms, tt.mechanism, got, tt.want)
+ }
+ })
+ }
+}
+
+// fakeSMTPServer speaks just enough SMTP to accept one connection, advertise
+// the given AUTH mechanisms on EHLO, and accept any AUTH attempt (or reject
+// every attempt when rejectAuth is set). It records each AUTH command line it
+// receives on authCmd.
+func fakeSMTPServer(t *testing.T, authAdvert string, rejectAuth bool, authCmd chan<- string) net.Listener {
+ t.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ go func() {
+ conn, acceptErr := ln.Accept()
+ if acceptErr != nil {
+ return
+ }
+ defer conn.Close()
+ r := bufio.NewReader(conn)
+ fmt.Fprintf(conn, "220 fake ESMTP ready\r\n")
+ for {
+ line, readErr := r.ReadString('\n')
+ if readErr != nil {
+ return
+ }
+ cmd := strings.ToUpper(strings.TrimSpace(line))
+ switch {
+ case strings.HasPrefix(cmd, "EHLO"):
+ fmt.Fprintf(conn, "250-fake\r\n250-AUTH %s\r\n250 8BITMIME\r\n", authAdvert)
+ case strings.HasPrefix(cmd, "AUTH LOGIN"):
+ authCmd <- strings.TrimSpace(line)
+ if rejectAuth {
+ fmt.Fprintf(conn, "504 5.5.4 Unrecognized authentication type\r\n")
+ continue
+ }
+ // Base64 "Username:" / "Password:" challenges
+ fmt.Fprintf(conn, "334 VXNlcm5hbWU6\r\n")
+ if _, err := r.ReadString('\n'); err != nil {
+ return
+ }
+ fmt.Fprintf(conn, "334 UGFzc3dvcmQ6\r\n")
+ if _, err := r.ReadString('\n'); err != nil {
+ return
+ }
+ fmt.Fprintf(conn, "235 2.7.0 Authentication successful\r\n")
+ case strings.HasPrefix(cmd, "AUTH PLAIN"):
+ authCmd <- strings.TrimSpace(line)
+ if rejectAuth {
+ fmt.Fprintf(conn, "504 5.5.4 Unrecognized authentication type\r\n")
+ continue
+ }
+ fmt.Fprintf(conn, "235 2.7.0 Authentication successful\r\n")
+ case strings.HasPrefix(cmd, "QUIT"):
+ fmt.Fprintf(conn, "221 bye\r\n")
+ return
+ default:
+ fmt.Fprintf(conn, "250 ok\r\n")
+ }
+ }
+ }()
+ return ln
+}
+
+// TestLoginMechanismSelection verifies which SASL mechanism the client puts on
+// the wire, per advertisement and per manual override (#355).
+func TestLoginMechanismSelection(t *testing.T) {
+ tests := []struct {
+ name string
+ authAdvert string
+ authMechanism string
+ wantCmd string
+ }{
+ {"auto picks LOGIN when PLAIN not advertised", "GSSAPI NTLM LOGIN", "", "AUTH LOGIN"},
+ {"auto picks PLAIN when advertised", "PLAIN LOGIN", "", "AUTH PLAIN"},
+ {"manual login override beats PLAIN advertisement", "PLAIN LOGIN", "login", "AUTH LOGIN"},
+ {"manual plain override", "GSSAPI NTLM LOGIN", "plain", "AUTH PLAIN"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ authCmd := make(chan string, 1)
+ ln := fakeSMTPServer(t, tt.authAdvert, false, authCmd)
+ defer ln.Close()
+
+ addr := ln.Addr().(*net.TCPAddr)
+ config := DefaultConfig()
+ config.Host = "127.0.0.1"
+ config.Port = addr.Port
+ config.Security = SecurityNone
+ config.Username = "user"
+ config.Password = "pass"
+ config.AuthMechanism = tt.authMechanism
+ config.ConnectTimeout = 5 * time.Second
+ config.ReadTimeout = 5 * time.Second
+ config.WriteTimeout = 5 * time.Second
+
+ client := NewClient(config)
+ if err := client.Connect(); err != nil {
+ t.Fatalf("connect: %v", err)
+ }
+ defer client.Close()
+ if err := client.Login(); err != nil {
+ t.Fatalf("login: %v", err)
+ }
+
+ select {
+ case got := <-authCmd:
+ if !strings.HasPrefix(strings.ToUpper(got), tt.wantCmd) {
+ t.Errorf("server received %q, want prefix %q", got, tt.wantCmd)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("no AUTH command received")
+ }
+ })
+ }
+}
+
+// TestForcedMechanismFailsWithoutFallback verifies that a manually forced
+// mechanism which the server rejects produces a clean authentication error and
+// does NOT fall back to another mechanism.
+func TestForcedMechanismFailsWithoutFallback(t *testing.T) {
+ tests := []struct {
+ name string
+ authMechanism string
+ wantCmd string
+ }{
+ {"forced LOGIN rejected", "login", "AUTH LOGIN"},
+ {"forced PLAIN rejected", "plain", "AUTH PLAIN"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ authCmd := make(chan string, 4)
+ ln := fakeSMTPServer(t, "GSSAPI NTLM", true, authCmd)
+ defer ln.Close()
+
+ addr := ln.Addr().(*net.TCPAddr)
+ config := DefaultConfig()
+ config.Host = "127.0.0.1"
+ config.Port = addr.Port
+ config.Security = SecurityNone
+ config.Username = "user"
+ config.Password = "pass"
+ config.AuthMechanism = tt.authMechanism
+ config.ConnectTimeout = 5 * time.Second
+ config.ReadTimeout = 5 * time.Second
+ config.WriteTimeout = 5 * time.Second
+
+ client := NewClient(config)
+ if err := client.Connect(); err != nil {
+ t.Fatalf("connect: %v", err)
+ }
+ defer client.Close()
+
+ err := client.Login()
+ if err == nil {
+ t.Fatal("Login() succeeded, want authentication error")
+ }
+ if !strings.Contains(err.Error(), "authentication failed") {
+ t.Errorf("error = %q, want it to contain %q", err, "authentication failed")
+ }
+
+ // Exactly one AUTH attempt — the forced mechanism, no fallback.
+ select {
+ case got := <-authCmd:
+ if !strings.HasPrefix(strings.ToUpper(got), tt.wantCmd) {
+ t.Errorf("server received %q, want prefix %q", got, tt.wantCmd)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("no AUTH command received")
+ }
+ select {
+ case extra := <-authCmd:
+ t.Errorf("unexpected second AUTH attempt: %q", extra)
+ case <-time.After(100 * time.Millisecond):
+ // no fallback attempt — expected
+ }
+ })
+ }
+}
diff --git a/internal/sync/engine.go b/internal/sync/engine.go
index 55702dc3..3167ca2a 100644
--- a/internal/sync/engine.go
+++ b/internal/sync/engine.go
@@ -97,6 +97,11 @@ type Engine struct {
// mailboxes on the CONDSTORE fast-path (see runFlagSync / condstore.go).
flagSweepMu gosync.Mutex
flagSweepCounter map[string]int
+
+ // Per-folder serialization of sync runs (see folderlock.go). Header/flag
+ // reconciles and body fetches lock under separate key namespaces so a
+ // header sync never queues behind a long body download.
+ folderLocks folderLocks
}
// NewEngine creates a new sync engine
@@ -111,6 +116,7 @@ func NewEngine(pool *imapPkg.Pool, accountStore *account.Store, folderStore *fol
sanitizer: email.NewSanitizer(),
log: logging.WithComponent("sync"),
flagSweepCounter: map[string]int{},
+ folderLocks: folderLocks{locks: map[string]chan struct{}{}},
}
}
diff --git a/internal/sync/fetch.go b/internal/sync/fetch.go
index 99df5274..eb792acb 100644
--- a/internal/sync/fetch.go
+++ b/internal/sync/fetch.go
@@ -428,6 +428,16 @@ func (e *Engine) FetchBodiesInBackground(ctx context.Context, accountID, folderI
return ctx.Err()
}
+ // Serialize with other body fetches on this folder: two concurrent runs
+ // can both see body_fetched=0 and double-insert attachment rows (plain
+ // INSERT, no unique constraint). Separate key namespace from the header
+ // lock so header reconciles never queue behind body downloads.
+ unlock, err := e.folderLocks.lock(ctx, "body:"+folderID)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+
// Get folder to get path
f, err := e.folderStore.Get(folderID)
if err != nil {
diff --git a/internal/sync/folderlock.go b/internal/sync/folderlock.go
new file mode 100644
index 00000000..dfc8e757
--- /dev/null
+++ b/internal/sync/folderlock.go
@@ -0,0 +1,44 @@
+package sync
+
+import (
+ "context"
+ gosync "sync"
+)
+
+// folderLocks serializes sync runs per folder. Two engine.SyncMessages runs
+// overlapping on one folder corrupt state three ways: duplicate attachment
+// rows (plain INSERT, body_fetched=1 set in a separate transaction),
+// body_fetched resets via the header Upsert's ON CONFLICT clause, and
+// last-writer-wins rollback of the UIDNext/HighestModSeq watermarks. The
+// caller-side tracking maps (app syncContexts, scheduler syncing) are
+// mutually blind and two engine call sites are tracked by neither, so the
+// serialization lives here at the single choke point.
+//
+// Channel-based rather than a mutex so waiters can abandon on context
+// cancellation (a blocked mutex acquire is uncancellable).
+type folderLocks struct {
+ mu gosync.Mutex
+ locks map[string]chan struct{} // key -> held marker (cap-1 channel)
+}
+
+// lock acquires the lock for key, waiting until the current holder releases
+// or ctx is cancelled. On success it returns the release func; on
+// cancellation it returns ctx's error and the lock is untouched.
+func (l *folderLocks) lock(ctx context.Context, key string) (func(), error) {
+ l.mu.Lock()
+ ch, ok := l.locks[key]
+ if !ok {
+ // Entries live for the process (bounded by folder count), matching
+ // flagSweepCounter's lifetime.
+ ch = make(chan struct{}, 1)
+ l.locks[key] = ch
+ }
+ l.mu.Unlock()
+
+ select {
+ case ch <- struct{}{}:
+ return func() { <-ch }, nil
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+}
diff --git a/internal/sync/folderlock_test.go b/internal/sync/folderlock_test.go
new file mode 100644
index 00000000..32e6e722
--- /dev/null
+++ b/internal/sync/folderlock_test.go
@@ -0,0 +1,101 @@
+package sync
+
+import (
+ "context"
+ "errors"
+ gosync "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func newTestLocks() *folderLocks {
+ return &folderLocks{locks: map[string]chan struct{}{}}
+}
+
+func TestFolderLocks_SerializesSameKey(t *testing.T) {
+ l := newTestLocks()
+ var inCritical atomic.Int32
+ var maxSeen atomic.Int32
+ var wg gosync.WaitGroup
+
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ unlock, err := l.lock(context.Background(), "folder-1")
+ if err != nil {
+ t.Errorf("lock: %v", err)
+ return
+ }
+ n := inCritical.Add(1)
+ if n > maxSeen.Load() {
+ maxSeen.Store(n)
+ }
+ time.Sleep(2 * time.Millisecond)
+ inCritical.Add(-1)
+ unlock()
+ }()
+ }
+ wg.Wait()
+
+ if maxSeen.Load() != 1 {
+ t.Errorf("max concurrent holders = %d, want 1", maxSeen.Load())
+ }
+}
+
+func TestFolderLocks_IndependentKeys(t *testing.T) {
+ l := newTestLocks()
+
+ unlockA, err := l.lock(context.Background(), "folder-a")
+ if err != nil {
+ t.Fatalf("lock a: %v", err)
+ }
+ defer unlockA()
+
+ // A held lock on another key must not block this one.
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ unlockB, err := l.lock(ctx, "folder-b")
+ if err != nil {
+ t.Fatalf("lock b blocked by unrelated key: %v", err)
+ }
+ unlockB()
+}
+
+func TestFolderLocks_WaiterCancellation(t *testing.T) {
+ l := newTestLocks()
+
+ unlock, err := l.lock(context.Background(), "folder-1")
+ if err != nil {
+ t.Fatalf("lock: %v", err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() {
+ _, waitErr := l.lock(ctx, "folder-1")
+ done <- waitErr
+ }()
+
+ cancel()
+ select {
+ case waitErr := <-done:
+ if !errors.Is(waitErr, context.Canceled) {
+ t.Errorf("waiter error = %v, want context.Canceled", waitErr)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("cancelled waiter did not return")
+ }
+
+ // The holder's release must still work, and the lock must be acquirable
+ // again after the cancelled wait.
+ unlock()
+ ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second)
+ defer cancel2()
+ unlock2, err := l.lock(ctx2, "folder-1")
+ if err != nil {
+ t.Fatalf("re-acquire after cancelled wait: %v", err)
+ }
+ unlock2()
+}
diff --git a/internal/sync/messages.go b/internal/sync/messages.go
index 2a9dc5b9..6e8f3fd3 100644
--- a/internal/sync/messages.go
+++ b/internal/sync/messages.go
@@ -36,6 +36,14 @@ func (e *Engine) SyncMessages(ctx context.Context, accountID, folderID string, s
return ctx.Err()
}
+ // Serialize with any other header/flag reconcile on this folder — the
+ // caller-side tracking maps don't see each other (see folderlock.go).
+ unlock, err := e.folderLocks.lock(ctx, folderID)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+
// Get folder from store
f, err := e.folderStore.Get(folderID)
if err != nil {
@@ -416,6 +424,14 @@ func (e *Engine) SyncFolderFlags(ctx context.Context, accountID, folderID string
return ctx.Err()
}
+ // Serialize with SyncMessages / other flag reconciles on this folder —
+ // this path writes folder state too (see folderlock.go).
+ unlock, err := e.folderLocks.lock(ctx, folderID)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+
f, err := e.folderStore.Get(folderID)
if err != nil {
return fmt.Errorf("failed to get folder: %w", err)
diff --git a/wails.json b/wails.json
index 53c57c25..a0d1afd2 100644
--- a/wails.json
+++ b/wails.json
@@ -9,7 +9,7 @@
"info": {
"companyName": "Aerion",
"productName": "Aerion",
- "productVersion": "0.3.2",
+ "productVersion": "0.3.3",
"copyright": "Copyright 2024-2025",
"comments": "A modern, cross-platform email client",
"protocols": [