From 48ed1d247b51c484950c0deb37920507b47dc38b Mon Sep 17 00:00:00 2001 From: hkdb Date: Fri, 17 Jul 2026 05:26:52 +0000 Subject: [PATCH 01/27] Bump to v0.3.3-dev --- CHANGELOG.md | 6 ++++++ app/state.go | 2 +- build/flatpak/io.github.hkdb.Aerion.metainfo.xml | 7 +++++++ frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- frontend/package.json.md5 | 2 +- wails.json | 2 +- 7 files changed, 19 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70033ad9..bff44665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # CHANGELOG +**v0.3.3 - 07-30-2026** +--- + + + + **v0.3.2 - 07-16-2026** --- diff --git a/app/state.go b/app/state.go index 6e02f08c..631d211b 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-dev" // AppInfo contains application metadata type AppInfo struct { diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index ba16b57b..7655fa83 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -285,6 +285,13 @@ + + +
    +
  • +
+
+
    diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4ca7dde4..78c8b191 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-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aerion", - "version": "0.3.2", + "version": "0.3.3-dev", "dependencies": { "@iconify/svelte": "^4.2.0", "@tanstack/svelte-virtual": "^3.13.0", diff --git a/frontend/package.json b/frontend/package.json index 818982d3..b4babb4a 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-dev", "type": "module", "scripts": { "predev": "node scripts/copy-dictionaries.mjs", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 1b7fd3fc..d1b0b2ea 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -094a91fee09afbc45deb48c483b6d504 \ No newline at end of file +8fbf2a6f119ff406af7364c118ca8c87 \ No newline at end of file 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": [ From 870197efd3eed86bd1eafc34b805370a9a1554e7 Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 29 Jul 2026 05:45:12 +0000 Subject: [PATCH 02/27] IMAP/SMTP auth fix - #355 --- CHANGELOG.md | 3 +- app/account.go | 1 + app/compose.go | 5 +- app/settings.go | 11 +- .../io.github.hkdb.Aerion.metainfo.xml | 3 +- .../components/settings/AccountDialog.svelte | 10 + .../components/settings/AccountForm.svelte | 45 ++++ .../settings/account/AccountServerTab.svelte | 59 +++++ frontend/src/lib/config/providers.ts | 11 + frontend/src/lib/i18n/locales/cs.json | 1 + frontend/src/lib/i18n/locales/de.json | 1 + frontend/src/lib/i18n/locales/en.json | 1 + frontend/src/lib/i18n/locales/fr.json | 1 + frontend/src/lib/i18n/locales/it.json | 1 + frontend/src/lib/i18n/locales/nb.json | 1 + frontend/src/lib/i18n/locales/vi.json | 1 + frontend/src/lib/i18n/locales/zh-CN.json | 1 + frontend/src/lib/i18n/locales/zh-HK.json | 1 + frontend/src/lib/i18n/locales/zh-TW.json | 1 + frontend/wailsjs/go/app/App.d.ts | 2 +- frontend/wailsjs/go/app/App.js | 4 +- frontend/wailsjs/go/models.ts | 8 + go.mod | 10 +- go.sum | 24 +- internal/account/model.go | 49 ++++ internal/account/model_test.go | 36 +++ internal/account/store.go | 46 ++-- internal/database/database_test.go | 10 +- internal/database/migrations.go | 22 ++ internal/imap/client.go | 41 +++- internal/smtp/client.go | 63 ++++- internal/smtp/client_test.go | 215 ++++++++++++++++++ 32 files changed, 619 insertions(+), 69 deletions(-) create mode 100644 internal/account/model_test.go create mode 100644 internal/smtp/client_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bff44665..184714ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ **v0.3.3 - 07-30-2026** --- - +- Added manual config of IMAP/SMTP auth mech +- Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355) **v0.3.2 - 07-16-2026** 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/compose.go b/app/compose.go index 9c3cee27..570ae755 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 != "" { @@ -889,7 +891,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 +915,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/settings.go b/app/settings.go index d949cc5c..9a20bae4 100644 --- a/app/settings.go +++ b/app/settings.go @@ -445,11 +445,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/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index 7655fa83..5df39f97 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -288,7 +288,8 @@
      -
    • +
    • Added manual config of IMAP/SMTP auth mech
    • +
    • Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355)
    diff --git a/frontend/src/lib/components/settings/AccountDialog.svelte b/frontend/src/lib/components/settings/AccountDialog.svelte index 5fd8fae8..2269bfdf 100644 --- a/frontend/src/lib/components/settings/AccountDialog.svelte +++ b/frontend/src/lib/components/settings/AccountDialog.svelte @@ -64,12 +64,14 @@ let imapHost = $state('') let imapPort = $state(993) let imapSecurity = $state('tls') + let imapAuthMechanism = $state('auto') let smtpHost = $state('') let smtpPort = $state(587) let smtpSecurity = $state('starttls') let noOutgoingServer = $state(false) let smtpUsername = $state('') let smtpPassword = $state('') + let smtpAuthMechanism = $state('auto') let replyForwardIdentityID = $state('') let allIdentityGroups = $state([]) let syncPeriodDays = $state('180') @@ -114,12 +116,14 @@ imapHost = editAccount.imapHost imapPort = editAccount.imapPort imapSecurity = editAccount.imapSecurity + imapAuthMechanism = editAccount.imapAuthMechanism || 'auto' smtpHost = editAccount.smtpHost smtpPort = editAccount.smtpPort smtpSecurity = editAccount.smtpSecurity noOutgoingServer = editAccount.noOutgoingServer || false smtpUsername = editAccount.smtpUsername || '' smtpPassword = '' // never echo a stored password back; blank means "keep existing" + smtpAuthMechanism = editAccount.smtpAuthMechanism || 'auto' replyForwardIdentityID = editAccount.replyForwardIdentityId || '' loadAllIdentityGroups() syncPeriodDays = String(editAccount.syncPeriodDays) @@ -222,12 +226,14 @@ imapHost, imapPort, imapSecurity, + imapAuthMechanism, smtpHost, smtpPort, smtpSecurity, noOutgoingServer, smtpUsername, smtpPassword, // Empty = keep current (when SMTPUsername unchanged) or skip (when toggle is on) + smtpAuthMechanism, replyForwardIdentityId: replyForwardIdentityID, authType, syncPeriodDays: Number(syncPeriodDays), @@ -465,12 +471,14 @@ bind:imapHost bind:imapPort bind:imapSecurity + bind:imapAuthMechanism bind:smtpHost bind:smtpPort bind:smtpSecurity bind:noOutgoingServer bind:smtpUsername bind:smtpPassword + bind:smtpAuthMechanism bind:replyForwardIdentityID {availableIdentityGroups} {isGenericProvider} @@ -487,12 +495,14 @@ onImapHostChange={(v) => imapHost = v} onImapPortChange={(v) => imapPort = v} onImapSecurityChange={(v) => imapSecurity = v} + onImapAuthMechanismChange={(v) => imapAuthMechanism = v} onSmtpHostChange={(v) => smtpHost = v} onSmtpPortChange={(v) => smtpPort = v} onSmtpSecurityChange={(v) => smtpSecurity = v} onNoOutgoingServerChange={(v) => noOutgoingServer = v} onSmtpUsernameChange={(v) => smtpUsername = v} onSmtpPasswordChange={(v) => smtpPassword = v} + onSmtpAuthMechanismChange={(v) => smtpAuthMechanism = v} onReplyForwardIdentityIDChange={(v) => replyForwardIdentityID = v} onSyncIntervalChange={(v) => syncInterval = v} onReadReceiptPolicyChange={(v) => readReceiptRequestPolicy = v} diff --git a/frontend/src/lib/components/settings/AccountForm.svelte b/frontend/src/lib/components/settings/AccountForm.svelte index 8dcf927d..442380d2 100644 --- a/frontend/src/lib/components/settings/AccountForm.svelte +++ b/frontend/src/lib/components/settings/AccountForm.svelte @@ -11,6 +11,7 @@ detectProvider, getCustomProvider, securityOptions, + authMechanismOptions, syncPeriodOptions, syncIntervalOptions, isOAuthProvider, @@ -130,12 +131,14 @@ let imapHost = $state('') let imapPort = $state(993) let imapSecurity = $state('tls') + let imapAuthMechanism = $state('auto') let smtpHost = $state('') let smtpPort = $state(587) let smtpSecurity = $state('starttls') let noOutgoingServer = $state(false) let smtpUsername = $state('') let smtpPassword = $state('') + let smtpAuthMechanism = $state('auto') let smtpUseSameAsIncoming = $state(true) // Auto-mirror IMAP host into SMTP host for new Generic accounts: most // providers use the same hostname or a near-identical subdomain swap, @@ -172,6 +175,10 @@ return securityOptions.find(opt => opt.value === value)?.label || value } + function getAuthMechanismLabel(value: string): string { + return authMechanismOptions.find(opt => opt.value === value)?.label || value + } + function getSyncPeriodLabel(value: string): string { const numValue = Number(value) const option = syncPeriodOptions.find(opt => opt.value === numValue) @@ -256,9 +263,11 @@ imapHost = editAccount.imapHost imapPort = editAccount.imapPort imapSecurity = editAccount.imapSecurity + imapAuthMechanism = editAccount.imapAuthMechanism || 'auto' smtpHost = editAccount.smtpHost smtpPort = editAccount.smtpPort smtpSecurity = editAccount.smtpSecurity + smtpAuthMechanism = editAccount.smtpAuthMechanism || 'auto' syncPeriodDays = String(editAccount.syncPeriodDays) // @ts-ignore - syncInterval from backend syncInterval = String(editAccount.syncInterval ?? 30) @@ -506,12 +515,14 @@ imapHost, imapPort, imapSecurity, + imapAuthMechanism, smtpHost, smtpPort, smtpSecurity, noOutgoingServer, smtpUsername, smtpPassword, + smtpAuthMechanism, replyForwardIdentityId: replyForwardIdentityID, authType: authMethod, syncPeriodDays: Number(syncPeriodDays), @@ -1220,6 +1231,25 @@ + {#if isGenericProvider} +
    +
    + + + + + {getAuthMechanismLabel(imapAuthMechanism)} + + + + {#each authMechanismOptions as opt (opt.value)} + + {/each} + + +
    +
    + {/if} @@ -558,6 +599,24 @@

    {errors.smtpPassword}

    {/if} +
    + + { smtpAuthMechanism = v; onSmtpAuthMechanismChange(v) }} + > + + + {getAuthMechanismLabel(smtpAuthMechanism)} + + + + {#each authMechanismOptions as opt (opt.value)} + + {/each} + + +
    {/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/locales/cs.json b/frontend/src/lib/i18n/locales/cs.json index 21e6072e..0eebfa75 100644 --- a/frontend/src/lib/i18n/locales/cs.json +++ b/frontend/src/lib/i18n/locales/cs.json @@ -595,6 +595,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", diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 26183532..945edac3 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -595,6 +595,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", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index fcfea48a..4bb30cb5 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -597,6 +597,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", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 28bb90fd..c724efba 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -595,6 +595,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", diff --git a/frontend/src/lib/i18n/locales/it.json b/frontend/src/lib/i18n/locales/it.json index d3244f03..d1cbfdf6 100644 --- a/frontend/src/lib/i18n/locales/it.json +++ b/frontend/src/lib/i18n/locales/it.json @@ -595,6 +595,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", diff --git a/frontend/src/lib/i18n/locales/nb.json b/frontend/src/lib/i18n/locales/nb.json index 27f7130d..b1837861 100644 --- a/frontend/src/lib/i18n/locales/nb.json +++ b/frontend/src/lib/i18n/locales/nb.json @@ -590,6 +590,7 @@ "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", "smtpPasswordPlaceholder": "SMTP-passord", diff --git a/frontend/src/lib/i18n/locales/vi.json b/frontend/src/lib/i18n/locales/vi.json index feb95849..2eb81494 100644 --- a/frontend/src/lib/i18n/locales/vi.json +++ b/frontend/src/lib/i18n/locales/vi.json @@ -595,6 +595,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", diff --git a/frontend/src/lib/i18n/locales/zh-CN.json b/frontend/src/lib/i18n/locales/zh-CN.json index fc351e5d..8d7e022a 100644 --- a/frontend/src/lib/i18n/locales/zh-CN.json +++ b/frontend/src/lib/i18n/locales/zh-CN.json @@ -595,6 +595,7 @@ "replyForwardWithHelp": "回复或转发在此收到的邮件时,在撰写邮件中预先选择的身份。", "replyForwardWithDefault": "默认发送账户", "smtpAuthentication": "SMTP 身份验证", + "authMechanism": "身份验证方式", "smtpUseSameAsIncoming": "与收件服务器相同", "smtpUsernamePlaceholder": "smtp-user@example.com", "smtpPasswordPlaceholder": "SMTP 密码", diff --git a/frontend/src/lib/i18n/locales/zh-HK.json b/frontend/src/lib/i18n/locales/zh-HK.json index ff1725d7..60555b22 100644 --- a/frontend/src/lib/i18n/locales/zh-HK.json +++ b/frontend/src/lib/i18n/locales/zh-HK.json @@ -595,6 +595,7 @@ "replyForwardWithHelp": "回覆或轉寄在此收到的郵件時,在撰寫郵件中預先選擇的身分。", "replyForwardWithDefault": "預設寄件帳戶", "smtpAuthentication": "SMTP 身分驗證", + "authMechanism": "身分驗證方式", "smtpUseSameAsIncoming": "與收件伺服器相同", "smtpUsernamePlaceholder": "smtp-user@example.com", "smtpPasswordPlaceholder": "SMTP 密碼", diff --git a/frontend/src/lib/i18n/locales/zh-TW.json b/frontend/src/lib/i18n/locales/zh-TW.json index 7fa564fa..5a36cfb6 100644 --- a/frontend/src/lib/i18n/locales/zh-TW.json +++ b/frontend/src/lib/i18n/locales/zh-TW.json @@ -595,6 +595,7 @@ "replyForwardWithHelp": "回覆或轉寄在此收到的郵件時,在撰寫郵件中預先選擇的身分。", "replyForwardWithDefault": "預設寄件帳號", "smtpAuthentication": "SMTP 身分驗證", + "authMechanism": "身分驗證方式", "smtpUseSameAsIncoming": "與收件伺服器相同", "smtpUsernamePlaceholder": "smtp-user@example.com", "smtpPasswordPlaceholder": "SMTP 密碼", diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 55a28532..ef37fb14 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -653,7 +653,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..ba330dc9 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -1270,8 +1270,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..b7983848 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"]; diff --git a/go.mod b/go.mod index 27587473..4433d0b3 100644 --- a/go.mod +++ b/go.mod @@ -17,13 +17,13 @@ require ( 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 +33,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..770f353e 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= @@ -98,8 +98,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 +119,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 +128,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 +143,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 +162,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= 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/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/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 + } + }) + } +} From cb8e1357f71576803e1892d1e08709f79e15e4b0 Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 29 Jul 2026 06:47:43 +0000 Subject: [PATCH 03/27] Added digest auth for contacts and calendars - #313 --- CHANGELOG.md | 1 + .../io.github.hkdb.Aerion.metainfo.xml | 1 + extensions/calendar/backend/api.go | 2 +- extensions/calendar/backend/caldav.go | 21 +- .../calendar/backend/provider_caldav.go | 2 +- .../backend/provider_caldav_compose.go | 5 +- go.mod | 1 + go.sum | 6 + internal/carddav/client.go | 23 +- internal/kit/davutil/authtransport.go | 152 ++++++++++ internal/kit/davutil/authtransport_test.go | 268 ++++++++++++++++++ 11 files changed, 441 insertions(+), 41 deletions(-) create mode 100644 internal/kit/davutil/authtransport.go create mode 100644 internal/kit/davutil/authtransport_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 184714ef..9ec645dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ --- - Added manual config of IMAP/SMTP auth mech +- Added digest auth for contacts and calendars - [#313](https://github.com/hkdb/aerion/issues/313) - Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355) diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index 5df39f97..29bd7225 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -289,6 +289,7 @@
    • Added manual config of IMAP/SMTP auth mech
    • +
    • Added digest auth for contacts and calendars - #313
    • Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355)
    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..b166d667 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) } @@ -411,19 +408,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/go.mod b/go.mod index 4433d0b3..f3340daa 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ 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 diff --git a/go.sum b/go.sum index 770f353e..577cab1b 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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/carddav/client.go b/internal/carddav/client.go index 0be6744a..c8b742c8 100644 --- a/internal/carddav/client.go +++ b/internal/carddav/client.go @@ -18,14 +18,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 +34,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 +63,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 +110,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) } 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) + } +} From de5f8bec1c846ee00fde17d8dda1f32f80470ad0 Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 29 Jul 2026 07:01:31 +0000 Subject: [PATCH 04/27] Update flathub offline build --- build/flatpak/flathub/go.mod.yml | 25 +++++++++++++++---------- build/flatpak/flathub/modules.txt | 23 ++++++++++++++--------- 2 files changed, 29 insertions(+), 19 deletions(-) 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 From 706ab4a073625a5f7a4b2f86b132518d1e7d9157 Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 29 Jul 2026 07:37:19 +0000 Subject: [PATCH 05/27] Fixed glitch when rapid deleting messages --- CHANGELOG.md | 1 + app/actions.go | 14 +++ app/app.go | 2 + app/background.go | 94 +++++++++++++++++-- .../io.github.hkdb.Aerion.metainfo.xml | 1 + 5 files changed, 104 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec645dd..3c3cb20d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Added manual config of IMAP/SMTP auth mech - Added digest auth for contacts and calendars - [#313](https://github.com/hkdb/aerion/issues/313) - Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355) +- Fixed glitch when rapid deleting messages **v0.3.2 - 07-16-2026** 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/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index 29bd7225..140b0e0f 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -291,6 +291,7 @@
  • Added manual config of IMAP/SMTP auth mech
  • Added digest auth for contacts and calendars - #313
  • Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355)
  • +
  • Fixed glitch when rapid deleting messages
From c267ddd218733ef9d7bda0b230b0135f9a4d1277 Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 29 Jul 2026 08:00:45 +0000 Subject: [PATCH 06/27] Refactored to engine-level serialization --- CHANGELOG.md | 1 + .../io.github.hkdb.Aerion.metainfo.xml | 1 + internal/sync/engine.go | 6 ++ internal/sync/fetch.go | 10 ++ internal/sync/folderlock.go | 44 ++++++++ internal/sync/folderlock_test.go | 101 ++++++++++++++++++ internal/sync/messages.go | 16 +++ 7 files changed, 179 insertions(+) create mode 100644 internal/sync/folderlock.go create mode 100644 internal/sync/folderlock_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c3cb20d..043762f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Added digest auth for contacts and calendars - [#313](https://github.com/hkdb/aerion/issues/313) - Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355) - Fixed glitch when rapid deleting messages +- Refactored to engine-level serialization **v0.3.2 - 07-16-2026** diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index 140b0e0f..c401713c 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -292,6 +292,7 @@
  • Added digest auth for contacts and calendars - #313
  • Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355)
  • Fixed glitch when rapid deleting messages
  • +
  • Refactored to engine-level serialization
  • 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) From 0ace8f461ee446baca0049866b02f68424bd2f8f Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 29 Jul 2026 08:31:54 +0000 Subject: [PATCH 07/27] Improved toast notification logic - #115 --- CHANGELOG.md | 1 + .../io.github.hkdb.Aerion.metainfo.xml | 1 + .../components/ui/toast/ToastContainer.svelte | 72 ++++++++++++++++++- frontend/src/lib/i18n/locales/cs.json | 4 +- frontend/src/lib/i18n/locales/de.json | 4 +- frontend/src/lib/i18n/locales/en.json | 4 +- frontend/src/lib/i18n/locales/fr.json | 4 +- frontend/src/lib/i18n/locales/it.json | 4 +- frontend/src/lib/i18n/locales/nb.json | 4 +- frontend/src/lib/i18n/locales/vi.json | 4 +- frontend/src/lib/i18n/locales/zh-CN.json | 4 +- frontend/src/lib/i18n/locales/zh-HK.json | 4 +- frontend/src/lib/i18n/locales/zh-TW.json | 4 +- 13 files changed, 102 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 043762f5..d8a4c15c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355) - Fixed glitch when rapid deleting messages - Refactored to engine-level serialization +- Improved toast notification logic - [#115](https://github.com/hkdb/aerion/issues/115) **v0.3.2 - 07-16-2026** diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index c401713c..05730e64 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -293,6 +293,7 @@
  • Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355)
  • Fixed glitch when rapid deleting messages
  • Refactored to engine-level serialization
  • +
  • Improved toast notification logic - #115
  • diff --git a/frontend/src/lib/components/ui/toast/ToastContainer.svelte b/frontend/src/lib/components/ui/toast/ToastContainer.svelte index 926c71dc..2d0d705a 100644 --- a/frontend/src/lib/components/ui/toast/ToastContainer.svelte +++ b/frontend/src/lib/components/ui/toast/ToastContainer.svelte @@ -1,12 +1,80 @@ -
    - {#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} + + {/if} + +
    + toasts.remove(pile.id)} /> + +
    +
    + {/if} + + {#if expanded} +
    + +
    + {/if}
    diff --git a/frontend/src/lib/i18n/locales/cs.json b/frontend/src/lib/i18n/locales/cs.json index 0eebfa75..a18ac9d3 100644 --- a/frontend/src/lib/i18n/locales/cs.json +++ b/frontend/src/lib/i18n/locales/cs.json @@ -925,7 +925,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 945edac3..1d802a42 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -925,7 +925,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 4bb30cb5..b8f185a9 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -927,7 +927,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 c724efba..361920cc 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -918,7 +918,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 d1cbfdf6..3a174da7 100644 --- a/frontend/src/lib/i18n/locales/it.json +++ b/frontend/src/lib/i18n/locales/it.json @@ -925,7 +925,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 b1837861..428b2aa4 100644 --- a/frontend/src/lib/i18n/locales/nb.json +++ b/frontend/src/lib/i18n/locales/nb.json @@ -905,7 +905,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", diff --git a/frontend/src/lib/i18n/locales/vi.json b/frontend/src/lib/i18n/locales/vi.json index 2eb81494..b3377413 100644 --- a/frontend/src/lib/i18n/locales/vi.json +++ b/frontend/src/lib/i18n/locales/vi.json @@ -925,7 +925,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 8d7e022a..194a325f 100644 --- a/frontend/src/lib/i18n/locales/zh-CN.json +++ b/frontend/src/lib/i18n/locales/zh-CN.json @@ -925,7 +925,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 60555b22..56c24862 100644 --- a/frontend/src/lib/i18n/locales/zh-HK.json +++ b/frontend/src/lib/i18n/locales/zh-HK.json @@ -925,7 +925,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 5a36cfb6..7506d781 100644 --- a/frontend/src/lib/i18n/locales/zh-TW.json +++ b/frontend/src/lib/i18n/locales/zh-TW.json @@ -925,7 +925,9 @@ "selectPresetColor": "選擇 {color}", "backToList": "返回郵件清單", "toggleSidebar": "切換側邊欄", - "closeSidebar": "關閉側邊欄" + "closeSidebar": "關閉側邊欄", + "showAllToasts": "顯示全部通知", + "collapseToasts": "收起通知" }, "window": { "minimize": "最小化", From eb4fbdfd23f5a9f3e70c1cbf4f47ac2c4e25d1e7 Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 29 Jul 2026 09:15:14 +0000 Subject: [PATCH 08/27] Added states to fix unwanted message list reset - #348 --- CHANGELOG.md | 1 + .../io.github.hkdb.Aerion.metainfo.xml | 1 + .../lib/components/list/MessageList.svelte | 82 ++++++++++++++++++- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a4c15c..5bbb7032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed glitch when rapid deleting messages - Refactored to engine-level serialization - Improved toast notification logic - [#115](https://github.com/hkdb/aerion/issues/115) +- Added states to fix unwanted message list reset - [#348](https://github.com/hkdb/aerion/issues/348) **v0.3.2 - 07-16-2026** diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index 05730e64..dbbc3580 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -294,6 +294,7 @@
  • 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
  • diff --git a/frontend/src/lib/components/list/MessageList.svelte b/frontend/src/lib/components/list/MessageList.svelte index e1eeafee..aa4febe9 100644 --- a/frontend/src/lib/components/list/MessageList.svelte +++ b/frontend/src/lib/components/list/MessageList.svelte @@ -1,3 +1,15 @@ + + = {} + + function getFocusedRowRef(): ConversationRow | null { + if (!selectedThreadId) return null + return rowRefs[selectedThreadId] ?? null + } + + export function isFolderPickerOpen(): boolean { + return getFocusedRowRef()?.isFolderPickerOpen() ?? false + } + + export function toggleMoveToDialog() { + getFocusedRowRef()?.toggleFolderPicker('move') + } + + export function toggleCopyToDialog() { + getFocusedRowRef()?.toggleFolderPicker('copy') + } + // Permanent delete confirmation state let showDeleteConfirm = $state(false) let pendingDeleteIds = $state([]) @@ -1616,6 +1657,7 @@ {@const resultAccountId = result.accountId || accountId} {@const resultFolderId = result.folderId || folderId} 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) @@ -72,12 +79,21 @@ export const SIDEBAR_NEXT = (e: KeyboardEvent): boolean => export const SIDEBAR_PREV = (e: KeyboardEvent): boolean => altOnly(e) && (e.key === 'k' || e.key === 'ArrowUp') +// 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_SELECT_ALL, @@ -86,6 +102,8 @@ export const KEY = { PANE_FOCUS_PREV, SIDEBAR_NEXT, SIDEBAR_PREV, + LIST_MOVE_TO, + LIST_COPY_TO, } // matchesAny is a small helper for cases where several predicates should be From fcc023844ec1f8812a2b2c7bf1e89bf0e9c34ee7 Mon Sep 17 00:00:00 2001 From: hkdb Date: Tue, 4 Aug 2026 06:00:46 +0000 Subject: [PATCH 16/27] alt+g, alt+G --- CHANGELOG.md | 2 +- .../io.github.hkdb.Aerion.metainfo.xml | 2 +- docs/KEYBOARD_SHORTCUTS.md | 5 +++++ frontend/src/App.svelte | 22 +++++++++++++++++++ .../lib/components/kit/SourceSidebar.svelte | 8 +++++++ .../src/lib/components/sidebar/Sidebar.svelte | 15 +++++++++++++ frontend/src/lib/keyboard/shortcuts.ts | 9 ++++++++ frontend/src/lib/stores/keyboard.svelte.ts | 3 +++ 8 files changed, 64 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3146c55..da400595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - Basic CarDAV (Mailfence) support - [#366](https://github.com/hkdb/aerion/issues/366) - Fixed typo in autostart code - [#33](https://github.com/hkdb/aerion/issues/33) - Updated nb translations - [PR #371](https://github.com/hkdb/aerion/pull/371) -- Added g, G, alt+c, alt+m shortcuts - [#351](https://github.com/hkdb/aerion/issues/351) +- Added g, G, alt+g, alt+G, alt+c, alt+m shortcuts - [#351](https://github.com/hkdb/aerion/issues/351) **v0.3.2 - 07-16-2026** diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index e7425a85..8506779d 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -299,7 +299,7 @@
  • Basic CarDAV (Mailfence) support - #366
  • Fixed typo in autostart code - #33
  • Updated nb translations - PR #371
  • -
  • Added g, G, alt+c, alt+m shortcuts - #351
  • +
  • Added g, G, alt+g, alt+G, alt+c, alt+m shortcuts - #351
  • diff --git a/docs/KEYBOARD_SHORTCUTS.md b/docs/KEYBOARD_SHORTCUTS.md index 328e532b..3665da46 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 | @@ -144,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 diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index bd68584b..7c755299 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1189,6 +1189,28 @@ 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. 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/sidebar/Sidebar.svelte b/frontend/src/lib/components/sidebar/Sidebar.svelte index 5feb98ce..96b63aa8 100644 --- a/frontend/src/lib/components/sidebar/Sidebar.svelte +++ b/frontend/src/lib/components/sidebar/Sidebar.svelte @@ -423,6 +423,21 @@ selectFolderByIndex(navList, newIndex) } + // Jump to the top sidebar item — All Inboxes when present (Alt+G) + export function selectFirstFolder() { + const navList = buildFolderNavList() + if (navList.length === 0) return + selectFolderByIndex(navList, 0) + } + + // Jump to the last visible item: last folder of the last expanded account, + // or the last account header when all trees are collapsed (Alt+Shift+G) + export function selectLastFolder() { + const navList = buildFolderNavList() + if (navList.length === 0) return + selectFolderByIndex(navList, navList.length - 1) + } + // Scroll an item into view function scrollItemIntoView(item: FolderNavItem) { if (!scrollContainer) return diff --git a/frontend/src/lib/keyboard/shortcuts.ts b/frontend/src/lib/keyboard/shortcuts.ts index 90fa083e..411d02d1 100644 --- a/frontend/src/lib/keyboard/shortcuts.ts +++ b/frontend/src/lib/keyboard/shortcuts.ts @@ -79,6 +79,13 @@ 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' @@ -102,6 +109,8 @@ export const KEY = { PANE_FOCUS_PREV, SIDEBAR_NEXT, SIDEBAR_PREV, + SIDEBAR_FIRST, + SIDEBAR_LAST, LIST_MOVE_TO, LIST_COPY_TO, } 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 From 41f1ddbd99bb0297e99d8277e11e69a19172d736 Mon Sep 17 00:00:00 2001 From: hkdb Date: Tue, 4 Aug 2026 06:09:30 +0000 Subject: [PATCH 17/27] Backfilled v to contact list --- CHANGELOG.md | 1 + .../flatpak/io.github.hkdb.Aerion.metainfo.xml | 1 + docs/KEYBOARD_SHORTCUTS.md | 2 +- frontend/src/App.svelte | 17 ++++++++++------- frontend/src/lib/components/kit/ListPane.svelte | 2 +- frontend/src/lib/keyboard/shortcuts.ts | 5 +++++ 6 files changed, 19 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da400595..07ae988f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Fixed typo in autostart code - [#33](https://github.com/hkdb/aerion/issues/33) - Updated nb translations - [PR #371](https://github.com/hkdb/aerion/pull/371) - Added g, G, alt+g, alt+G, alt+c, alt+m shortcuts - [#351](https://github.com/hkdb/aerion/issues/351) +- Backfilled v to contact list **v0.3.2 - 07-16-2026** diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index 8506779d..601a5f4c 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -300,6 +300,7 @@
  • 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
  • diff --git a/docs/KEYBOARD_SHORTCUTS.md b/docs/KEYBOARD_SHORTCUTS.md index 3665da46..68d802be 100644 --- a/docs/KEYBOARD_SHORTCUTS.md +++ b/docs/KEYBOARD_SHORTCUTS.md @@ -85,7 +85,7 @@ These shortcuts depend on which pane is focused. They are disabled when typing i | `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` also work in extension list panes (e.g., Contacts list, Calendar agenda). +`G` / `Shift+G` / `V` also work in extension list panes (e.g., Contacts list, Calendar agenda). ### Conversation Viewer diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 7c755299..c4ba674f 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1385,15 +1385,18 @@ 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/kit/ListPane.svelte b/frontend/src/lib/components/kit/ListPane.svelte index 5722ff15..2977b823 100644 --- a/frontend/src/lib/components/kit/ListPane.svelte +++ b/frontend/src/lib/components/kit/ListPane.svelte @@ -206,7 +206,7 @@ onSelect(items[items.length - 1].id) return } - if (KEY.LIST_OPEN(e)) { + 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/keyboard/shortcuts.ts b/frontend/src/lib/keyboard/shortcuts.ts index 411d02d1..4870521f 100644 --- a/frontend/src/lib/keyboard/shortcuts.ts +++ b/frontend/src/lib/keyboard/shortcuts.ts @@ -56,6 +56,10 @@ export const LIST_TOGGLE_CHECK = (e: KeyboardEvent): boolean => 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 @@ -103,6 +107,7 @@ export const KEY = { LIST_LAST, LIST_TOGGLE_CHECK, LIST_OPEN, + LIST_VIEW, LIST_SELECT_ALL, LIST_DELETE, PANE_FOCUS_NEXT, From bdadc7c5256602e2aa1cd038b89791a422aced9c Mon Sep 17 00:00:00 2001 From: hkdb Date: Tue, 4 Aug 2026 09:25:00 +0000 Subject: [PATCH 18/27] Message list redesign + swipe gestures - #68 + #70 --- CHANGELOG.md | 2 + app/settings.go | 10 + .../io.github.hkdb.Aerion.metainfo.xml | 2 + .../src/lib/components/WhatsNewDialog.svelte | 2 + .../components/list/ConversationRow.svelte | 344 +++++++++++++++++- .../lib/components/list/MessageList.svelte | 3 + .../lib/components/settings/GeneralTab.svelte | 19 + .../components/settings/SettingsDialog.svelte | 12 +- frontend/src/lib/i18n/locales/cs.json | 2 + frontend/src/lib/i18n/locales/de.json | 2 + frontend/src/lib/i18n/locales/en.json | 2 + frontend/src/lib/i18n/locales/fr.json | 2 + frontend/src/lib/i18n/locales/it.json | 2 + frontend/src/lib/i18n/locales/nb.json | 2 + frontend/src/lib/i18n/locales/vi.json | 2 + frontend/src/lib/i18n/locales/zh-CN.json | 2 + frontend/src/lib/i18n/locales/zh-HK.json | 2 + frontend/src/lib/i18n/locales/zh-TW.json | 2 + frontend/src/lib/stores/settings.svelte.ts | 15 +- frontend/wailsjs/go/app/App.d.ts | 4 + frontend/wailsjs/go/app/App.js | 8 + internal/settings/store.go | 21 ++ 22 files changed, 444 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ae988f..63da02d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **v0.3.3 - 07-30-2026** --- +- Message list redesign - [#70](https://github.com/hkdb/aerion/issues/70) +- Added swipe gestures - right select and left delete - [#68](https://github.com/hkdb/aerion/issues/68) - Added manual config of IMAP/SMTP auth mech - Added digest auth for contacts and calendars - [#313](https://github.com/hkdb/aerion/issues/313) - Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355) diff --git a/app/settings.go b/app/settings.go index 9a20bae4..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() diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index 601a5f4c..d542ce7f 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -288,6 +288,8 @@
      +
    • Message list redesign - #70
    • +
    • 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
    • Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355)
    • diff --git a/frontend/src/lib/components/WhatsNewDialog.svelte b/frontend/src/lib/components/WhatsNewDialog.svelte index 04f8d16f..634aaf86 100644 --- a/frontend/src/lib/components/WhatsNewDialog.svelte +++ b/frontend/src/lib/components/WhatsNewDialog.svelte @@ -53,6 +53,8 @@

      Here are the highlights of this release:

        +
      • ⚗️ 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
      • 📩 Improved sync guards to improve stability
      • 🐛 Bug fixes and improvements
      • diff --git a/frontend/src/lib/components/list/ConversationRow.svelte b/frontend/src/lib/components/list/ConversationRow.svelte index 05edac05..5aaa458b 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} + +
        +
        diff --git a/frontend/src/lib/components/settings/SettingsDialog.svelte b/frontend/src/lib/components/settings/SettingsDialog.svelte index 94c42d6b..fd0493b1 100644 --- a/frontend/src/lib/components/settings/SettingsDialog.svelte +++ b/frontend/src/lib/components/settings/SettingsDialog.svelte @@ -5,9 +5,9 @@ import * as Tabs from '$lib/components/ui/tabs' import { Button } from '$lib/components/ui/button' // @ts-ignore - wailsjs path - import { GetReadReceiptResponsePolicy, SetReadReceiptResponsePolicy, GetMarkAsReadDelay, SetMarkAsReadDelay, GetMessageListDensity, SetMessageListDensity, GetThemeMode, SetThemeMode, GetShowTitleBar, SetShowTitleBar, GetRunBackground, SetRunBackground, GetStartHidden, SetStartHidden, GetAutostart, SetAutostart, GetLanguage, SetLanguage, GetComposerMode, SetComposerMode, GetMailtoMode, SetMailtoMode, GetComposerFormat, SetComposerFormat, GetNativeTitleBar, SetNativeTitleBar, GetAlwaysLoadImages, SetAlwaysLoadImages, GetDarkMailContent, SetDarkMailContent, GetDarkComposerBody, SetDarkComposerBody, GetAccentBarUnread, SetAccentBarUnread, GetShowMessageListCircles, SetShowMessageListCircles, GetShowMessageListProfilePics, SetShowMessageListProfilePics, GetShowViewerCircles, SetShowViewerCircles, GetSpellcheckEnabled, SetSpellcheckEnabled, GetSpellcheckLanguages, SetSpellcheckLanguages, QuitApp } from '../../../../wailsjs/go/app/App.js' + import { GetReadReceiptResponsePolicy, SetReadReceiptResponsePolicy, GetMarkAsReadDelay, SetMarkAsReadDelay, GetMessageListDensity, SetMessageListDensity, GetThemeMode, SetThemeMode, GetShowTitleBar, SetShowTitleBar, GetRunBackground, SetRunBackground, GetStartHidden, SetStartHidden, GetAutostart, SetAutostart, GetLanguage, SetLanguage, GetComposerMode, SetComposerMode, GetMailtoMode, SetMailtoMode, GetComposerFormat, SetComposerFormat, GetNativeTitleBar, SetNativeTitleBar, GetAlwaysLoadImages, SetAlwaysLoadImages, GetDarkMailContent, SetDarkMailContent, GetDarkComposerBody, SetDarkComposerBody, GetAccentBarUnread, SetAccentBarUnread, GetShowMessageListCircles, SetShowMessageListCircles, GetShowMessageListProfilePics, SetShowMessageListProfilePics, GetAlwaysShowMessageCheckbox, SetAlwaysShowMessageCheckbox, GetShowViewerCircles, SetShowViewerCircles, GetSpellcheckEnabled, SetSpellcheckEnabled, GetSpellcheckLanguages, SetSpellcheckLanguages, QuitApp } from '../../../../wailsjs/go/app/App.js' import { addToast } from '$lib/stores/toast' - import { setMessageListDensity as updateDensityStore, setThemeMode as updateThemeStore, setShowTitleBar as updateShowTitleBarStore, setRunBackground as updateRunBackgroundStore, setStartHidden as updateStartHiddenStore, setAutostart as updateAutostartStore, setLanguage as updateLanguageStore, setComposerMode as updateComposerModeStore, setMailtoMode as updateMailtoModeStore, setComposerFormat as updateComposerFormatStore, setNativeTitleBar as updateNativeTitleBarStore, setAlwaysLoadImages as updateAlwaysLoadImagesStore, setDarkMailContent as updateDarkMailContentStore, setDarkComposerBody as updateDarkComposerBodyStore, setAccentBarUnread as updateAccentBarUnreadStore, setShowMessageListCircles as updateShowMessageListCirclesStore, setShowMessageListProfilePics as updateShowMessageListProfilePicsStore, setShowViewerCircles as updateShowViewerCirclesStore, setSpellcheckEnabled as updateSpellcheckEnabledStore, setSpellcheckLanguages as updateSpellcheckLanguagesStore, type MessageListDensity, type ThemeMode, type ComposerMode, type ComposerFormat } from '$lib/stores/settings.svelte' + import { setMessageListDensity as updateDensityStore, setThemeMode as updateThemeStore, setShowTitleBar as updateShowTitleBarStore, setRunBackground as updateRunBackgroundStore, setStartHidden as updateStartHiddenStore, setAutostart as updateAutostartStore, setLanguage as updateLanguageStore, setComposerMode as updateComposerModeStore, setMailtoMode as updateMailtoModeStore, setComposerFormat as updateComposerFormatStore, setNativeTitleBar as updateNativeTitleBarStore, setAlwaysLoadImages as updateAlwaysLoadImagesStore, setDarkMailContent as updateDarkMailContentStore, setDarkComposerBody as updateDarkComposerBodyStore, setAccentBarUnread as updateAccentBarUnreadStore, setShowMessageListCircles as updateShowMessageListCirclesStore, setShowMessageListProfilePics as updateShowMessageListProfilePicsStore, setAlwaysShowMessageCheckbox as updateAlwaysShowMessageCheckboxStore, setShowViewerCircles as updateShowViewerCirclesStore, setSpellcheckEnabled as updateSpellcheckEnabledStore, setSpellcheckLanguages as updateSpellcheckLanguagesStore, type MessageListDensity, type ThemeMode, type ComposerMode, type ComposerFormat } from '$lib/stores/settings.svelte' import { syncSpellcheckLanguagesIfActive, defaultSpellcheckLanguages } from '$lib/spellcheck/settings' import { applyThemeFromMode } from '$lib/stores/theme.svelte' import { dialogGuardOpen, dialogGuardClose } from '$lib/stores/dialogGuard' @@ -55,6 +55,7 @@ let accentBarUnread = $state(false) let showMessageListCircles = $state(true) let showMessageListProfilePics = $state(false) + let alwaysShowMessageCheckbox = $state(false) let showViewerCircles = $state(true) let originalNativeTitleBar = false // Snapshot of the saved theme at dialog open time. Used to revert live preview @@ -100,7 +101,7 @@ loading = true hasSaved = false try { - const [policy, delayMs, density, theme, titleBar, runBg, startHid, autoSt, lang, comp, mail, compFmt, nativeTB, alwaysImages, darkMail, darkComposer, accentBar, listCircles, listProfilePics, viewerCircles, scEnabled, scLangs] = await Promise.all([ + const [policy, delayMs, density, theme, titleBar, runBg, startHid, autoSt, lang, comp, mail, compFmt, nativeTB, alwaysImages, darkMail, darkComposer, accentBar, listCircles, listProfilePics, alwaysCheckbox, viewerCircles, scEnabled, scLangs] = await Promise.all([ GetReadReceiptResponsePolicy(), GetMarkAsReadDelay(), GetMessageListDensity(), @@ -120,6 +121,7 @@ GetAccentBarUnread(), GetShowMessageListCircles(), GetShowMessageListProfilePics(), + GetAlwaysShowMessageCheckbox(), GetShowViewerCircles(), GetSpellcheckEnabled(), GetSpellcheckLanguages(), @@ -148,6 +150,7 @@ accentBarUnread = accentBar ?? false showMessageListCircles = listCircles ?? true showMessageListProfilePics = listProfilePics ?? false + alwaysShowMessageCheckbox = alwaysCheckbox ?? false showViewerCircles = viewerCircles ?? true originalNativeTitleBar = nativeTitleBar } catch (err) { @@ -187,6 +190,7 @@ await SetAccentBarUnread(accentBarUnread) await SetShowMessageListCircles(showMessageListCircles) await SetShowMessageListProfilePics(showMessageListProfilePics) + await SetAlwaysShowMessageCheckbox(alwaysShowMessageCheckbox) await SetShowViewerCircles(showViewerCircles) // Update the reactive stores so UI updates immediately updateDensityStore(messageListDensity as MessageListDensity) @@ -211,6 +215,7 @@ updateAccentBarUnreadStore(accentBarUnread) updateShowMessageListCirclesStore(showMessageListCircles) updateShowMessageListProfilePicsStore(showMessageListProfilePics) + updateAlwaysShowMessageCheckboxStore(alwaysShowMessageCheckbox) updateShowViewerCirclesStore(showViewerCircles) addToast({ type: 'success', @@ -327,6 +332,7 @@ bind:accentBarUnread bind:showMessageListCircles bind:showMessageListProfilePics + bind:alwaysShowMessageCheckbox bind:showViewerCircles bind:darkMailContent bind:darkComposerBody diff --git a/frontend/src/lib/i18n/locales/cs.json b/frontend/src/lib/i18n/locales/cs.json index a18ac9d3..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í", diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 1d802a42..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", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index b8f185a9..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", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 361920cc..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é", diff --git a/frontend/src/lib/i18n/locales/it.json b/frontend/src/lib/i18n/locales/it.json index 3a174da7..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", diff --git a/frontend/src/lib/i18n/locales/nb.json b/frontend/src/lib/i18n/locales/nb.json index ed78507c..cdc606f7 100644 --- a/frontend/src/lib/i18n/locales/nb.json +++ b/frontend/src/lib/i18n/locales/nb.json @@ -395,6 +395,8 @@ "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", diff --git a/frontend/src/lib/i18n/locales/vi.json b/frontend/src/lib/i18n/locales/vi.json index b3377413..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", diff --git a/frontend/src/lib/i18n/locales/zh-CN.json b/frontend/src/lib/i18n/locales/zh-CN.json index 194a325f..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": "当有人请求回执时", diff --git a/frontend/src/lib/i18n/locales/zh-HK.json b/frontend/src/lib/i18n/locales/zh-HK.json index 56c24862..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": "當有人要求讀取回條時", diff --git a/frontend/src/lib/i18n/locales/zh-TW.json b/frontend/src/lib/i18n/locales/zh-TW.json index 7506d781..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": "當有人要求讀取回條時", 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 ef37fb14..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; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index ba330dc9..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); } 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) { From a989d17d256d6dbc84b124b211cbb920f4e4fb6f Mon Sep 17 00:00:00 2001 From: hkdb Date: Tue, 4 Aug 2026 09:28:54 +0000 Subject: [PATCH 19/27] Updated what's new --- frontend/src/lib/components/WhatsNewDialog.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/components/WhatsNewDialog.svelte b/frontend/src/lib/components/WhatsNewDialog.svelte index 634aaf86..0b70253d 100644 --- a/frontend/src/lib/components/WhatsNewDialog.svelte +++ b/frontend/src/lib/components/WhatsNewDialog.svelte @@ -56,6 +56,7 @@
      • ⚗️ 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
      From 5d647ad201acc19d5f831e83c17a6eaddcdcc852 Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 5 Aug 2026 01:03:05 +0000 Subject: [PATCH 20/27] Commit bdadc7c also fixes #340 --- CHANGELOG.md | 2 +- build/flatpak/io.github.hkdb.Aerion.metainfo.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63da02d4..2874da19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **v0.3.3 - 07-30-2026** --- -- Message list redesign - [#70](https://github.com/hkdb/aerion/issues/70) +- Message list redesign - [#70](https://github.com/hkdb/aerion/issues/70) + [#340](https://github.com/hkdb/aerion/issues/340) - Added swipe gestures - right select and left delete - [#68](https://github.com/hkdb/aerion/issues/68) - Added manual config of IMAP/SMTP auth mech - Added digest auth for contacts and calendars - [#313](https://github.com/hkdb/aerion/issues/313) diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index d542ce7f..652b2224 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -288,7 +288,7 @@
        -
      • Message list redesign - #70
      • +
      • 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
      • From 77510ceb9744dc8ec9a3bb4c1196a340c100f2a7 Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 5 Aug 2026 01:29:32 +0000 Subject: [PATCH 21/27] Fixed .desktop - #356 - #367 --- CHANGELOG.md | 1 + build/flatpak/io.github.hkdb.Aerion.metainfo.xml | 1 + build/linux/aerion.desktop | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2874da19..e4ccae35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Updated nb translations - [PR #371](https://github.com/hkdb/aerion/pull/371) - Added g, G, alt+g, alt+G, alt+c, alt+m shortcuts - [#351](https://github.com/hkdb/aerion/issues/351) - Backfilled v to contact list +- Fixed .desktop - [#356](https://github.com/hkdb/aerion/issues/356) - [#367](https://github.com/hkdb/aerion/issues/367) **v0.3.2 - 07-16-2026** diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index 652b2224..39b270d0 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -303,6 +303,7 @@
      • 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
      diff --git a/build/linux/aerion.desktop b/build/linux/aerion.desktop index 80a7f420..de6e7518 100644 --- a/build/linux/aerion.desktop +++ b/build/linux/aerion.desktop @@ -39,7 +39,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; From d88ea191a6ca6842aaea3733150e746a1d86c7ea Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 5 Aug 2026 03:16:24 +0000 Subject: [PATCH 22/27] Fixed multi-select alt+m and alt+c --- .../src/lib/components/list/ConversationRow.svelte | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/list/ConversationRow.svelte b/frontend/src/lib/components/list/ConversationRow.svelte index 5aaa458b..c5d8dab9 100644 --- a/frontend/src/lib/components/list/ConversationRow.svelte +++ b/frontend/src/lib/components/list/ConversationRow.svelte @@ -91,7 +91,15 @@ } export function toggleFolderPicker(mode: 'move' | 'copy') { - contextMenuRef?.toggleFolderPicker(mode) + if (!contextMenuRef) return + // Same target rule as the Delete key: any checked messages act as a set, + // otherwise the focused row. Applied only on the press that OPENS the + // picker (close/switch presses keep the target locked), and never + // mutates the selection — canceling the dialog keeps checkboxes intact. + if (!contextMenuRef.isFolderPickerOpen()) { + useMultiSelect = selectedMessageIds.length > 0 + } + contextMenuRef.toggleFolderPicker(mode) } // Density-based class mappings From a83f05802d469334707737c7b6c6ed8f9309df16 Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 5 Aug 2026 06:53:49 +0000 Subject: [PATCH 23/27] multigetVCards + fetchVCardByGET - #366 --- internal/carddav/client.go | 158 +++++++++++++++++++++++++++-- internal/carddav/enumerate_test.go | 32 +++++- 2 files changed, 180 insertions(+), 10 deletions(-) diff --git a/internal/carddav/client.go b/internal/carddav/client.go index b06f3dc8..ec60ea05 100644 --- a/internal/carddav/client.go +++ b/internal/carddav/client.go @@ -6,6 +6,7 @@ import ( "encoding/xml" "fmt" "io" + "mime" "net/http" "net/url" "strings" @@ -819,11 +820,12 @@ func (c *Client) FetchContactsEnumerate(addressbookPath string) ([]*ParsedRecord return nil, nil } - abClient, err := carddav.NewClient(httpClient, fullPath) - if err != nil { - return nil, fmt.Errorf("failed to create client for addressbook: %w", err) - } - records, err := c.fetchContactsByPath(abClient, addressbookPath, paths) + // 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 } @@ -833,11 +835,10 @@ func (c *Client) FetchContactsEnumerate(addressbookPath string) ([]*ParsedRecord c.log.Debug().Err(err).Msg("Multiget rejected, fetching vCards individually via GET") records = make([]*ParsedRecord, 0, len(paths)) for _, p := range paths { - obj, getErr := abClient.GetAddressObject(ctx, p) + parsed, getErr := c.fetchVCardByGET(ctx, httpClient, p) if getErr != nil { return nil, fmt.Errorf("failed to fetch %s: %w", p, getErr) } - parsed := parseVCard(*obj) if parsed == nil { continue } @@ -846,6 +847,149 @@ func (c *Client) FetchContactsEnumerate(addressbookPath string) ([]*ParsedRecord 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 diff --git a/internal/carddav/enumerate_test.go b/internal/carddav/enumerate_test.go index 7781c28b..77c4df78 100644 --- a/internal/carddav/enumerate_test.go +++ b/internal/carddav/enumerate_test.go @@ -10,9 +10,13 @@ import ( // 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 } @@ -22,7 +26,7 @@ const ( ) func newMailfenceServer(rejectMultiget bool) *mailfenceServer { - m := &mailfenceServer{rejectMultiget: rejectMultiget} + m := &mailfenceServer{rejectMultiget: rejectMultiget, getContentType: "text/x-vcard"} m.srv = httptest.NewServer(http.HandlerFunc(m.handle)) return m } @@ -51,12 +55,16 @@ func (m *mailfenceServer) handle(w http.ResponseWriter, r *http.Request) { } 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` + ``)) @@ -76,11 +84,11 @@ func (m *mailfenceServer) handle(w http.ResponseWriter, r *http.Request) { `HTTP/1.1 200 OK` + ``)) case r.Method == "GET" && r.URL.Path == "/addressbook/alice.vcf": - w.Header().Set("Content-Type", "text/vcard; charset=utf-8") + 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", "text/vcard; charset=utf-8") + w.Header().Set("Content-Type", m.getContentType+"; charset=utf-8") w.Header().Set("ETag", `"e-bob"`) w.Write([]byte(vcardBob)) //nolint:errcheck default: @@ -114,6 +122,24 @@ func TestFetchContactsEnumerate_Multiget(t *testing.T) { 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() From 32f2ed0d7d24dfc79dd0df1865041df8b1b7fe6b Mon Sep 17 00:00:00 2001 From: hkdb Date: Wed, 5 Aug 2026 08:07:44 +0000 Subject: [PATCH 24/27] Fixed attachment leak on reply - #381 --- CHANGELOG.md | 1 + app/compose.go | 17 ++++++ app/compose_test.go | 54 +++++++++++++++++++ .../io.github.hkdb.Aerion.metainfo.xml | 1 + 4 files changed, 73 insertions(+) create mode 100644 app/compose_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ccae35..71390621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Added g, G, alt+g, alt+G, alt+c, alt+m shortcuts - [#351](https://github.com/hkdb/aerion/issues/351) - Backfilled v to contact list - Fixed .desktop - [#356](https://github.com/hkdb/aerion/issues/356) - [#367](https://github.com/hkdb/aerion/issues/367) +- Fixed attachment leak on reply - [#381](https://github.com/hkdb/aerion/issues/381) **v0.3.2 - 07-16-2026** diff --git a/app/compose.go b/app/compose.go index 570ae755..18011dd6 100644 --- a/app/compose.go +++ b/app/compose.go @@ -803,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 @@ -834,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) { 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/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index 39b270d0..d13a244e 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -304,6 +304,7 @@
    • 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
    From 91c1d7c5e5d0486301c428627af77a53f2b95d26 Mon Sep 17 00:00:00 2001 From: aquilapl <81761491+aquilapl@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:32:42 +0200 Subject: [PATCH 25/27] i18n: Add Polish (pl) translation (#374) * i18n: Add Polish (pl) translation * i18n: Update and refine Polish (pl) translations * feat(lang): add spellcheck section to Polish translation * i18n: keep en first in register and supportedLocales * i18n: sync pl.json keys with en.json and fill missing translations * Sync with latest EN + complete Polish translation * i18n: add missing toast.removedStar + rebase on v0.3.3-dev --------- Co-authored-by: hkdb --- .../io.github.hkdb.Aerion.metainfo.xml | 24 + build/linux/aerion.desktop | 4 + extensions/contacts/frontend/i18n/index.ts | 1 + .../contacts/frontend/i18n/locales/pl.json | 168 +++ frontend/src/lib/i18n/dateFnsLocale.ts | 5 + frontend/src/lib/i18n/index.ts | 4 + frontend/src/lib/i18n/locales/pl.json | 982 ++++++++++++++++++ 7 files changed, 1188 insertions(+) create mode 100644 extensions/contacts/frontend/i18n/locales/pl.json create mode 100644 frontend/src/lib/i18n/locales/pl.json diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index d13a244e..4a1c4f8a 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ư diff --git a/build/linux/aerion.desktop b/build/linux/aerion.desktop index de6e7518..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; @@ -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/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/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/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/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}" + } + } +} From b90daf6f6df145d3339e80edd2cfc2373e723110 Mon Sep 17 00:00:00 2001 From: hkdb Date: Thu, 6 Aug 2026 05:46:46 +0000 Subject: [PATCH 26/27] Polish docs --- CHANGELOG.md | 1 + README.md | 13 +++++++++++++ build/flatpak/io.github.hkdb.Aerion.metainfo.xml | 3 ++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71390621..bfdce7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Added swipe gestures - right select and left delete - [#68](https://github.com/hkdb/aerion/issues/68) - Added manual config of IMAP/SMTP auth mech - Added digest auth for contacts and calendars - [#313](https://github.com/hkdb/aerion/issues/313) +- Added Polish translation - PR [#374](https://github.com/hkdb/aerion/pull/374) - Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355) - Fixed glitch when rapid deleting messages - Refactored to engine-level serialization diff --git a/README.md b/README.md index c74822c8..1bd6d639 100644 --- a/README.md +++ b/README.md @@ -724,6 +724,19 @@ Special thanks to translation contributors for making Aerion more accessible: + + + + +
    + +
    + aquilapl +

    + Polski (pl)
    +   +
    +
    diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index 4a1c4f8a..d4037bec 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -316,7 +316,8 @@
  • 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
  • -
  • Fixed IMAP/SMTP auto auth mech - [#365](https://github.com/hkdb/aerion/issues/355)
  • +
  • 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
  • From 9f5012c318402ba64d24af283cf7ea2442609610 Mon Sep 17 00:00:00 2001 From: hkdb Date: Thu, 6 Aug 2026 05:58:14 +0000 Subject: [PATCH 27/27] Bump to v0.3.3 --- CHANGELOG.md | 2 +- app/state.go | 2 +- build/flatpak/io.github.hkdb.Aerion.metainfo.xml | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- frontend/package.json.md5 | 2 +- frontend/src/lib/components/WhatsNewDialog.svelte | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfdce7b0..5d890dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # CHANGELOG -**v0.3.3 - 07-30-2026** +**v0.3.3 - 08-06-2026** --- - Message list redesign - [#70](https://github.com/hkdb/aerion/issues/70) + [#340](https://github.com/hkdb/aerion/issues/340) diff --git a/app/state.go b/app/state.go index 631d211b..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.3-dev" +const Version = "0.3.3" // AppInfo contains application metadata type AppInfo struct { diff --git a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml index d4037bec..e3cc4a1f 100644 --- a/build/flatpak/io.github.hkdb.Aerion.metainfo.xml +++ b/build/flatpak/io.github.hkdb.Aerion.metainfo.xml @@ -309,7 +309,7 @@ - +
    • Message list redesign - #70 - #340
    • diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 78c8b191..a02022c3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "aerion", - "version": "0.3.3-dev", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aerion", - "version": "0.3.3-dev", + "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 b4babb4a..8bccb7a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aerion", "private": true, - "version": "0.3.3-dev", + "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 d1b0b2ea..eb195a77 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -8fbf2a6f119ff406af7364c118ca8c87 \ No newline at end of file +48a90fd1c3ac1a9e4f4707bb63114d36 \ No newline at end of file diff --git a/frontend/src/lib/components/WhatsNewDialog.svelte b/frontend/src/lib/components/WhatsNewDialog.svelte index 0b70253d..51fa4103 100644 --- a/frontend/src/lib/components/WhatsNewDialog.svelte +++ b/frontend/src/lib/components/WhatsNewDialog.svelte @@ -48,7 +48,7 @@
      -

      🚀 Welcome to Aerion v0.3.3-dev!

      +

      🚀 Welcome to Aerion v0.3.3!

      Here are the highlights of this release: