Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
48ed1d2
Bump to v0.3.3-dev
hkdb Jul 17, 2026
870197e
IMAP/SMTP auth fix - #355
hkdb Jul 29, 2026
cb8e135
Added digest auth for contacts and calendars - #313
hkdb Jul 29, 2026
de5f8be
Update flathub offline build
hkdb Jul 29, 2026
706ab4a
Fixed glitch when rapid deleting messages
hkdb Jul 29, 2026
c267ddd
Refactored to engine-level serialization
hkdb Jul 29, 2026
0ace8f4
Improved toast notification logic - #115
hkdb Jul 29, 2026
eb4fbdf
Added states to fix unwanted message list reset - #348
hkdb Jul 29, 2026
027559a
Improved CalDAV discovery - #363
hkdb Jul 29, 2026
5b3956b
Reduce organizer-email error confusion - #363
hkdb Jul 31, 2026
c583b98
Basic CarDAV (Mailfence) support - #366
hkdb Jul 31, 2026
f2c0cd1
Fixed typo in autostart code - #33
hkdb Aug 1, 2026
011f218
i18n nb fixes (#371)
dexblasnoot Aug 4, 2026
f93ec2e
i18n nb translation amendments for (#371) :
hkdb Aug 4, 2026
2f126e4
Added g, G, alt+c, alt+m shortcuts - #351
hkdb Aug 4, 2026
fcc0238
alt+g, alt+G
hkdb Aug 4, 2026
41f1ddb
Backfilled v to contact list
hkdb Aug 4, 2026
bdadc7c
Message list redesign + swipe gestures - #68 + #70
hkdb Aug 4, 2026
a989d17
Updated what's new
hkdb Aug 4, 2026
5d647ad
Commit bdadc7c also fixes #340
hkdb Aug 5, 2026
77510ce
Fixed .desktop - #356 - #367
hkdb Aug 5, 2026
d88ea19
Fixed multi-select alt+m and alt+c
hkdb Aug 5, 2026
a83f058
multigetVCards + fetchVCardByGET - #366
hkdb Aug 5, 2026
32f2ed0
Fixed attachment leak on reply - #381
hkdb Aug 5, 2026
91c1d7c
i18n: Add Polish (pl) translation (#374)
aquilapl Aug 6, 2026
b90daf6
Polish docs
hkdb Aug 6, 2026
9f5012c
Bump to v0.3.3
hkdb Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
# CHANGELOG


**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)
- 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
- 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)
- Improved CalDAV discovery - [#363](https://github.com/hkdb/aerion/issues/363)
- 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+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**
---

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,19 @@ Special thanks to translation contributors for making Aerion more accessible:
</tr>
</table>

<table align="left">
<tr>
<td align="center" width="180">
<a href="https://github.com/aquilapl">
<img src="https://github.com/aquilapl.png" width="80"><br>
<sub><b>aquilapl</b></sub>
</a><br>
<sub>Polski (pl)</sub><br>
<sub>&nbsp;</sub>
</td>
</tr>
</table>

<table align="left">
<tr>
<td align="center" width="180">
Expand Down
1 change: 1 addition & 0 deletions app/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions app/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{})

Expand Down
94 changes: 86 additions & 8 deletions app/background.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package app
import (
"context"
"fmt"
goSync "sync"
"time"

"github.com/hkdb/aerion/internal/folder"
Expand Down Expand Up @@ -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 {
Expand All @@ -124,23 +130,65 @@ 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,
// mailcow, etc.) signal a delete with EXPUNGE only — no follow-up
// 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
Expand All @@ -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()
}
}
}
Expand Down Expand Up @@ -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
Expand Down
27 changes: 15 additions & 12 deletions app/carddav.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,7 @@ func (a *App) AddContactSource(config carddav.SourceConfig) (*carddav.Source, er

// Create addressbooks based on enabled paths
for _, path := range config.EnabledAddressbooks {
// Try to get the name from discovery
name := path
if parts := strings.Split(strings.Trim(path, "/"), "/"); len(parts) > 0 {
name = parts[len(parts)-1]
}

_, err := a.carddavStore.CreateAddressbook(source.ID, path, name, true)
_, err := a.carddavStore.CreateAddressbook(source.ID, path, addressbookDisplayName(config.AddressbookNames, path), true)
if err != nil {
log.Warn().Err(err).Str("path", path).Msg("Failed to create addressbook")
}
Expand Down Expand Up @@ -163,11 +157,7 @@ func (a *App) UpdateContactSource(id string, config carddav.SourceConfig) error
if _, exists := existingByPath[path]; exists {
continue
}
name := path
if parts := strings.Split(strings.Trim(path, "/"), "/"); len(parts) > 0 {
name = parts[len(parts)-1]
}
if _, err := a.carddavStore.CreateAddressbook(id, path, name, true); err != nil {
if _, err := a.carddavStore.CreateAddressbook(id, path, addressbookDisplayName(config.AddressbookNames, path), true); err != nil {
log.Warn().Err(err).Str("path", path).Msg("Failed to create new addressbook")
}
}
Expand Down Expand Up @@ -644,3 +634,16 @@ func (a *App) CancelContactSourceOAuthFlow() {

wailsRuntime.EventsEmit(a.ctx, "contact-source-oauth:cancelled", nil)
}

// addressbookDisplayName resolves the stored name for an addressbook path:
// the display name discovery found (passed through SourceConfig, #366),
// falling back to the path's last segment.
func addressbookDisplayName(names map[string]string, path string) string {
if name := names[path]; name != "" {
return name
}
if parts := strings.Split(strings.Trim(path, "/"), "/"); len(parts) > 0 && parts[len(parts)-1] != "" {
return parts[len(parts)-1]
}
return path
}
22 changes: 21 additions & 1 deletion app/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -801,6 +803,14 @@ func (a *App) PrepareReply(messageID, mode string) (*smtp.ComposeMessage, error)
log.Warn().Err(inlineErr).Msg("Failed to get inline attachments for reply/forward")
}
for cid, dataURL := range inlineMap {
// Replies include an "inline" attachment only when the quoted HTML
// actually embeds it — mailers stamp Content-IDs on ordinary document
// attachments, and re-attaching those sends phantom copies with the
// cid as filename (#381). Forwards keep every part (the forwarder
// wants the documents).
if mode != "forward" && !quotedHTMLReferencesCID(quotedHTML, cid) {
continue
}
ct, b64 := parseDataURL(dataURL)
if b64 == "" {
continue
Expand Down Expand Up @@ -832,6 +842,15 @@ func (a *App) PrepareReply(messageID, mode string) (*smtp.ComposeMessage, error)
}, nil
}

// quotedHTMLReferencesCID reports whether the quoted HTML actually references
// the given (bracket-stripped) Content-ID. Guards against re-attaching
// misclassified "inline" documents that the reply body never embeds (#381).
// Plain substring match errs toward keeping an attachment when the cid
// appears anywhere in the body, regardless of quote style.
func quotedHTMLReferencesCID(html, cid string) bool {
return strings.Contains(html, "cid:"+cid)
}

// fetchForwardAttachments fetches regular (non-inline) attachment content from IMAP
// and appends them to the attachments slice. Failures are logged but not fatal.
func (a *App) fetchForwardAttachments(log zerolog.Logger, msg *message.Message, attachments *[]smtp.Attachment) {
Expand Down Expand Up @@ -889,7 +908,7 @@ func parseDataURL(dataURL string) (contentType, base64Data string) {
}

// TestSMTPConnection tests SMTP connection settings
func (a *App) TestSMTPConnection(host string, port int, security, username, password string) error {
func (a *App) TestSMTPConnection(host string, port int, security, username, password, authMechanism string) error {
log := logging.WithComponent("app")

// Map security string to type
Expand All @@ -913,6 +932,7 @@ func (a *App) TestSMTPConnection(host string, port int, security, username, pass
config.Username = username
config.Password = password
config.AuthType = smtp.AuthTypePassword
config.AuthMechanism = authMechanism
config.TLSConfig = certificate.BuildTLSConfig(host, a.certStore)

client := smtp.NewClient(config)
Expand Down
Loading
Loading