feat: sync every kind of IMAP mailbox (issues #345-#349): connect servers without CONDSTORE by following UIDNEXT per folder and mirroring read state with a periodic flag scan, so Outlook.com, Microsoft 365 over IMAP and Yahoo work instead of failing at load; fall back to a STATUS per folder when the server has no LIST-STATUS, which silently made an account look empty; bound the wait between IMAP responses so a peer that vanishes without a FIN cannot park a command forever; keep the inbox and special folders when a mailbox has more folders than the cap (raised to 100) and relay the overflow as a warning instead of failing the mailbox silently; recognize localized folder names and the server's own hierarchy delimiter so a Sent folder called Gesendete Elemente is not filed as inbox; and back off on a widening interval while a mail server is unreachable so one outage is one warning rather than one a minute

This commit is contained in:
Matthew Meszaros
2026-09-07 03:12:22 -07:00
parent e9754e939f
commit f2c35cf872
23 changed files with 1510 additions and 232 deletions
@@ -78,6 +78,9 @@ Newer builds return the invite-only refusal with its own machine code, `registra
| "No mailbox workers are available" when connecting a mailbox | No worker has a heartbeat inside the last 10 minutes. Check `make status` shows `worker` running and `make logs worker` is clean. An empty `ENCRYPTED_KEYS_BACKEND_URL` or worker token lets a worker start and never register, silently |
| Connecting a mailbox fails with `SERVER_UNREACHABLE` on a reachable host | The security setting does not match the server. A server expecting STARTTLS looks unreachable to a client attempting implicit TLS, and vice versa. Any port from 1 to 65535 is accepted, so the port alone no longer decides: set **Security** to SSL / TLS for a server that is encrypted from the first byte (usually SMTP `465`, IMAP `993`) and STARTTLS for one that upgrades in place (usually SMTP `587` or `2525`, IMAP `143`) |
| A mailbox stalls after about an hour | The worker is missing `BOX_GOOGLE_*` or `BOX_OUTLOOK_*`. The backend starts the OAuth flow but each worker refreshes the token. Set them and restart the worker |
| An IMAP mailbox connects but no mail ever arrives | Check the sync card in the mailbox drawer for a folder count. Zero folders on a reachable server means the server refused `STATUS` for every folder; the worker log names each one it skipped. A connected mailbox whose inbox is genuinely empty is normal |
| An IMAP mailbox stopped syncing and the log is quiet | Sessions dropped by the server, or by a firewall that removed the mapping without closing the connection, are re-dialed on the next pass, and every pass that cannot reach the server is retried on a widening interval up to five minutes. If a mailbox is still stuck, `make logs worker` shows the folder cursors; a mailbox held by the sync budget says so in its drawer instead |
| A folder is missing from the unibox | Up to `100` folders per mailbox are synced (**Instance settings > Limits**). Past that, the inbox and the special folders are kept and the rest follow the server's order, with a warning in the drawer naming how many were left out. Gmail's All Mail, Starred and Important are label views over other folders and are deliberately never synced |
| Scheduled sends never fire | Delayed sends run through the in-process Postgres task poller (`TASKS_PROVIDER=local`), so the backend must be running |
| Every send dead-letters with `permission denied` on `/data/blobs` | The `blobs` volume was created before the images owned that path, so it is still `root:root` while the services run as uid 1000. Fix it once with `docker compose -p warmbly exec -u root backend chown -R warmbly:warmbly /data/blobs`. The `blob_fs_root` health check reports it, and volumes created from current images are already correct |
| `email account <id> not found in worker` | The mailbox is assigned to a worker that no longer exists, usually because the worker was recreated and came back with a fresh UUID. The reconciler releases and re-places it on a live worker within its interval. Compose workers now keep their id in the `worker_state` volume (`WORKER_STATE_DIR`), so this stops recurring once that volume exists; removing the volume or unsetting `WORKER_STATE_DIR` reintroduces the churn. See [worker identity](/development/deployment-guide/#worker-identity) |
+11 -1
View File
@@ -13,7 +13,7 @@ Open **Accounts** and choose **Add account**.
|----------|--------|-------|
| Gmail / Google Workspace | OAuth (`gmail`) | Recommended. No password stored. Sends and syncs over the Gmail API |
| Outlook / Microsoft 365 | OAuth (`outlook`) | Recommended. No password stored. Runs on Microsoft Graph |
| Any other server | IMAP + SMTP (`smtp_imap`) | Custom domains, self-hosted, or providers without OAuth |
| Any other server | IMAP + SMTP (`smtp_imap`) | Custom domains, self-hosted, or providers without OAuth. Any IMAP server works, including Outlook.com, Microsoft 365 over IMAP, Yahoo, Fastmail, Zoho, cPanel and self-hosted Dovecot |
**OAuth** sends you to your provider's consent screen and returns a token instead of a password. Both OAuth providers use the provider's native API, never IMAP or SMTP, so consent asks to send mail and to read and organize your mailbox. It needs no app passwords or server settings. Note that Google revokes Gmail tokens when the account's password changes, so a password change there means [re-authorizing the mailbox](#reconnecting-an-account) once.
@@ -35,6 +35,8 @@ The form picks the right one from the port as you type, so standard setups need
If a mailbox fails to connect with a server-unreachable error and the host and port are definitely right, the security setting is the first thing to check. A server expecting STARTTLS looks unreachable to a client attempting implicit TLS, and vice versa.
When a mail server goes down or stops answering, the mailbox is not deactivated. Warmbly says so once in the drawer, retries on a widening interval, and picks up where it left off when the server comes back. Nothing that arrived meanwhile is skipped.
With two-factor authentication on, generate an app password in your provider's security settings and use that.
<Callout type="warn" title="Authentication runs on connect">
@@ -111,6 +113,14 @@ Connecting a mailbox does two things: it imports the mailbox's recent history, a
**Live sync** then follows every folder the provider exposes, including junk (so placement problems are visible) and sent mail (so a conversation shows both sides). Read state, flags, deletions and moves are mirrored too.
Nested folders are followed as well, so mail in a subfolder of the inbox or under a label group arrives like anything else. Up to `100` folders per mailbox are synced. Past that the inbox, sent, drafts, spam, trash and archive are always kept and the rest are taken in the order the server lists them, with a note in the mailbox drawer saying how many were left out.
On IMAP, folders are identified by the standard attributes a server publishes, and by name when it publishes none. The common names are recognized in a dozen languages, so a mailbox whose Sent folder is called "Gesendete Elemente" or "Éléments envoyés" still files sent mail as sent rather than as inbox.
<Callout type="info" title="Read state on older IMAP servers">
Some IMAP servers, including Outlook.com, Microsoft 365 over IMAP and Yahoo, cannot tell a client what changed since it last looked. New mail from those servers still arrives within a minute. Reading or flagging a message in another mail client shows up in Warmbly within about ten minutes rather than immediately. Nothing is lost either way, and Gmail, Outlook over OAuth, Fastmail and most self-hosted servers are immediate.
</Callout>
<Callout type="info" title="Fair use, not a hard cap">
There is a budget on how much new mail one mailbox stores per day (`2,000` by default) and how much a whole workspace stores per day (`25,000`), plus a short burst limit so a mailing-list storm cannot swamp the workers. Mail over a budget is not dropped: it waits on the server with the sync cursor held, and comes in when the window rolls. Replies to your own outreach have their own budget and are never held behind ordinary inbound mail. The drawer says "Waiting on the sync budget until ..." while this is happening, and the mailbox checks back less often until then.
</Callout>
+5 -1
View File
@@ -75,7 +75,11 @@ func mailErrorToJobEventType(mailErr *errx.MailError) models.JobEventType {
case errx.MailErrorCodeServerUnreachable,
errx.MailErrorCodeConnectionLost,
errx.MailErrorCodeNotFound,
errx.MailErrorCodeImapUnknown:
errx.MailErrorCodeImapUnknown,
// The mailbox has more folders than the sync follows. A warning, not
// a failure: the inbox and the special folders are always among the
// ones kept, so mail keeps arriving.
errx.MailErrorCodeFolderLimit:
return models.JobEventTypeEmailServerError
}
return ""
+7
View File
@@ -17,9 +17,16 @@ import (
type ImapConn interface {
// Sync pass.
Folders() ([]models.Mailbox, *errx.MailError)
// FolderOverflow is how many folders the last listing left out.
FolderOverflow() int
// HasCondStore picks the incremental strategy: mod-sequences when the
// server has CONDSTORE, UIDNEXT plus a periodic flag scan when it does not.
HasCondStore() bool
ReleaseMailbox()
SelectForSync(mailbox string) (uint32, *errx.MailError)
SearchChangedSince(modSeq uint64) ([]goimap.UID, *errx.MailError)
SearchNewSince(uidNext uint32) ([]goimap.UID, *errx.MailError)
FetchFlags(ctx context.Context, uidFrom uint32) (map[uint32]imap.FlagState, *errx.MailError)
SearchSince(since time.Time) ([]goimap.UID, *errx.MailError)
FetchEnvelopes(ctx context.Context, uids []goimap.UID) ([]*imap.Fetched, *errx.MailError)
FetchBody(f *imap.Fetched)
+36 -3
View File
@@ -11,7 +11,8 @@ import (
)
// syncBackoffMax is the longest a mailbox waits between passes: while fair
// use holds it, or after the provider itself asked us to slow down.
// use holds it, after the provider asked us to slow down, or while the mail
// server is unreachable.
const syncBackoffMax = 5 * time.Minute
// StartSyncWorker runs the mail sync loop until the context is cancelled.
@@ -43,10 +44,17 @@ func (w *WMail) StartSyncWorker(ctx context.Context) {
// nextSyncDelay picks the wait before the next pass.
func (w *WMail) nextSyncDelay(base time.Duration, last *errx.MailError) time.Duration {
d := base
if last != nil && last.Code == errx.MailErrorCodeSendingTooFast {
switch {
case last != nil && last.Code == errx.MailErrorCodeSendingTooFast:
// The provider returned 429: back off well past the base interval.
d = syncBackoffMax
} else if w.tracker != nil && w.tracker.state.ThrottledUntil != nil {
case last != nil && isTransportError(last):
// The server is unreachable. Retry soon after the first failure (a
// dropped session reconnects on the next pass and costs one dial),
// then step back toward the ceiling while it stays down, so a server
// that is out for a day does not write a warning every minute.
d = min(base<<min(w.transportFailures, 8), syncBackoffMax)
case w.tracker != nil && w.tracker.state.ThrottledUntil != nil:
// Held by fair use: no point asking every minute; wake when the
// window rolls, bounded so a priority reply still lands promptly.
if until := time.Until(*w.tracker.state.ThrottledUntil); until > d {
@@ -74,9 +82,34 @@ func (w *WMail) syncOnce(ctx context.Context) (result *errx.MailError) {
}
}()
if err := w.SyncMail(ctx); err != nil {
// A server that is down answers every pass the same way. Report the
// first one and then stay quiet until it comes back, so one outage is
// one warning in the drawer rather than one a minute.
if isTransportError(err) {
w.transportFailures++
if w.transportFailures > 1 {
log.Debug().Err(err).Str("email_id", w.ID.String()).Int("consecutive", w.transportFailures).Msg("mail server still unreachable")
return err
}
}
w.CaptureError(err)
log.Warn().Err(err).Str("email_id", w.ID.String()).Msg("mail sync error")
return err
}
w.transportFailures = 0
return nil
}
// isTransportError is the mail server being unreachable rather than refusing
// what we asked: a dropped session, a refused dial, a timeout. These retry on
// their own and must not be treated as a mailbox problem.
func isTransportError(err *errx.MailError) bool {
if err == nil {
return false
}
switch err.Code {
case errx.MailErrorCodeServerUnreachable, errx.MailErrorCodeConnectionLost:
return true
}
return false
}
+67 -104
View File
@@ -5,7 +5,6 @@ import (
"slices"
"sort"
"strconv"
"strings"
"time"
goimap "github.com/emersion/go-imap/v2"
@@ -40,16 +39,23 @@ func (w *WMail) Sync(ctx context.Context) *errx.MailError {
if err != nil {
return err
}
// Dropped here, not skipped below, so a label view a previous build
// baselined falls into the deletion sweep and its cursor is retired.
w.reportFolderOverflow()
// Folders() already drops Gmail's label views. Dropping them here too
// costs nothing and keeps the pass correct against any listing: a view
// that reached it would re-file known mail as archive under a second UID.
folders = slices.DeleteFunc(folders, func(b models.Mailbox) bool { return imapVirtualFolder(&b) })
// condStore decides the incremental strategy for the whole account:
// mod-sequences where the server has CONDSTORE, UIDNEXT where it does not
// (Outlook.com, Microsoft 365 over IMAP, Yahoo, many hosted servers).
condStore := client.HasCondStore()
for i := range folders {
box := &folders[i]
befBox := w.SmtpImapData.FindPair(box)
if befBox == nil {
// First sight: baseline. Live sync starts from this mod-sequence;
// the backfill owns everything before it.
// First sight: baseline. Live sync starts from this cursor; the
// backfill owns everything before it.
saved := *box
if err := w.mboxEvent(&saved); err != nil {
return nil
@@ -58,26 +64,28 @@ func (w *WMail) Sync(ctx context.Context) *errx.MailError {
continue
}
changed := imapFolderChanged(befBox, box, condStore)
fullyProcessed := true
if befBox.HighestModSeq != box.HighestModSeq && !stats.aborted {
if changed && !stats.aborted {
w.SmtpImapData.mailbox = box.UIDValidity
w.SmtpImapData.folder = imapCanonicalFolder(box)
done, err := w.imapIncremental(ctx, box, befBox.HighestModSeq, stats)
w.SmtpImapData.folder = imap.CanonicalFolder(box.Name, box.Attrs)
done, err := w.imapIncremental(ctx, box, befBox, condStore, stats)
if err != nil {
return err
}
fullyProcessed = done
} else if befBox.HighestModSeq != box.HighestModSeq {
} else if changed {
// The pass was aborted before this folder; hold its cursor too.
fullyProcessed = false
}
if befBox.HighestModSeq != box.HighestModSeq || befBox.Name != box.Name || !slices.Equal(befBox.Attrs, box.Attrs) {
// The stored mod-sequence only moves once every change up to it
// was stored; a deferred message keeps the folder re-asked.
if changed || befBox.Name != box.Name || !slices.Equal(befBox.Attrs, box.Attrs) {
// The stored cursor only moves once every change up to it was
// stored; a deferred message keeps the folder re-asked.
next := *box
if !fullyProcessed {
next.HighestModSeq = befBox.HighestModSeq
next.UIDNext = befBox.UIDNext
}
if err := w.mboxEvent(&next); err != nil {
return nil
@@ -85,11 +93,26 @@ func (w *WMail) Sync(ctx context.Context) *errx.MailError {
for _, ibox := range w.SmtpImapData.Mailboxes {
if ibox.UIDValidity == box.UIDValidity {
ibox.HighestModSeq = next.HighestModSeq
ibox.UIDNext = next.UIDNext
ibox.Name = next.Name
ibox.Attrs = next.Attrs
}
}
}
// Without CONDSTORE a message marked read elsewhere moves no cursor,
// so read state is mirrored by a periodic scan instead. It runs after
// the arrivals above so a message stored this pass is already known.
if !condStore && !stats.aborted {
w.SmtpImapData.mailbox = box.UIDValidity
w.SmtpImapData.folder = imap.CanonicalFolder(box.Name, box.Attrs)
if _, err := w.SmtpImapData.ImapClient.SelectForSync(box.Name); err != nil {
return err
}
if err := w.imapScanFlags(ctx, box, stats); err != nil {
return err
}
}
}
// Collect deletions first to avoid modifying the slice during iteration
@@ -113,6 +136,9 @@ outer:
}
if len(deleted) > 0 {
for _, uidv := range deleted {
delete(w.flagScan, uidv)
}
filtered := w.SmtpImapData.Mailboxes[:0]
for _, b := range w.SmtpImapData.Mailboxes {
if !slices.Contains(deleted, b.UIDValidity) {
@@ -132,11 +158,22 @@ outer:
return nil
}
// imapIncremental stores what changed in one folder since modSeq. Known
// messages relay their flags unbudgeted; new ones are admitted newest first.
// It reports whether every change was stored, which is what lets the folder's
// mod-sequence advance.
func (w *WMail) imapIncremental(ctx context.Context, box *models.Mailbox, modSeq uint64, stats *tickStats) (bool, *errx.MailError) {
// imapFolderChanged reports whether a folder has anything new since the
// cursor we hold for it. With CONDSTORE the mod-sequence answers for new mail
// AND flag changes; without it only arrivals are visible here, and flag
// changes are picked up by the periodic scan in imapIncremental.
func imapFolderChanged(before, now *models.Mailbox, condStore bool) bool {
if condStore {
return before.HighestModSeq != now.HighestModSeq
}
return before.UIDNext != now.UIDNext
}
// imapIncremental stores what changed in one folder since the held cursor.
// Known messages relay their flags unbudgeted; new ones are admitted newest
// first. It reports whether every change was stored, which is what lets the
// folder's cursor advance.
func (w *WMail) imapIncremental(ctx context.Context, box, before *models.Mailbox, condStore bool, stats *tickStats) (bool, *errx.MailError) {
client := w.SmtpImapData.ImapClient
count, err := client.SelectForSync(box.Name)
if err != nil {
@@ -145,7 +182,12 @@ func (w *WMail) imapIncremental(ctx context.Context, box *models.Mailbox, modSeq
if count == 0 {
return true, nil
}
uids, err := client.SearchChangedSince(modSeq)
var uids []goimap.UID
if condStore {
uids, err = client.SearchChangedSince(before.HighestModSeq)
} else {
uids, err = client.SearchNewSince(before.UIDNext)
}
if err != nil {
return false, err
}
@@ -408,99 +450,20 @@ func (w *WMail) imapBackfill(ctx context.Context, folders []models.Mailbox, stat
return nil
}
// imapVirtualFolder is a Gmail label view (All Mail, Starred, Important):
// every message in it also lives in a real folder under a different UID, so
// syncing it would re-file known mail (All Mail reads as archive) and swap
// the (mailbox, uid) pair the warmup actions address. Neither lane looks at
// these; a message archived out of every real folder stays unsynced, which
// is the ceiling of Gmail-over-IMAP — the OAuth Gmail path has no such gap.
// imapVirtualFolder, imapBackfillEligible and imapCanonicalFolder classify a
// folder. The rules live in the imap client package, next to the LIST that
// produces the attributes, so the sync loop and the Sent-folder resolver
// cannot drift apart.
func imapVirtualFolder(box *models.Mailbox) bool {
for _, a := range box.Attrs {
switch strings.ToLower(a) {
case "\\all", "\\flagged", "\\important":
return true
}
}
// Name fallback only inside Gmail's own namespace: a plain IMAP server
// can legitimately have a user folder called "Important" or "Starred".
name := strings.ToLower(box.Name)
if !strings.HasPrefix(name, "[gmail]/") && !strings.HasPrefix(name, "[google mail]/") {
return false
}
switch name[strings.Index(name, "/")+1:] {
case "all mail", "starred", "important":
return true
}
return false
return imap.IsVirtualFolder(box.Name, box.Attrs)
}
// imapBackfillEligible excludes folders whose history is not worth importing:
// trash, spam and Gmail's virtual "All Mail" (a duplicate of every other
// folder). Live sync still follows them for placement signals and to file new
// mail into the Spam and Trash scopes; only the bounded initial import skips
// them, because their history would consume the message budget that belongs to
// real conversations. Drafts IS imported: it is small and a Drafts scope with
// none of the mailbox's existing drafts in it reads as broken.
//
// Special-use attributes are authoritative, with a name fallback for servers
// that do not advertise them.
func imapBackfillEligible(box *models.Mailbox) bool {
if imapVirtualFolder(box) {
return false
}
for _, a := range box.Attrs {
switch strings.ToLower(a) {
case "\\noselect", "\\nonexistent", "\\trash", "\\junk", "\\all":
return false
}
}
name := strings.ToLower(box.Name)
if i := strings.LastIndexAny(name, "/."); i >= 0 {
name = name[i+1:]
}
switch name {
case "trash", "bin", "junk", "spam", "deleted items", "deleted messages", "junk e-mail", "junk email", "bulk mail":
return false
}
return true
return imap.BackfillEligible(box.Name, box.Attrs)
}
// imapCanonicalFolder maps an IMAP folder to the canonical unibox folder.
// Special-use attributes are authoritative, with a name fallback for servers
// that do not advertise them; unrecognized user folders file as inbox so
// their mail stays visible.
func imapCanonicalFolder(box *models.Mailbox) string {
for _, a := range box.Attrs {
switch strings.ToLower(a) {
case "\\sent":
return models.FolderSent
case "\\drafts":
return models.FolderDrafts
case "\\junk":
return models.FolderSpam
case "\\trash":
return models.FolderTrash
case "\\archive", "\\all":
return models.FolderArchive
}
}
name := strings.ToLower(box.Name)
if i := strings.LastIndexAny(name, "/."); i >= 0 {
name = name[i+1:]
}
switch name {
case "sent", "sent mail", "sent items", "sent messages":
return models.FolderSent
case "drafts", "draft":
return models.FolderDrafts
case "junk", "spam", "junk e-mail", "junk email", "bulk mail":
return models.FolderSpam
case "trash", "bin", "deleted", "deleted items", "deleted messages":
return models.FolderTrash
case "archive", "archives", "all mail":
return models.FolderArchive
}
return models.FolderInbox
return imap.CanonicalFolder(box.Name, box.Attrs)
}
// controlPlaneError handles a failed map lookup, body store or event publish
@@ -0,0 +1,137 @@
package wmail
import (
"context"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/client/smtpimap/imap"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// reportFolderOverflow tells the user once per worker session that the
// mailbox has more folders than the sync follows. Once, not per pass: the
// condition is static until someone reorganizes their mail, and a warning a
// minute would bury every real error in the drawer.
func (w *WMail) reportFolderOverflow() {
left := w.SmtpImapData.ImapClient.FolderOverflow()
if left <= 0 || w.SmtpImapData.overflowReported {
return
}
w.SmtpImapData.overflowReported = true
w.CaptureError(errx.ErrMailFoldersOverflow(left))
}
// imapScanFlags mirrors read state and flag changes on a server without
// CONDSTORE, which cannot say what changed: it re-reads the flags of the
// folder's newest window and relays only the messages whose flags actually
// moved since the previous scan.
//
// It is deliberately periodic (config.ImapFlagScanInterval) rather than every
// pass: it is one FETCH per folder over up to config.ImapFlagScanWindow UIDs,
// and read state is not worth a round trip a minute per folder. New mail does
// not wait for it; that arrives through UIDNEXT on every pass.
func (w *WMail) imapScanFlags(ctx context.Context, box *models.Mailbox, stats *tickStats) *errx.MailError {
if w.flagScan == nil {
w.flagScan = map[uint32]*folderFlagScan{}
}
scan := w.flagScan[box.UIDValidity]
now := time.Now()
if scan != nil && now.Sub(scan.at) < config.ImapFlagScanInterval {
return nil
}
from := uint32(1)
if box.UIDNext > config.ImapFlagScanWindow {
from = box.UIDNext - config.ImapFlagScanWindow
}
flags, err := w.SmtpImapData.ImapClient.FetchFlags(ctx, from)
if err != nil {
return err
}
// The first scan of a folder only records the baseline: without a
// previous scan every message would read as changed and the whole window
// would be relayed for nothing.
if scan != nil {
for uid, state := range flags {
before, ok := scan.flags[uid]
// Not in the previous scan means it arrived since; the UIDNEXT
// path owns it and will store it with its flags.
if !ok || sameFlags(before.Flags, state.Flags) {
continue
}
if err := w.relayFlags(ctx, box, uid, state, stats); err != nil {
return err
}
if stats.aborted {
break
}
}
}
w.flagScan[box.UIDValidity] = &folderFlagScan{at: now, flags: flags}
return nil
}
// relayFlags sends an UPDATE_EMAIL for one message whose flags moved. A
// message the platform does not know is skipped: it is not ours to update,
// and the message paths admit it under a budget instead.
func (w *WMail) relayFlags(ctx context.Context, box *models.Mailbox, uid uint32, state imap.FlagState, stats *tickStats) *errx.MailError {
if state.MessageID == "" {
return nil
}
internal, err := w.EmailMessageMapRepository.Get(ctx, w.UserID, w.ID, state.MessageID)
if err != nil {
return w.controlPlaneError(err, stats)
}
if internal == nil {
return nil
}
internalID, perr := uuid.Parse(internal.ID)
if perr != nil {
return nil
}
if err := w.onEvent(models.JobEventTypeEmailUpdate, &models.JobEventEmailUpdate{
UserID: w.UserID,
EmailID: w.ID,
ID: internalID,
UID: uid,
Mailbox: box.UIDValidity,
Folder: imapCanonicalFolder(box),
Flags: state.Flags,
}); err != nil {
return w.controlPlaneError(err, stats)
}
return nil
}
// folderFlagScan is the previous flag snapshot of one folder, held in worker
// memory only: a replaced worker re-baselines on its first scan, which costs
// one FETCH and no wrong updates.
type folderFlagScan struct {
at time.Time
flags map[uint32]imap.FlagState
}
func sameFlags(a, b []string) bool {
if len(a) != len(b) {
return false
}
// Flag sets are tiny (under ten) and usually identical, so the quadratic
// compare beats allocating a set per message per scan.
for _, x := range a {
found := false
for _, y := range b {
if x == y {
found = true
break
}
}
if !found {
return false
}
}
return true
}
+215
View File
@@ -12,6 +12,7 @@ import (
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
// fakeImapConn is the sync pass's view of a server. Only the methods a pass
@@ -23,10 +24,43 @@ type fakeImapConn struct {
changed []goimap.UID
fetches int
released int
overflow int
// noCondStore drives the UIDNEXT path instead of the mod-sequence one.
noCondStore bool
flags map[uint32]imap.FlagState
flagScans int
}
func (c *fakeImapConn) Folders() ([]models.Mailbox, *errx.MailError) { return c.folders, nil }
func (c *fakeImapConn) FolderOverflow() int { return c.overflow }
// condStore defaults to true: most of these tests exercise the mod-sequence
// path, and the UIDNEXT path has its own tests.
func (c *fakeImapConn) HasCondStore() bool { return !c.noCondStore }
func (c *fakeImapConn) SearchNewSince(uidNext uint32) ([]goimap.UID, *errx.MailError) {
var out []goimap.UID
for _, uid := range c.changed {
if uint32(uid) >= uidNext {
out = append(out, uid)
}
}
return out, nil
}
func (c *fakeImapConn) FetchFlags(context.Context, uint32) (map[uint32]imap.FlagState, *errx.MailError) {
c.flagScans++
// A fresh map per call, like the real client: the scan keeps the result
// as its baseline, so handing back the same map would compare it to
// itself and never see a change.
out := make(map[uint32]imap.FlagState, len(c.flags))
for uid, st := range c.flags {
out[uid] = st
}
return out, nil
}
func (c *fakeImapConn) ReleaseMailbox() { c.released++ }
func (c *fakeImapConn) SelectForSync(string) (uint32, *errx.MailError) {
@@ -249,6 +283,8 @@ type backfillImapConn struct {
}
func (c *backfillImapConn) Folders() ([]models.Mailbox, *errx.MailError) { return c.folders, nil }
func (c *backfillImapConn) FolderOverflow() int { return 0 }
func (c *backfillImapConn) HasCondStore() bool { return true }
func (c *backfillImapConn) ReleaseMailbox() {}
func (c *backfillImapConn) SelectForSync(name string) (uint32, *errx.MailError) {
@@ -343,3 +379,182 @@ func TestImapBackfillRetriesAFolderAfterATransientFailure(t *testing.T) {
t.Error("no history was imported at all")
}
}
// On a server without CONDSTORE the pass follows UIDNEXT instead of the
// mod-sequence. Refusing those servers is what left Outlook.com, Microsoft
// 365 over IMAP and Yahoo mailboxes unable to sync at all.
func TestImapSyncFollowsUIDNextWithoutCondStore(t *testing.T) {
conn := &fakeImapConn{
noCondStore: true,
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, UIDNext: 104}},
changed: []goimap.UID{101, 102, 103},
}
w, events := newIMAPTestMail(conn, &fixedBudget{allow: 10},
&models.Mailbox{Name: "INBOX", UIDValidity: 7, UIDNext: 101})
if err := w.Sync(t.Context()); err != nil {
t.Fatalf("Sync: %v", err)
}
if conn.fetches != 1 {
t.Errorf("fetched %d batches, want 1: the new mail above the cursor", conn.fetches)
}
if got := w.SmtpImapData.Mailboxes[0].UIDNext; got != 104 {
t.Errorf("UIDNEXT cursor = %d, want 104 once everything was stored", got)
}
if !hasEvent(*events, models.JobEventTypeNewEmail) {
t.Error("no mail was stored on a server without CONDSTORE")
}
}
// The cursor is held when the budget defers part of the batch, exactly as the
// mod-sequence is: the held mail is re-offered next pass rather than skipped.
func TestImapSyncHoldsUIDNextWhenDeferred(t *testing.T) {
conn := &fakeImapConn{
noCondStore: true,
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, UIDNext: 500}},
changed: uidRange(3 * config.ImapFetchBatchSize),
}
w, _ := newIMAPTestMail(conn, &fixedBudget{allow: 0},
&models.Mailbox{Name: "INBOX", UIDValidity: 7, UIDNext: 1})
if err := w.Sync(t.Context()); err != nil {
t.Fatalf("Sync: %v", err)
}
if got := w.SmtpImapData.Mailboxes[0].UIDNext; got != 1 {
t.Errorf("UIDNEXT advanced to %d with mail still waiting on the server", got)
}
}
// A quiet folder must cost nothing: with the cursor level there is no search
// and no fetch, which is what keeps a per-minute pass cheap on a big account.
func TestImapSyncSkipsAQuietFolderWithoutCondStore(t *testing.T) {
conn := &fakeImapConn{
noCondStore: true,
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, UIDNext: 101}},
}
w, _ := newIMAPTestMail(conn, &fixedBudget{allow: 10},
&models.Mailbox{Name: "INBOX", UIDValidity: 7, UIDNext: 101})
if err := w.Sync(t.Context()); err != nil {
t.Fatalf("Sync: %v", err)
}
if conn.fetches != 0 {
t.Errorf("fetched %d batches from a folder with nothing new", conn.fetches)
}
}
// Read state is mirrored by the periodic scan on a server that cannot say what
// changed. The first scan only baselines: relaying it would send an update for
// every message in the window for nothing.
func TestImapFlagScanBaselinesThenRelaysChanges(t *testing.T) {
conn := &fakeImapConn{
noCondStore: true,
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, UIDNext: 101}},
flags: map[uint32]imap.FlagState{
1: {MessageID: "<known@test>", Flags: []string{}},
},
}
w, events := newIMAPTestMail(conn, &fixedBudget{allow: 10},
&models.Mailbox{Name: "INBOX", UIDValidity: 7, UIDNext: 101})
// The platform already has this message; only a known message can have
// its flags mirrored.
w.EmailMessageMapRepository = knownMessageMap{id: uuid.New().String()}
if err := w.Sync(t.Context()); err != nil {
t.Fatalf("Sync: %v", err)
}
if conn.flagScans != 1 {
t.Fatalf("ran %d flag scans, want 1", conn.flagScans)
}
if hasEvent(*events, models.JobEventTypeEmailUpdate) {
t.Fatal("the first scan relayed updates; it has nothing to compare against yet")
}
// The message is marked read in the customer's own mail client.
conn.flags[1] = imap.FlagState{MessageID: "<known@test>", Flags: []string{"\\Seen"}}
w.flagScan[7].at = time.Now().Add(-2 * config.ImapFlagScanInterval)
if err := w.Sync(t.Context()); err != nil {
t.Fatalf("second Sync: %v", err)
}
if !hasEvent(*events, models.JobEventTypeEmailUpdate) {
t.Error("a message marked read elsewhere was never mirrored")
}
}
// The scan is periodic, not per pass: it is a FETCH per folder and read state
// is not worth one every minute on every folder.
func TestImapFlagScanIsPeriodic(t *testing.T) {
conn := &fakeImapConn{
noCondStore: true,
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, UIDNext: 101}},
flags: map[uint32]imap.FlagState{},
}
w, _ := newIMAPTestMail(conn, &fixedBudget{allow: 10},
&models.Mailbox{Name: "INBOX", UIDValidity: 7, UIDNext: 101})
for i := 0; i < 3; i++ {
if err := w.Sync(t.Context()); err != nil {
t.Fatalf("Sync: %v", err)
}
}
if conn.flagScans != 1 {
t.Errorf("ran %d flag scans over three passes, want 1", conn.flagScans)
}
}
// A CONDSTORE server must not pay for the scan: its mod-sequence already
// reports flag changes.
func TestImapFlagScanIsSkippedWithCondStore(t *testing.T) {
conn := &fakeImapConn{
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, HighestModSeq: 100}},
flags: map[uint32]imap.FlagState{},
}
w, _ := newIMAPTestMail(conn, &fixedBudget{allow: 10},
&models.Mailbox{Name: "INBOX", UIDValidity: 7, HighestModSeq: 100})
if err := w.Sync(t.Context()); err != nil {
t.Fatalf("Sync: %v", err)
}
if conn.flagScans != 0 {
t.Errorf("ran %d flag scans on a CONDSTORE server", conn.flagScans)
}
}
// The overflow warning is reported once, not once a minute: the condition is
// static until someone reorganizes their mail.
func TestFolderOverflowIsReportedOnce(t *testing.T) {
conn := &fakeImapConn{
folders: []models.Mailbox{{Name: "INBOX", UIDValidity: 7, HighestModSeq: 100}},
overflow: 12,
}
w, events := newIMAPTestMail(conn, &fixedBudget{allow: 10},
&models.Mailbox{Name: "INBOX", UIDValidity: 7, HighestModSeq: 100})
for i := 0; i < 3; i++ {
if err := w.Sync(t.Context()); err != nil {
t.Fatalf("Sync: %v", err)
}
}
warnings := 0
for _, e := range *events {
if e.eventType == models.JobEventTypeEmailServerError {
warnings++
}
}
if warnings != 1 {
t.Errorf("relayed %d folder-overflow warnings over three passes, want 1", warnings)
}
}
// knownMessageMap answers every lookup with the same stored message, which is
// what lets a flag-scan test exercise the relay rather than the "not ours"
// early return.
type knownMessageMap struct{ id string }
func (knownMessageMap) Add(context.Context, repository.EmailMessageData) error { return nil }
func (m knownMessageMap) Get(_ context.Context, _, _ uuid.UUID, messageID string) (*repository.EmailMessageData, error) {
return &repository.EmailMessageData{ID: m.id, MessageID: messageID}, nil
}
func (knownMessageMap) Del(context.Context, uuid.UUID, uuid.UUID, string, uuid.UUID) error {
return nil
}
+10
View File
@@ -45,6 +45,10 @@ type SmtpImapData struct {
// folder is the canonical folder of the mailbox currently being walked,
// set alongside mailbox and stamped on every stored/updated message.
folder string
// overflowReported keeps the "more folders than we follow" warning to
// one per worker session; the condition is static until the user
// reorganizes their mail.
overflowReported bool
}
type WMail struct {
@@ -86,6 +90,12 @@ type WMail struct {
laneCache laneCache
googleTick *tickStats
graphTick *tickStats
// flagScan is the previous flag snapshot per folder, used only on IMAP
// servers without CONDSTORE, which cannot say what changed.
flagScan map[uint32]*folderFlagScan
// transportFailures counts consecutive passes that could not reach the
// mail server, which paces the retry and keeps one outage to one warning.
transportFailures int
Ctx context.Context
Cancel context.CancelFunc
+24 -8
View File
@@ -46,6 +46,7 @@ func (c *Client) AppendToSent(ctx context.Context, raw []byte, sentAt time.Time)
}
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
defer c.begin()()
cmd := c.client.Append(mailbox, int64(len(raw)), &imap.AppendOptions{
// The sender has, by definition, read what they just sent.
Flags: []imap.Flag{imap.FlagSeen},
@@ -77,6 +78,7 @@ func (c *Client) sentMailbox() (string, error) {
}
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
defer c.begin()()
// RETURN (SPECIAL-USE) is only legal when the server advertises it; without
// the capability the attributes may still arrive on an ordinary LIST.
@@ -96,12 +98,11 @@ func (c *Client) sentMailbox() (string, error) {
if byName != "" {
continue
}
for _, candidate := range ImapSent {
// Match the leaf too: plenty of servers namespace folders as
// "INBOX.Sent" or "INBOX/Sent".
if strings.EqualFold(f.Mailbox, candidate) || strings.EqualFold(leaf(f.Mailbox), candidate) {
byName = f.Mailbox
}
// Match the leaf: plenty of servers namespace folders as
// "INBOX.Sent" or "INBOX/Sent", and the name is localized wherever
// the server does not advertise \Sent.
if matchesFolderName(strings.ToLower(leafWithDelim(f.Mailbox, string(f.Delim))), ImapSent) {
byName = f.Mailbox
}
}
if err := list.Close(); err != nil {
@@ -121,11 +122,26 @@ func (c *Client) sentMailbox() (string, error) {
return c.sentMailboxName, nil
}
// leaf returns the last path component of a mailbox name under either of the
// two hierarchy delimiters servers use in practice.
// leaf returns the last path component of a mailbox name. LIST reports the
// server's real delimiter per folder (see leafWithDelim); this guesses when
// the caller has only a name, which covers rows stored before the delimiter
// was carried and the handful of servers that report none.
func leaf(name string) string {
if i := strings.LastIndexAny(name, "./"); i >= 0 && i+1 < len(name) {
return name[i+1:]
}
return name
}
// leafWithDelim is leaf against the delimiter the server itself reported, so
// a folder whose name legitimately contains a dot ("Q1.Reports" under a "/"
// server) is not cut in the middle.
func leafWithDelim(name, delim string) string {
if delim == "" {
return leaf(name)
}
if i := strings.LastIndex(name, delim); i >= 0 && i+len(delim) < len(name) {
return name[i+len(delim):]
}
return name
}
@@ -1,6 +1,9 @@
package imap
import "testing"
import (
"strings"
"testing"
)
func TestLeaf(t *testing.T) {
cases := map[string]string{
@@ -20,14 +23,11 @@ func TestLeaf(t *testing.T) {
func TestImapSentCoversCommonNames(t *testing.T) {
// The name list is the fallback when a server does not advertise the
// RFC 6154 \Sent attribute; these are what the common servers call it.
for _, name := range []string{"Sent", "Sent Items", "Sent Mail"} {
found := false
for _, candidate := range ImapSent {
if candidate == name {
found = true
}
}
if !found {
// Localized names matter as much as the English ones: a server that
// advertises no \Sent attribute reports the folder in its owner's
// language, and an unmatched Sent folder means no sent copies at all.
for _, name := range []string{"Sent", "Sent Items", "Sent Mail", "Gesendete Elemente", "Éléments envoyés", "Enviados", "Elküldött elemek"} {
if !matchesFolderName(strings.ToLower(name), ImapSent) {
t.Fatalf("ImapSent does not cover %q", name)
}
}
+158 -81
View File
@@ -69,6 +69,45 @@ type Client struct {
// under a command, and two paths that both see the drop dial once.
// Lock order: mu before lifecycle, and never nest a read lock.
lifecycle sync.RWMutex
// conn is the transport under client, nil until the first Connect. It
// is swapped under the lifecycle write lock and read under the read lock
// like client itself.
conn *idleConn
// IdleTimeout bounds how long a command waits for the server to say
// anything before the session is declared dead. Zero means
// config.ImapCommandIdleTimeout.
IdleTimeout time.Duration
// plaintext dials without TLS at all. Only the tests set it, to talk to
// the in-process server; no product path reaches it, because an IMAP
// session in the clear would put the mailbox password on the wire.
plaintext bool
// condStore is whether the current session advertised CONDSTORE after
// authentication, which decides between the mod-sequence and the UIDNEXT
// incremental sync.
condStore atomic.Bool
// folderOverflow is what the last Folders call had to leave out.
folderOverflow atomic.Int32
}
// begin starts the idle clock for one command; call the result on exit.
// Callers hold the lifecycle read lock, so conn cannot change underneath.
func (c *Client) begin() func() {
if c.conn == nil {
return func() {}
}
return c.conn.arm()
}
// HasCondStore reports whether the session supports CONDSTORE, the
// mod-sequence path of the incremental sync. Without it the sync loop keys
// on UIDNEXT and mirrors flags with a periodic scan.
func (c *Client) HasCondStore() bool {
return c.condStore.Load()
}
// ensureConnected re-dials after the server has dropped the session. go-imap
@@ -113,32 +152,50 @@ func (c *Client) connectLocked() *errx.MailError {
tlsConf := &tls.Config{
ServerName: host,
InsecureSkipVerify: netbind.InsecureTLS(),
InsecureSkipVerify: netbind.InsecureTLS(), //nolint:gosec // MAIL_TLS_INSECURE, local dev only
}
// Dial through netbind so both paths honour WORKER_BIND_IP, and wrap the
// socket before TLS so the idle clock sits under the encryption.
timeout := c.IdleTimeout
if timeout <= 0 {
timeout = config.ImapCommandIdleTimeout
}
raw, err := netbind.Dialer(c.BindIP).DialContext(context.Background(), "tcp", addr)
if err != nil {
return errx.ErrMailServerUnreachable
}
conn := &idleConn{Conn: raw, timeout: timeout}
var client *imapclient.Client
if models.ResolveIMAPSecurity(security, port) == models.MailSecurityStartTLS {
// Plaintext greeting, upgraded in-band. Dial through netbind so the
// STARTTLS path honours WORKER_BIND_IP like the implicit one.
conn, err := netbind.Dialer(c.BindIP).DialContext(context.Background(), "tcp", addr)
if err != nil {
return errx.ErrMailServerUnreachable
}
// NewStartTLS closes conn itself when the upgrade fails.
switch {
case c.plaintext:
client = imapclient.New(conn, nil)
case models.ResolveIMAPSecurity(security, port) == models.MailSecurityStartTLS:
// Plaintext greeting, upgraded in-band. NewStartTLS closes conn
// itself when the upgrade fails.
client, err = imapclient.NewStartTLS(conn, &imapclient.Options{TLSConfig: tlsConf})
if err != nil {
return errx.ErrMailServerUnreachable
}
} else {
conn, err := netbind.TLSDialer(c.BindIP, tlsConf).DialContext(context.Background(), "tcp", addr)
default:
tconn := tls.Client(conn, tlsConf)
hctx, cancel := context.WithTimeout(context.Background(), timeout)
err = tconn.HandshakeContext(hctx)
cancel()
if err != nil {
_ = tconn.Close()
return errx.ErrMailServerUnreachable
}
client = imapclient.New(conn, nil)
client = imapclient.New(tconn, nil)
}
c.client = client
c.conn = conn
c.selected.Store(false)
c.condStore.Store(false)
done := conn.arm()
defer done()
var xerr *errx.MailError
@@ -155,13 +212,11 @@ func (c *Client) connectLocked() *errx.MailError {
return xerr
}
// CONDSTORE backs the ChangedSince incremental sync. Servers (Gmail,
// CONDSTORE backs the mod-sequence incremental sync. Servers (Gmail,
// Dovecot, ...) typically advertise it only after authentication, so the
// check must run post-auth.
if !c.client.Caps().Has(imap.CapCondStore) {
_ = client.Close()
return errx.ErrMailCondStoreNotSupported
}
// check must run post-auth. Without it (Outlook.com, Microsoft 365 over
// IMAP, Yahoo, many hosted servers) the sync loop keys on UIDNEXT instead.
c.condStore.Store(c.client.Caps().Has(imap.CapCondStore))
return nil
}
@@ -209,71 +264,10 @@ func (c *Client) oauth2Auth() *errx.MailError {
return nil
}
func (c *Client) Folders() ([]models.Mailbox, *errx.MailError) {
var resp []models.Mailbox
if err := c.ensureConnected(); err != nil {
return nil, err
}
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
// LIST-STATUS: without requesting these, f.Status is nil for every
// folder and the sync loop sees an empty account.
//
// "*", not "%": "%" stops at the top level, and on Gmail-over-IMAP every
// folder but INBOX lives under "[Gmail]/" (Dovecot commonly under
// "INBOX."), so Sent, Spam and Trash were never listed and never synced.
opts := &imap.ListOptions{
ReturnStatus: &imap.StatusOptions{
UIDValidity: true,
HighestModSeq: true,
},
}
// Gmail attaches \Sent, \Trash, \Junk, \All ... only when asked; on a
// plain LIST every folder is just \HasNoChildren and the canonical-folder
// mapping is left guessing from names ("Bin" filed as inbox).
if c.client.Caps().Has(imap.CapSpecialUse) {
opts.ReturnSpecialUse = true
}
cmd := c.client.List("", "*", opts)
for f := cmd.Next(); f != nil; f = cmd.Next() {
if len(resp) >= config.MaxEmailFolders {
// Drain the command first: unread LIST results would sit in the
// decoder channel and stall the next command on this session.
_ = cmd.Close()
return nil, errx.ErrMailFoldersMax
}
var attrs []string = make([]string, len(f.Attrs))
for i := range f.Attrs {
attrs[i] = string(f.Attrs[i])
}
if f.Status == nil {
continue
}
resp = append(resp, models.Mailbox{
Name: f.Mailbox,
Attrs: attrs,
UIDValidity: f.Status.UIDValidity,
HighestModSeq: f.Status.HighestModSeq,
})
}
if err := cmd.Close(); err != nil {
return nil, c.handleError(err)
}
return resp, nil
}
func (c *Client) Mailbox(mailbox string, uidvali, opts *imap.SelectOptions) error {
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
defer c.begin()()
if _, err := c.selectMailbox(mailbox, opts); err != nil {
return err
}
@@ -299,7 +293,9 @@ func (c *Client) selectMailbox(mailbox string, opts *imap.SelectOptions) (*imap.
func (c *Client) SelectForSync(mailbox string) (uint32, *errx.MailError) {
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
data, err := c.selectMailbox(mailbox, &imap.SelectOptions{ReadOnly: true, CondStore: true})
defer c.begin()()
// (CONDSTORE) on a server without it is a BAD.
data, err := c.selectMailbox(mailbox, &imap.SelectOptions{ReadOnly: true, CondStore: c.condStore.Load()})
if err != nil {
return 0, c.handleError(err)
}
@@ -322,6 +318,7 @@ func (c *Client) ReleaseMailbox() {
if c.client == nil || !c.selected.Load() || !c.client.Caps().Has(imap.CapUnselect) {
return
}
defer c.begin()()
if err := c.client.Unselect().Wait(); err == nil {
c.selected.Store(false)
}
@@ -352,9 +349,87 @@ func (c *Client) SearchChangedSince(modSeq uint64) ([]imap.UID, *errx.MailError)
return c.uidSearch(&imap.SearchCriteria{ModSeq: &imap.SearchCriteriaModSeq{ModSeq: modSeq + 1}})
}
// SearchNewSince returns the UIDs at or above uidNext: the mail that arrived
// since the folder's UIDNEXT was last recorded. It is the incremental set on
// a server without CONDSTORE. A "n:*" set with n past the end answers with
// the highest UID in the folder (RFC 3501 6.4.8), so the result is filtered.
func (c *Client) SearchNewSince(uidNext uint32) ([]imap.UID, *errx.MailError) {
if uidNext == 0 {
uidNext = 1
}
var set imap.UIDSet
set.AddRange(imap.UID(uidNext), 0)
uids, err := c.uidSearch(&imap.SearchCriteria{UID: []imap.UIDSet{set}})
if err != nil {
return nil, err
}
out := uids[:0]
for _, uid := range uids {
if uint32(uid) >= uidNext {
out = append(out, uid)
}
}
return out, nil
}
// FlagState is one message as the flag scan sees it: enough to find the
// platform's copy (the RFC Message-ID, which is the map key) and to compare
// its flags with the previous scan. Bodies and envelopes are not read.
type FlagState struct {
MessageID string
Flags []string
}
// FetchFlags reads the flags and Message-ID of every message at or above
// uidFrom in the selected mailbox, in one round trip with no bodies. It is
// how flag and read-state changes are found on a server without CONDSTORE:
// the caller diffs it against the previous scan.
func (c *Client) FetchFlags(ctx context.Context, uidFrom uint32) (map[uint32]FlagState, *errx.MailError) {
if uidFrom == 0 {
uidFrom = 1
}
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
defer c.begin()()
var set imap.UIDSet
set.AddRange(imap.UID(uidFrom), 0)
cmd := c.client.Fetch(set, &imap.FetchOptions{UID: true, Flags: true, Envelope: true})
out := map[uint32]FlagState{}
for em := cmd.Next(); em != nil; em = cmd.Next() {
var uid uint32
var st FlagState
for item := em.Next(); item != nil; item = em.Next() {
switch item := item.(type) {
case imapclient.FetchItemDataUID:
uid = uint32(item.UID)
case imapclient.FetchItemDataFlags:
st.Flags = make([]string, 0, len(item.Flags))
for _, f := range item.Flags {
st.Flags = append(st.Flags, string(f))
}
case imapclient.FetchItemDataEnvelope:
if item.Envelope != nil {
st.MessageID = item.Envelope.MessageID
}
}
}
if uid >= uidFrom {
out[uid] = st
}
if ctx.Err() != nil {
break
}
}
if err := cmd.Close(); err != nil {
return nil, c.handleError(err)
}
return out, nil
}
func (c *Client) uidSearch(criteria *imap.SearchCriteria) ([]imap.UID, *errx.MailError) {
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
defer c.begin()()
data, err := c.client.UIDSearch(criteria, nil).Wait()
if err != nil {
return nil, c.handleError(err)
@@ -375,6 +450,7 @@ func (c *Client) FetchEnvelopes(ctx context.Context, uids []imap.UID) ([]*Fetche
}
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
defer c.begin()()
cmd := c.client.Fetch(set, &imap.FetchOptions{
UID: true,
Envelope: true,
@@ -459,6 +535,7 @@ func (c *Client) FetchBody(f *Fetched) {
}
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
defer c.begin()()
f.Email.BodyPlain, f.Email.BodyHTML = fetchTextParts(c.client, f.uid, f.body)
}
+59
View File
@@ -0,0 +1,59 @@
package imap
import (
"net"
"sync/atomic"
"time"
)
// idleConn is the transport under one session. go-imap puts a deadline on
// the bytes of a response once its first byte has arrived, but waits for that
// first byte with no deadline at all, so a peer that vanished without a FIN
// (a NAT or firewall dropping the mapping) parks the command forever and the
// mailbox with it. While a command is in flight this keeps a deadline on the
// wait too; between commands it is cleared, because go-imap's reader blocks in
// Read the whole time and would otherwise time an idle session out.
type idleConn struct {
net.Conn
timeout time.Duration
inflight atomic.Int32
}
// arm starts the clock for one command. The returned func stops it; every
// command path calls it on exit.
func (c *idleConn) arm() func() {
if c.inflight.Add(1) == 1 {
_ = c.Conn.SetReadDeadline(time.Now().Add(c.timeout))
}
return func() {
if c.inflight.Add(-1) == 0 {
_ = c.Conn.SetReadDeadline(time.Time{})
}
}
}
// SetReadDeadline is where go-imap manages its own per-response deadline: it
// sets one while decoding a response and clears it in between. The cleared
// stretch is the gap: that is where the reader waits for the first byte of
// the next response, with nothing to fail it if the peer went away without a
// FIN. While a command is in flight the clear becomes our timeout instead.
//
// A deadline go-imap set itself is left alone, never shortened: it already
// allows five minutes for a large literal, and cutting that would fail a slow
// body fetch that is making progress.
func (c *idleConn) SetReadDeadline(t time.Time) error {
if t.IsZero() && c.inflight.Load() > 0 {
t = time.Now().Add(c.timeout)
}
return c.Conn.SetReadDeadline(t)
}
// Write bounds a send too: a large APPEND to a dead peer blocks once the
// socket buffer is full, and nothing else would ever fail it.
func (c *idleConn) Write(p []byte) (int, error) {
if c.inflight.Load() > 0 {
_ = c.Conn.SetWriteDeadline(time.Now().Add(c.timeout))
defer func() { _ = c.Conn.SetWriteDeadline(time.Time{}) }()
}
return c.Conn.Write(p)
}
+245
View File
@@ -0,0 +1,245 @@
package imap
import (
"errors"
"sort"
"strings"
"github.com/emersion/go-imap/v2"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// Folders lists the selectable folders of the account with the cursors the
// sync loop keys on (UIDVALIDITY, UIDNEXT and, on a CONDSTORE server,
// HIGHESTMODSEQ).
//
// "*", not "%": "%" stops at the top level, and on Gmail-over-IMAP every
// folder but INBOX lives under "[Gmail]/" (Dovecot commonly under "INBOX."),
// so Sent, Spam and Trash were never listed and never synced.
//
// The listing is capped at config.MaxEmailFolders, INBOX and the special
// folders first so a mailbox with hundreds of user folders still syncs what
// matters; FolderOverflow reports how many were left out.
func (c *Client) Folders() ([]models.Mailbox, *errx.MailError) {
return c.foldersCapped(config.MaxEmailFolders)
}
// foldersCapped is Folders with the cap injectable, so a test can exercise
// the overflow without standing up a hundred folders.
func (c *Client) foldersCapped(limit int) ([]models.Mailbox, *errx.MailError) {
if err := c.ensureConnected(); err != nil {
return nil, err
}
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
defer c.begin()()
caps := c.client.Caps()
status := &imap.StatusOptions{
UIDValidity: true,
UIDNext: true,
// Asking a server without CONDSTORE for HIGHESTMODSEQ is a BAD.
HighestModSeq: caps.Has(imap.CapCondStore),
}
opts := &imap.ListOptions{}
// LIST-STATUS folds the STATUS of every folder into the one round trip;
// without it each kept folder is asked separately below.
listStatus := caps.Has(imap.CapListStatus)
if listStatus {
opts.ReturnStatus = status
}
// Gmail attaches \Sent, \Trash, \Junk, \All ... only when asked; on a
// plain LIST every folder is just \HasNoChildren and the canonical-folder
// mapping is left guessing from names ("Bin" filed as inbox).
if caps.Has(imap.CapSpecialUse) {
opts.ReturnSpecialUse = true
}
var all []models.Mailbox
statuses := map[string]*imap.StatusData{}
cmd := c.client.List("", "*", opts)
for f := cmd.Next(); f != nil; f = cmd.Next() {
attrs := make([]string, len(f.Attrs))
for i := range f.Attrs {
attrs[i] = string(f.Attrs[i])
}
if !selectableFolder(attrs) || IsVirtualFolder(f.Mailbox, attrs) {
continue
}
box := models.Mailbox{Name: f.Mailbox, Attrs: attrs}
if f.Delim != 0 {
box.Delim = string(f.Delim)
}
all = append(all, box)
if f.Status != nil {
statuses[f.Mailbox] = f.Status
}
}
if err := cmd.Close(); err != nil {
return nil, c.handleError(err)
}
kept, overflow := rankFolders(all, limit)
c.folderOverflow.Store(int32(overflow))
resp := make([]models.Mailbox, 0, len(kept))
for _, box := range kept {
st := statuses[box.Name]
if st == nil {
if listStatus {
// The server was asked and said nothing: the folder is not
// one it can open for us.
continue
}
data, err := c.client.Status(box.Name, status).Wait()
if err != nil {
var imapErr *imap.Error
if errors.As(err, &imapErr) {
// One folder the server will not report on must not
// take the rest of the account with it.
log.Warn().Err(err).Str("folder", box.Name).Msg("imap: STATUS refused; folder skipped")
continue
}
return nil, c.handleError(err)
}
st = data
}
box.UIDValidity = st.UIDValidity
box.UIDNext = uint32(st.UIDNext)
box.HighestModSeq = st.HighestModSeq
resp = append(resp, box)
}
return resp, nil
}
// FolderOverflow is how many selectable folders the last Folders call left
// out because the account has more than config.MaxEmailFolders.
func (c *Client) FolderOverflow() int {
return int(c.folderOverflow.Load())
}
// rankFolders keeps INBOX first, then the special folders (sent, drafts,
// junk, trash, archive), then the rest in the server's order, and cuts the
// list at cap. The order within each rank is the server's, so the result is
// stable from one pass to the next.
func rankFolders(all []models.Mailbox, limit int) ([]models.Mailbox, int) {
rank := func(box *models.Mailbox) int {
switch {
case strings.EqualFold(box.Name, "INBOX"):
return 0
case CanonicalFolder(box.Name, box.Attrs) != models.FolderInbox:
return 1
}
return 2
}
sort.SliceStable(all, func(i, j int) bool { return rank(&all[i]) < rank(&all[j]) })
if len(all) <= limit {
return all, 0
}
return all[:limit], len(all) - limit
}
// selectableFolder is false for the containers a server lists only to show
// hierarchy (\Noselect) and the placeholders of LIST-EXTENDED (\NonExistent).
func selectableFolder(attrs []string) bool {
for _, a := range attrs {
switch strings.ToLower(a) {
case "\\noselect", "\\nonexistent":
return false
}
}
return true
}
// IsVirtualFolder is a Gmail label view (All Mail, Starred, Important):
// every message in it also lives in a real folder under a different UID, so
// syncing it would re-file known mail (All Mail reads as archive) and swap
// the (mailbox, uid) pair the warmup actions address. A message archived out
// of every real folder stays unsynced, which is the ceiling of
// Gmail-over-IMAP; the OAuth Gmail path has no such gap.
func IsVirtualFolder(name string, attrs []string) bool {
for _, a := range attrs {
switch strings.ToLower(a) {
case "\\all", "\\flagged", "\\important":
return true
}
}
// Name fallback only inside Gmail's own namespace: a plain IMAP server
// can legitimately have a user folder called "Important" or "Starred".
lower := strings.ToLower(name)
if !strings.HasPrefix(lower, "[gmail]/") && !strings.HasPrefix(lower, "[google mail]/") {
return false
}
switch lower[strings.Index(lower, "/")+1:] {
case "all mail", "starred", "important":
return true
}
return false
}
// BackfillEligible excludes folders whose history is not worth importing:
// trash, spam and Gmail's virtual views. Live sync still follows trash and
// spam for placement signals and to file new mail into those scopes; only the
// bounded initial import skips them, because their history would consume the
// message budget that belongs to real conversations. Drafts IS imported: it is
// small and a Drafts scope with none of the mailbox's existing drafts in it
// reads as broken.
func BackfillEligible(name string, attrs []string) bool {
if IsVirtualFolder(name, attrs) || !selectableFolder(attrs) {
return false
}
switch CanonicalFolder(name, attrs) {
case models.FolderTrash, models.FolderSpam:
return false
}
return true
}
// CanonicalFolder maps an IMAP folder to the canonical unibox folder.
// Special-use attributes are authoritative, with a name fallback for servers
// that do not advertise them; unrecognized user folders file as inbox so
// their mail stays visible.
func CanonicalFolder(name string, attrs []string) string {
for _, a := range attrs {
switch strings.ToLower(a) {
case "\\sent":
return models.FolderSent
case "\\drafts":
return models.FolderDrafts
case "\\junk":
return models.FolderSpam
case "\\trash":
return models.FolderTrash
case "\\archive", "\\all":
return models.FolderArchive
}
}
leafName := strings.ToLower(leaf(name))
switch {
case matchesFolderName(leafName, ImapSent):
return models.FolderSent
case matchesFolderName(leafName, ImapDrafts):
return models.FolderDrafts
case matchesFolderName(leafName, ImapSpam):
return models.FolderSpam
case matchesFolderName(leafName, ImapTrash):
return models.FolderTrash
case matchesFolderName(leafName, ImapArchive):
return models.FolderArchive
}
return models.FolderInbox
}
// matchesFolderName compares an already-lowercased leaf against one of the
// role lists. Exact match only: "spam reports" is a user folder, not spam.
func matchesFolderName(leafName string, names []string) bool {
for _, n := range names {
if leafName == n {
return true
}
}
return false
}
+62 -4
View File
@@ -1,8 +1,66 @@
package imap
// Folder names by role, lowercase, leaf only (the hierarchy prefix is
// stripped before matching). RFC 6154 special-use attributes are always
// preferred; these are the fallback for the servers that attach none, where
// the name is all there is. Older Exchange, cPanel and plenty of hosted
// Dovecots are in that group, and their folders carry the mailbox owner's
// language, so a list of English names alone files a customer's sent mail
// into their inbox.
//
// Kept as exported slices because the spam guard and the Sent resolver read
// them too. Everything here is compared case-insensitively.
var (
ImapSpam []string = []string{"Spam", "Junk"}
ImapSent []string = []string{"Sent Mail", "Sent", "Sent Items"}
ImapDrafts []string = []string{"Draft", "Drafts"}
ImapTrash []string = []string{"Trash", "Deleted", "Deleted Items"}
// ImapSpam is also the guard on the warmup "remove from spam" action, so
// a name that is merely spam-ish (a user folder called "Spam reports")
// must not appear here.
ImapSpam = []string{
"spam", "junk", "junk e-mail", "junk email", "bulk mail",
"correo no deseado", "no deseado", // Spanish
"courrier indésirable", "indésirables", "pourriel", // French
"junk-e-mail", "spamverdacht", // German, Dutch
"lixo eletrônico", "lixo electrónico", // Portuguese
"posta indesiderata", // Italian
"skräppost", "uønsket e-post", "roskaposti", // Swedish, Norwegian, Finnish
"spam-mappe", "levélszemét", // Danish-ish, Hungarian
"niechciane", "spam-post", // Polish
}
ImapSent = []string{
"sent", "sent mail", "sent items", "sent messages",
"enviados", "elementos enviados", "correo enviado", // Spanish
"éléments envoyés", "messages envoyés", "envoyés", // French
"gesendet", "gesendete elemente", "gesendete objekte", // German
"verzonden", "verzonden items", // Dutch
"itens enviados", "enviadas", // Portuguese
"posta inviata", "inviata", // Italian
"skickat", "skickade objekt", "sendt", "lähetetyt", // Nordic
"elküldött elemek", "elküldött üzenetek", // Hungarian
"elementy wysłane", "wysłane", // Polish
"odeslaná pošta", "trimise", // Czech, Romanian
}
ImapDrafts = []string{
"draft", "drafts",
"borradores", "brouillons", "entwürfe", "concepten",
"rascunhos", "bozze", "utkast", "luonnokset",
"piszkozatok", "kopie robocze", "koncepty", "ciorne",
}
ImapTrash = []string{
"trash", "bin", "deleted", "deleted items", "deleted messages",
"papelera", "elementos eliminados", // Spanish
"corbeille", "éléments supprimés", // French
"papierkorb", "gelöschte elemente", "gelöschte objekte", // German
"prullenbak", "verwijderde items", // Dutch
"lixeira", "itens excluídos", // Portuguese
"cestino", "posta eliminata", // Italian
"papperskorgen", "slettet post", "roskakori", // Nordic
"törölt elemek", "kuka", // Hungarian
"kosz", "elementy usunięte", // Polish
"koš", "coș de gunoi", // Czech, Romanian
}
ImapArchive = []string{
"archive", "archives", "all mail",
"archivo", "archivado", "archives", "archiv",
"archief", "arquivo", "archivio", "arkiv",
"arkisto", "archívum", "archiwum", "arhiva",
}
)
@@ -0,0 +1,403 @@
package imap
import (
"context"
"net"
"strings"
"testing"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
"github.com/warmbly/warmbly/internal/models"
)
// The in-process server has no CONDSTORE and no LIST-STATUS, which is exactly
// the shape this package used to refuse to talk to (Outlook.com, Microsoft
// 365 over IMAP, Yahoo, plenty of hosted servers). Every test here runs
// against it, so "works on a plain RFC 3501 server" is a thing CI checks
// rather than a thing we believe.
func testServer(t *testing.T, caps imap.CapSet, folders ...string) *Client {
t.Helper()
mem := imapmemserver.New()
user := imapmemserver.NewUser("warmbly@test", "hunter2")
if err := user.Create("INBOX", nil); err != nil {
t.Fatalf("create INBOX: %v", err)
}
for _, f := range folders {
if err := user.Create(f, nil); err != nil {
t.Fatalf("create %q: %v", f, err)
}
}
mem.AddUser(user)
if caps == nil {
caps = imap.CapSet{imap.CapIMAP4rev1: {}}
}
srv := imapserver.New(&imapserver.Options{
NewSession: func(*imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
return mem.NewSession(), nil, nil
},
Caps: caps,
InsecureAuth: true,
})
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() { _ = srv.Serve(ln) }()
t.Cleanup(func() { _ = srv.Close() })
host, port, _ := net.SplitHostPort(ln.Addr().String())
c := &Client{
Email: "warmbly@test",
AuthType: models.AuthPlain,
Credentials: &models.Service{
Username: "warmbly@test",
Password: "hunter2",
Host: host,
Port: atoi(port),
},
// The in-process server speaks plaintext; no product path does.
plaintext: true,
}
t.Cleanup(func() { _ = c.Close() })
return c
}
func atoi(s string) int {
n := 0
for _, r := range s {
n = n*10 + int(r-'0')
}
return n
}
// A server without CONDSTORE must connect. Refusing it is what left
// Outlook.com and Yahoo mailboxes unable to sync at all.
func TestConnectWithoutCondStore(t *testing.T) {
c := testServer(t, nil)
if err := c.Connect(); err != nil {
t.Fatalf("Connect: %v", err)
}
if c.HasCondStore() {
t.Error("HasCondStore on a server that does not advertise it")
}
}
// Without LIST-STATUS the cursors have to come from a STATUS per folder.
// Skipping the folders instead (what the old code did) made the account look
// empty and retired every saved cursor with nothing logged.
func TestFoldersWithoutListStatus(t *testing.T) {
c := testServer(t, nil, "Sent", "Archive")
if err := c.Connect(); err != nil {
t.Fatalf("Connect: %v", err)
}
boxes, err := c.Folders()
if err != nil {
t.Fatalf("Folders: %v", err)
}
if len(boxes) != 3 {
t.Fatalf("listed %d folders, want 3: %+v", len(boxes), boxes)
}
for _, b := range boxes {
if b.UIDValidity == 0 {
t.Errorf("%q came back with no UIDVALIDITY; its cursor would be meaningless", b.Name)
}
if b.UIDNext == 0 {
t.Errorf("%q came back with no UIDNEXT, which is the incremental cursor here", b.Name)
}
}
}
// A nested folder must be listed: "%" stopped at the top level, which is how
// Gmail's [Gmail]/Sent Mail and Dovecot's INBOX.Sent went unsynced.
func TestFoldersListsNested(t *testing.T) {
c := testServer(t, nil, "Clients", "Clients/Acme")
if err := c.Connect(); err != nil {
t.Fatalf("Connect: %v", err)
}
boxes, err := c.Folders()
if err != nil {
t.Fatalf("Folders: %v", err)
}
var names []string
for _, b := range boxes {
names = append(names, b.Name)
}
if !contains(names, "Clients/Acme") {
t.Fatalf("nested folder missing from %v", names)
}
}
// INBOX and the special folders survive the cap; the overflow is reported
// rather than failing the whole mailbox, which is what a user with a lot of
// folders used to get (silently).
func TestFoldersCapKeepsInboxAndSpecialFolders(t *testing.T) {
var many []string
for i := 0; i < 30; i++ {
many = append(many, "Project"+string(rune('a'+i%26))+string(rune('0'+i/26)))
}
many = append(many, "Sent", "Trash")
c := testServer(t, nil, many...)
if err := c.Connect(); err != nil {
t.Fatalf("Connect: %v", err)
}
boxes, err := c.foldersCapped(5)
if err != nil {
t.Fatalf("Folders: %v", err)
}
if len(boxes) != 5 {
t.Fatalf("kept %d folders, want the cap of 5", len(boxes))
}
var names []string
for _, b := range boxes {
names = append(names, b.Name)
}
for _, want := range []string{"INBOX", "Sent", "Trash"} {
if !contains(names, want) {
t.Errorf("%q was cut; the inbox and special folders must survive the cap. kept: %v", want, names)
}
}
if c.FolderOverflow() != len(many)+1-5 {
t.Errorf("FolderOverflow = %d, want %d", c.FolderOverflow(), len(many)+1-5)
}
}
// The UIDNEXT search is the incremental set without CONDSTORE: everything at
// or above the cursor, and nothing below it.
func TestSearchNewSince(t *testing.T) {
c := testServer(t, nil)
if err := c.Connect(); err != nil {
t.Fatalf("Connect: %v", err)
}
for i := 0; i < 3; i++ {
appendMessage(t, c, "INBOX", "<m"+string(rune('1'+i))+"@test>")
}
if _, err := c.SelectForSync("INBOX"); err != nil {
t.Fatalf("SelectForSync: %v", err)
}
all, err := c.SearchNewSince(1)
if err != nil {
t.Fatalf("SearchNewSince(1): %v", err)
}
if len(all) != 3 {
t.Fatalf("SearchNewSince(1) returned %d UIDs, want 3", len(all))
}
// A cursor past the end must return nothing. IMAP answers "n:*" with the
// last message when n is beyond the end, so an unfiltered result would
// re-offer the newest message on every quiet pass forever.
none, err := c.SearchNewSince(uint32(all[len(all)-1]) + 1)
if err != nil {
t.Fatalf("SearchNewSince(past the end): %v", err)
}
if len(none) != 0 {
t.Fatalf("a cursor past the end returned %v, want nothing", none)
}
}
// The flag scan is how read state is mirrored without CONDSTORE, so it has to
// carry the Message-ID the platform keys on.
func TestFetchFlagsCarriesMessageID(t *testing.T) {
c := testServer(t, nil)
if err := c.Connect(); err != nil {
t.Fatalf("Connect: %v", err)
}
appendMessage(t, c, "INBOX", "<flagme@test>")
if _, err := c.SelectForSync("INBOX"); err != nil {
t.Fatalf("SelectForSync: %v", err)
}
got, err := c.FetchFlags(context.Background(), 1)
if err != nil {
t.Fatalf("FetchFlags: %v", err)
}
if len(got) != 1 {
t.Fatalf("FetchFlags returned %d messages, want 1", len(got))
}
for _, st := range got {
if st.MessageID != "flagme@test" && st.MessageID != "<flagme@test>" {
t.Errorf("MessageID = %q, want the message's own id", st.MessageID)
}
}
}
// A session the server has closed must be re-dialed rather than failing
// forever, and the re-dial has to happen without the caller knowing.
func TestEnsureConnectedRedialsAfterDrop(t *testing.T) {
c := testServer(t, nil)
if err := c.Connect(); err != nil {
t.Fatalf("Connect: %v", err)
}
if _, err := c.Folders(); err != nil {
t.Fatalf("first Folders: %v", err)
}
// Drop it the way a server does: close the socket under the client.
_ = c.conn.Conn.Close()
waitForLogout(t, c)
if _, err := c.Folders(); err != nil {
t.Fatalf("Folders after the session was dropped: %v", err)
}
}
// A peer that goes away without a FIN must not park a command forever.
//
// go-imap puts a 30 second deadline on a response it has already started
// reading, but between responses it clears the deadline entirely, and that is
// where the reader waits for the first byte of the answer to the command we
// just sent. A NAT or firewall that drops the mapping leaves the socket open
// and silent, so before idleConn that wait never ended: the mailbox stopped
// syncing until the worker restarted, the same zombie a dropped session used
// to cause.
func TestIdleConnBoundsTheWaitBetweenResponses(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer func() { _ = ln.Close() }()
// Accept and then say nothing, holding the socket open.
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
select {}
}()
raw, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatalf("dial: %v", err)
}
conn := &idleConn{Conn: raw, timeout: 300 * time.Millisecond}
release := conn.arm()
defer release()
// Exactly what go-imap does around each response: a deadline while it
// decodes, cleared when it is done.
_ = conn.SetReadDeadline(time.Now().Add(30 * time.Second))
_ = conn.SetReadDeadline(time.Time{})
start := time.Now()
if _, err := conn.Read(make([]byte, 1)); err == nil {
t.Fatal("a read against a silent peer succeeded")
}
if waited := time.Since(start); waited > 5*time.Second {
t.Fatalf("the read waited %v; the cleared deadline left it unbounded", waited)
}
}
// Between commands the deadline is released, because go-imap's reader sits in
// Read the whole time a session is idle and would otherwise time out a
// perfectly healthy connection.
func TestIdleConnReleasesTheDeadlineBetweenCommands(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer func() { _ = ln.Close() }()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
// Quiet for longer than the idle timeout, then speak: a mailbox with
// no new mail looks exactly like this.
time.Sleep(400 * time.Millisecond)
_, _ = conn.Write([]byte("* OK still here\r\n"))
time.Sleep(time.Second)
}()
raw, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatalf("dial: %v", err)
}
conn := &idleConn{Conn: raw, timeout: 100 * time.Millisecond}
conn.arm()()
if _, err := conn.Read(make([]byte, 1)); err != nil {
t.Fatalf("an idle session was cut while no command was in flight: %v", err)
}
}
// A large literal is allowed the time go-imap gives it: the wrapper fills in
// a missing deadline, it never shortens one, so a slow body fetch that is
// making progress is not failed at the idle timeout.
func TestIdleConnDoesNotShortenAnExistingDeadline(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer func() { _ = ln.Close() }()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
time.Sleep(300 * time.Millisecond)
_, _ = conn.Write([]byte("x"))
time.Sleep(time.Second)
}()
raw, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatalf("dial: %v", err)
}
conn := &idleConn{Conn: raw, timeout: 50 * time.Millisecond}
release := conn.arm()
defer release()
// go-imap's literal read timeout, far longer than ours.
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := conn.Read(make([]byte, 1)); err != nil {
t.Fatalf("a read go-imap had given 5s was cut at the idle timeout: %v", err)
}
}
func appendMessage(t *testing.T, c *Client, mailbox, messageID string) {
t.Helper()
raw := "From: someone@test\r\nTo: warmbly@test\r\nSubject: hello\r\nMessage-ID: " + messageID +
"\r\nDate: Mon, 2 Jan 2006 15:04:05 -0700\r\n\r\nbody\r\n"
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
cmd := c.client.Append(mailbox, int64(len(raw)), nil)
if _, err := cmd.Write([]byte(raw)); err != nil {
t.Fatalf("append write: %v", err)
}
if err := cmd.Close(); err != nil {
t.Fatalf("append close: %v", err)
}
if _, err := cmd.Wait(); err != nil {
t.Fatalf("append: %v", err)
}
}
func waitForLogout(t *testing.T, c *Client) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
c.lifecycle.RLock()
state := c.client.State()
c.lifecycle.RUnlock()
if state == imap.ConnStateLogout {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("the client never noticed the dropped socket")
}
func contains(all []string, want string) bool {
for _, s := range all {
if strings.EqualFold(s, want) {
return true
}
}
return false
}
@@ -20,6 +20,7 @@ func (c *Client) MarkAsRead(ctx context.Context, mailboxName string, uid uint32)
}
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
defer c.begin()()
if _, err := c.selectMailbox(mailboxName, nil); err != nil {
return fmt.Errorf("select %q: %w", mailboxName, err)
}
@@ -46,6 +47,7 @@ func (c *Client) MarkImportant(ctx context.Context, mailboxName string, uid uint
}
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
defer c.begin()()
if _, err := c.selectMailbox(mailboxName, nil); err != nil {
return fmt.Errorf("select %q: %w", mailboxName, err)
}
@@ -82,6 +84,7 @@ func (c *Client) MoveToFolder(ctx context.Context, sourceMailbox, dstFolder stri
}
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
defer c.begin()()
dst := c.qualifyMailboxLocked(dstFolder)
if err := c.ensureMailboxExists(dst); err != nil {
return err
@@ -127,6 +130,7 @@ func (c *Client) moveUID(ctx context.Context, src, dst string, uid uint32) error
}
c.lifecycle.RLock()
defer c.lifecycle.RUnlock()
defer c.begin()()
return c.moveUIDLocked(src, dst, uid)
}
@@ -181,19 +185,12 @@ func (c *Client) ensureMailboxExists(name string) error {
return nil
}
// IsSpamMailboxName returns true if the mailbox name looks like Junk/Spam.
// Used as a guard so we never accidentally MOVE a non-spam message.
// IsSpamMailboxName returns true if the mailbox name is a Junk/Spam folder.
// Used as a guard so we never accidentally MOVE a non-spam message, so it
// matches the leaf exactly rather than by substring: a user folder called
// "Spam reports" holds mail its owner wants kept where it is.
func IsSpamMailboxName(name string) bool {
lower := strings.ToLower(strings.TrimSpace(name))
for _, candidate := range ImapSpam {
if strings.EqualFold(name, candidate) {
return true
}
if strings.Contains(lower, strings.ToLower(candidate)) {
return true
}
}
return false
return matchesFolderName(strings.ToLower(leaf(strings.TrimSpace(name))), ImapSpam)
}
// IsSpamMailbox returns true if the mailbox's attributes or name identify it
+22 -1
View File
@@ -1,5 +1,7 @@
package config
import "time"
const (
DefaultColor = "#c4c8cf"
// LimitMin/LimitMax bound every per-mailbox and per-campaign daily send
@@ -32,7 +34,11 @@ const (
// HTML newsletters mid-document; 512 KB clears the overwhelming majority
// of them while still bounding what one message can cost.
MaxEmailBodySize = 512 * 1024 // 512 KB
MaxEmailFolders = 30
// MaxEmailFolders bounds how many folders one mailbox's sync follows.
// INBOX and the special folders are always kept; past the cap the rest
// are taken in the server's order and the overflow is relayed once as a
// warning rather than failing the mailbox.
MaxEmailFolders = 100
// MaxSearchBodyText bounds the plain-text copy of a message body kept in
// Postgres for full-text search. The body itself lives in object storage;
@@ -43,6 +49,21 @@ const (
// memory, so a large folder is never buffered whole before any body is read.
ImapFetchBatchSize = 200
// ImapCommandIdleTimeout is how long an IMAP command may wait for the
// server to say anything before the session is declared dead and
// re-dialed on the next pass. go-imap bounds the bytes of a response but
// not the wait for its first byte, which is where a peer that vanished
// without a FIN parks a command forever.
ImapCommandIdleTimeout = 2 * time.Minute
// Servers without CONDSTORE (Outlook.com, Microsoft 365 over IMAP,
// Yahoo, many hosted servers) cannot say which messages changed, so read
// state and flags are mirrored by re-reading the flags of a folder's
// newest window every ImapFlagScanInterval and diffing against the
// previous scan. New mail still lands within one pass through UIDNEXT.
ImapFlagScanInterval = 10 * time.Minute
ImapFlagScanWindow = 5_000 // newest UIDs per folder the scan covers
// Mailbox sync fair use. Connecting a mailbox imports its recent history
// (the backfill), then follows new mail (live). Every number below is a
// default: the four Sync* settings are operator-editable in the admin
+9 -1
View File
@@ -120,7 +120,12 @@ func MError(eType MailErrorType, code MailErrorCode, message string, resolveMeth
}
var (
ErrMailFoldersMax = MError(MailErrorCritical, MailErrorCodeFolderLimit, fmt.Sprintf("You reached the maximum limit of %d folders reached.", config.MaxEmailFolders), MailErrorResolveMethodReload)
// ErrMailFoldersOverflow is relayed once per worker session when a
// mailbox has more folders than the sync follows. The inbox and the
// special folders are always among the ones kept.
ErrMailFoldersOverflow = func(left int) *MailError {
return MError(MailErrorWarning, MailErrorCodeFolderLimit, fmt.Sprintf("This mailbox has %d more folders than the %d Warmbly follows. The inbox, sent, drafts, spam and trash are always synced; the rest are taken in the server's order.", left, config.MaxEmailFolders), MailErrorResolveMethodNone)
}
ErrMailUpdateLimit = MError(MailErrorCritical, MailErrorCodeUpdateLimit, "Your inbox has received an unusually large number of updates. Please reactivate your inbox once the issue is resolved.", MailErrorResolveMethodReload)
ErrMailGoogleAuth = MError(MailErrorCritical, MailErrorCodeGoogleAuth, "Cannot access your Gmail account. Please re-authorize your account to restore mailbox access.", MailErrorResolveMethodReload)
ErrMailGooglePayment = MError(MailErrorCritical, MailErrorCodeGooglePayment, "Gmail access blocked due to unpaid invoices. Please resolve the payment with Google.", MailErrorResolveMethodReload)
@@ -235,6 +240,9 @@ func (e *MailError) GetUserErrorInfo() UserErrorInfo {
case MailErrorCodeServerUnreachable:
info.Title = "Connection Error"
info.ActionRequired = "The email server is temporarily unavailable. We'll retry automatically."
case MailErrorCodeFolderLimit:
info.Title = "Some folders are not synced"
info.ActionRequired = "Move or delete folders you no longer need if one you rely on is missing from the unibox."
case MailErrorCodeNotFound:
info.Title = "Mailbox Item Missing"
info.ActionRequired = "The folder or message is no longer on the mail server. Nothing to do; we'll skip it."
@@ -0,0 +1 @@
ALTER TABLE unibox_mailboxes DROP COLUMN IF EXISTS uid_next;
@@ -0,0 +1,3 @@
-- UIDNEXT per folder: the incremental cursor on IMAP servers without
-- CONDSTORE, where highestmodseq stays 0.
ALTER TABLE unibox_mailboxes ADD COLUMN IF NOT EXISTS uid_next bigint NOT NULL DEFAULT 0;
+7
View File
@@ -7,6 +7,13 @@ type Mailbox struct {
Attrs []string `json:"attributes"`
UIDValidity uint32 `json:"uid_validity"`
HighestModSeq uint64 `json:"highestmodseq"`
// UIDNext is the folder's next UID as last seen. It is the incremental
// cursor on a server without CONDSTORE, where HighestModSeq stays 0.
UIDNext uint32 `json:"uid_next"`
// Delim is the hierarchy delimiter this server reported for the folder
// ("/" on Gmail, "." on many Dovecots). Empty when the server reported
// none, where the leaf is guessed instead.
Delim string `json:"delim,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
+8 -7
View File
@@ -29,32 +29,33 @@ func (r *mailboxRepository) CreateEntry(ctx context.Context, userId, emailId uui
mb.UpdatedAt = time.Now()
query := `
INSERT INTO unibox_mailboxes (email_id, uid_validity, mailbox, attributes, highestmodseq, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO unibox_mailboxes (email_id, uid_validity, mailbox, attributes, highestmodseq, uid_next, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (email_id, uid_validity) DO UPDATE SET
mailbox = EXCLUDED.mailbox,
attributes = EXCLUDED.attributes,
highestmodseq = EXCLUDED.highestmodseq,
uid_next = EXCLUDED.uid_next,
updated_at = EXCLUDED.updated_at
`
// attributes is NOT NULL; a nil slice binds as SQL NULL. See textArray.
_, err := r.db.Exec(ctx, query,
emailId, mb.UIDValidity, mb.Name, textArray(mb.Attrs), mb.HighestModSeq, mb.UpdatedAt,
emailId, mb.UIDValidity, mb.Name, textArray(mb.Attrs), mb.HighestModSeq, mb.UIDNext, mb.UpdatedAt,
)
return err
}
func (r *mailboxRepository) GetMailbox(ctx context.Context, userId, emailId uuid.UUID, uidValidity uint32) (*models.Mailbox, error) {
query := `
SELECT mailbox, attributes, uid_validity, highestmodseq, updated_at
SELECT mailbox, attributes, uid_validity, highestmodseq, uid_next, updated_at
FROM unibox_mailboxes
WHERE email_id = $1 AND uid_validity = $2
`
var mb models.Mailbox
err := r.db.QueryRow(ctx, query, emailId, uidValidity).Scan(
&mb.Name, &mb.Attrs, &mb.UIDValidity, &mb.HighestModSeq, &mb.UpdatedAt,
&mb.Name, &mb.Attrs, &mb.UIDValidity, &mb.HighestModSeq, &mb.UIDNext, &mb.UpdatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
@@ -68,7 +69,7 @@ func (r *mailboxRepository) GetMailbox(ctx context.Context, userId, emailId uuid
func (r *mailboxRepository) ListMailboxes(ctx context.Context, userId, emailId uuid.UUID) ([]models.Mailbox, error) {
query := `
SELECT mailbox, attributes, uid_validity, highestmodseq, updated_at
SELECT mailbox, attributes, uid_validity, highestmodseq, uid_next, updated_at
FROM unibox_mailboxes
WHERE email_id = $1
`
@@ -82,7 +83,7 @@ func (r *mailboxRepository) ListMailboxes(ctx context.Context, userId, emailId u
var mailboxes []models.Mailbox
for rows.Next() {
var mb models.Mailbox
if err := rows.Scan(&mb.Name, &mb.Attrs, &mb.UIDValidity, &mb.HighestModSeq, &mb.UpdatedAt); err != nil {
if err := rows.Scan(&mb.Name, &mb.Attrs, &mb.UIDValidity, &mb.HighestModSeq, &mb.UIDNext, &mb.UpdatedAt); err != nil {
return nil, err
}
mailboxes = append(mailboxes, mb)