From f8fbca7cbb81d67294e64bfd4e5d939263500b77 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 21 Oct 2026 06:20:13 +0200 Subject: [PATCH] feat: adopt the linked Cloud workspace's own SMTP/IMAP mailbox on self-hosted enrollment when the server presents its exact stored credentials and no active Cloud campaign sends from it, instead of answering pool_link_mailbox_conflict, with tests and Warmbly Cloud and error code docs --- cmd/backend/main.go | 3 + docs/content/docs/api/error-codes.mdx | 2 +- docs/content/docs/guides/warmbly-cloud.mdx | 2 +- internal/app/poollink/adopt_hosted_test.go | 121 +++++++++++++++++++++ internal/app/poollink/adopt_warmup_test.go | 5 + internal/app/poollink/service.go | 83 +++++++++++++- 6 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 internal/app/poollink/adopt_hosted_test.go diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 518bc03e4..fae96d7e6 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -1312,6 +1312,9 @@ func main() { if w, ok := poolLinkService.(poollink.CacheWirer); ok { w.WireCache(cache) } + if w, ok := poolLinkService.(interface{ WireCampaigns(poollink.CampaignUsage) }); ok { + w.WireCampaigns(campaignRepostory) + } cloudLinkRepository := repository.NewCloudLinkRepository(primaryDB.Pool, credEncrypter) emailService.WireCloudLink(cloudLinkRepository) cloudLinkService = cloudlink.NewService(cloudLinkRepository, emailRepostory, emailService) diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx index b6d747d1b..a9f1e7928 100644 --- a/docs/content/docs/api/error-codes.mdx +++ b/docs/content/docs/api/error-codes.mdx @@ -283,7 +283,7 @@ The [inbox vendor](/guides/mailbox-import/#import-from-an-inbox-vendor) routes ( | `domain_redirect_limit` | 409 | Warmbly Cloud already serves 200 redirects for this linked instance | | `pool_link_redirect_not_found` | 404 | On Warmbly Cloud's linked-instance API: Cloud serves no redirect for this domain for the calling instance | | `pool_link_cleartext_mailbox` | 422 | Enrolling a mailbox in [Warmbly Cloud](/guides/warmbly-cloud/), or updating its credential there: SMTP or IMAP uses security `none`. Cloud reaches the mail server over the internet and needs TLS or STARTTLS on both legs. Switch the mailbox to TLS, or keep warming it on the instance | -| `pool_link_mailbox_conflict` | 409 | Enrollment found the address already connected in the linked Cloud workspace, but without an association for this local mailbox. Nothing is attached by address alone. Contact Warmbly support to check the exact instance and mailbox IDs; do not delete or re-enroll the mailbox | +| `pool_link_mailbox_conflict` | 409 | Enrollment found the address already connected in the linked Cloud workspace and could not attach it. Cloud attaches an existing copy only when it is an active SMTP/IMAP mailbox, the server presents exactly the credentials Cloud holds for it, no active Cloud campaign sends from it and no other server has enrolled it. Nothing is attached by address alone. Contact Warmbly support to check the exact instance and mailbox IDs; do not delete or re-enroll the mailbox | | `pool_link_instance_url` | 422 | Starting a Google or Microsoft sign-in through [Warmbly Cloud](/guides/warmbly-cloud/#google-and-microsoft-mailboxes-sign-in-through-warmbly-cloud) from an instance that registered no address of its own when it was linked, so Cloud has nowhere to return the sign-in to. Set `APP_URL` on the instance, disconnect it and link it again | | `pool_link_return_url` | 400 | The same sign-in named a return address that is not on the instance's registered address (same scheme and host) | | `pool_link_oauth_browser` | 400 | The brokered sign-in was not continued from the Warmbly Cloud page that started it, or returned to a different browser. Nothing was connected. Start again from the instance | diff --git a/docs/content/docs/guides/warmbly-cloud.mdx b/docs/content/docs/guides/warmbly-cloud.mdx index 8abe38474..e0d90f8f9 100644 --- a/docs/content/docs/guides/warmbly-cloud.mdx +++ b/docs/content/docs/guides/warmbly-cloud.mdx @@ -277,7 +277,7 @@ On your instance, **Settings > Warmbly Cloud > Disconnect** removes only the sel SMTP enrollment, mailbox removal and instance disconnect first record a durable local intent. While enrollment or removal is pending, the mailbox is not admitted to local warmup. The regular standing sync retries pending work after an outage or restart, using the same mailbox identity rather than creating another. Remote removal must be acknowledged, or confirmed already absent, before local enrollment is dropped. A failed local cleanup stays pending and is retried. -Each mailbox's pending work is retried independently. A failed enrollment or removal does not stop the other mailboxes' retries or Cloud status refreshes. The failed mailbox stays pending and unavailable, and the sync still reports the error. An enrollment conflict because the address already exists in the linked Cloud workspace answers `pool_link_mailbox_conflict`. Contact support with the connection ID and local mailbox IDs to check the association, without sending credentials or deleting or re-enrolling the mailbox. Cloud does not attach an existing mailbox by address alone. +Each mailbox's pending work is retried independently. A failed enrollment or removal does not stop the other mailboxes' retries or Cloud status refreshes. The failed mailbox stays pending and unavailable, and the sync still reports the error. When the address is already connected in the linked Cloud workspace, for example because you used hosted Warmbly before self-hosting, Cloud attaches that copy instead of creating a second one, as long as it is an active SMTP/IMAP mailbox, your server presents exactly the SMTP and IMAP host, port, username and password Cloud holds for it, no active Cloud campaign sends from it and no other server has enrolled it. From then on it is your server's mailbox: its warmup settings follow your server, and removing it from your server removes it from Cloud. Any other existing copy answers `pool_link_mailbox_conflict`. Contact support with the connection ID and local mailbox IDs to check the association, without sending credentials or deleting or re-enrolling the mailbox. Cloud never attaches an existing mailbox by address alone. Removal is an explicit opt-out. A late enrollment acknowledgment cannot reverse a pending removal. After removal finishes, a new explicit enrollment can create a fresh intent, but it does not reset reputation holds. Disconnect remains pending until remote revocation and local cleanup succeed, and no new enrollment is accepted during it. diff --git a/internal/app/poollink/adopt_hosted_test.go b/internal/app/poollink/adopt_hosted_test.go new file mode 100644 index 000000000..3d72b7ff8 --- /dev/null +++ b/internal/app/poollink/adopt_hosted_test.go @@ -0,0 +1,121 @@ +package poollink + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// hostedAccounts holds one SMTP/IMAP mailbox the Cloud workspace connected itself. +type hostedAccounts struct { + managedOpsEmails + hosted *models.Email + ref models.EmailRef + creds repository.SMTPCredentials +} + +func (r *hostedAccounts) NewSMTPIMAPAccount(context.Context, string, models.NewSMTPIMAPAccount) (*models.Email, *errx.Error) { + return nil, errx.ErrEmailOnboardAlreadyExists +} + +func (r *hostedAccounts) FindInOrganization(_ context.Context, orgID uuid.UUID, email string) (*models.EmailRef, *errx.Error) { + if r.hosted.OrganizationID == nil || *r.hosted.OrganizationID != orgID || email != r.hosted.Email { + return nil, nil + } + ref := r.ref + return &ref, nil +} + +func (r *hostedAccounts) GetSMTPCredentials(context.Context, uuid.UUID) (*repository.SMTPCredentials, *errx.Error) { + c := r.creds + return &c, nil +} + +type hostedLinkRepo struct { + adoptLinkRepo + boundElsewhere bool +} + +func (r *hostedLinkRepo) GetMailboxByAccount(_ context.Context, accountID uuid.UUID) (*models.PoolLinkMailbox, error) { + if r.boundElsewhere { + return &models.PoolLinkMailbox{InstanceID: uuid.New(), RemoteID: uuid.New(), EmailAccountID: accountID}, nil + } + return nil, nil +} + +type campaignUsage bool + +func (c campaignUsage) AccountHasActiveCampaign(context.Context, uuid.UUID) (bool, error) { + return bool(c), nil +} + +func TestEnrollAdoptsTheWorkspacesOwnMailboxOnlyWithItsExactCredentials(t *testing.T) { + t.Setenv("BILLING_PROVIDER", "none") + smtp := models.Service{Host: "smtp.example.test", Port: 465, Username: "sender@example.test", Password: "synthetic-a"} + imap := models.Service{Host: "imap.example.test", Port: 993, Username: "sender@example.test", Password: "synthetic-a"} + cases := []struct { + name string + mutate func(*models.SmtpImap, *models.EmailRef) + campaigns CampaignUsage + boundElsewhere bool + adopt bool + }{ + {name: "same credentials", campaigns: campaignUsage(false), adopt: true}, + {name: "host case differs", mutate: func(c *models.SmtpImap, _ *models.EmailRef) { c.IMAP.Host = "IMAP.Example.Test" }, campaigns: campaignUsage(false), adopt: true}, + {name: "different password", mutate: func(c *models.SmtpImap, _ *models.EmailRef) { c.SMTP.Password = "synthetic-b" }, campaigns: campaignUsage(false)}, + {name: "different imap user", mutate: func(c *models.SmtpImap, _ *models.EmailRef) { c.IMAP.Username = "other@example.test" }, campaigns: campaignUsage(false)}, + {name: "different port", mutate: func(c *models.SmtpImap, _ *models.EmailRef) { c.SMTP.Port = 587 }, campaigns: campaignUsage(false)}, + {name: "active campaign sends from it", campaigns: campaignUsage(true)}, + {name: "campaign usage unknown", campaigns: nil}, + {name: "already enrolled by another instance", campaigns: campaignUsage(false), boundElsewhere: true}, + {name: "inactive", mutate: func(_ *models.SmtpImap, r *models.EmailRef) { r.Status = "inactive" }, campaigns: campaignUsage(false)}, + {name: "cloud managed", mutate: func(_ *models.SmtpImap, r *models.EmailRef) { r.Managed = true }, campaigns: campaignUsage(false)}, + {name: "not smtp imap", mutate: func(_ *models.SmtpImap, r *models.EmailRef) { r.Provider = string(models.InboxProviderGoogle) }, campaigns: campaignUsage(false)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + org, owner := uuid.New(), uuid.New() + hosted := &models.Email{ID: uuid.New(), OrganizationID: &org, Email: "sender@example.test", Provider: "smtp_imap", Status: "active"} + accounts := &hostedAccounts{ + managedOpsEmails: managedOpsEmails{accounts: map[uuid.UUID]*models.Email{hosted.ID: hosted}}, + hosted: hosted, + ref: models.EmailRef{ID: hosted.ID, Provider: "smtp_imap", Status: "active", AuthMethod: "password"}, + creds: repository.SMTPCredentials{ + SMTPHost: smtp.Host, SMTPPort: smtp.Port, SMTPUser: smtp.Username, SMTPPassword: smtp.Password, + IMAPHost: imap.Host, IMAPPort: imap.Port, IMAPUser: imap.Username, IMAPPassword: imap.Password, + }, + } + presented := &models.SmtpImap{SMTP: new(models.Service), IMAP: new(models.Service)} + *presented.SMTP, *presented.IMAP = smtp, imap + if tc.mutate != nil { + tc.mutate(presented, &accounts.ref) + } + r := &hostedLinkRepo{boundElsewhere: tc.boundElsewhere} + emails := &adoptEmails{org: org} + s := &service{repo: r, emails: accounts, emailSvc: emails, campaigns: tc.campaigns} + inst := &models.PoolLinkInstance{ID: uuid.New(), OrganizationID: org, CreatedBy: &owner} + req := models.PoolLinkEnrollRequest{RemoteID: uuid.New(), Email: hosted.Email, Provider: models.InboxProviderSMTPIMAP, SMTPIMAP: presented} + + got, xerr := s.Enroll(context.Background(), inst, req) + if !tc.adopt { + if got != nil || xerr != ErrMailboxConflict || r.enrolled != nil || emails.started { + t.Fatalf("must refuse without attaching: %+v, %v", got, xerr) + } + return + } + if xerr != nil || got == nil { + t.Fatalf("adoption failed: %v", xerr) + } + if r.enrolled == nil || r.enrolled.EmailAccountID != hosted.ID || r.enrolled.InstanceID != inst.ID || r.enrolled.RemoteID != req.RemoteID || r.enrolled.Managed { + t.Fatalf("adopted enrollment = %+v", r.enrolled) + } + if !emails.started { + t.Fatal("adopted mailbox did not start warmup") + } + }) + } +} diff --git a/internal/app/poollink/adopt_warmup_test.go b/internal/app/poollink/adopt_warmup_test.go index 655d60f0a..f3e0c294a 100644 --- a/internal/app/poollink/adopt_warmup_test.go +++ b/internal/app/poollink/adopt_warmup_test.go @@ -91,6 +91,11 @@ func (r *conflictingEnrollmentAccounts) NewOauthAccount(context.Context, string, return nil, r.xerr } +// FindInOrganization finds no adoptable copy, so the conflict stands. +func (r *conflictingEnrollmentAccounts) FindInOrganization(context.Context, uuid.UUID, string) (*models.EmailRef, *errx.Error) { + return nil, nil +} + func TestEnrollmentExplainsAnExistingUnassociatedCloudMailbox(t *testing.T) { t.Setenv("BILLING_PROVIDER", "none") for _, provider := range []models.InboxProvider{models.InboxProviderSMTPIMAP, models.InboxProviderGoogle, models.InboxProviderOutlook} { diff --git a/internal/app/poollink/service.go b/internal/app/poollink/service.go index 352ccac34..d402e0abe 100644 --- a/internal/app/poollink/service.go +++ b/internal/app/poollink/service.go @@ -120,6 +120,12 @@ type service struct { scheduler WarmupScheduler planRepo repository.PlanRepository cache *cache.Cache + campaigns CampaignUsage +} + +// CampaignUsage says whether Cloud campaigns send from a mailbox. +type CampaignUsage interface { + AccountHasActiveCampaign(ctx context.Context, accountID uuid.UUID) (bool, error) } func NewService( @@ -138,6 +144,9 @@ func NewService( // WireScheduler attaches the warmup task seeder after construction. func (s *service) WireScheduler(w WarmupScheduler) { s.scheduler = w } +// WireCampaigns lets enrollment adopt only mailboxes no active campaign sends from. +func (s *service) WireCampaigns(c CampaignUsage) { s.campaigns = c } + // Unambiguous alphabet: no 0/O, 1/I/L. const userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" @@ -508,6 +517,9 @@ func (s *service) Enroll(ctx context.Context, inst *models.PoolLinkInstance, req } if xerr != nil { if errors.Is(xerr, errx.ErrEmailOnboardAlreadyExists) { + if req.Provider == models.InboxProviderSMTPIMAP { + return s.adoptHostedSMTPIMAP(ctx, inst, req, userID) + } return nil, ErrMailboxConflict } return nil, xerr @@ -517,16 +529,75 @@ func (s *service) Enroll(ctx context.Context, inst *models.PoolLinkInstance, req _ = s.emailSvc.Delete(ctx, orgID.String(), acc.ID.String()) return nil, errx.InternalError() } + return s.startEnrolled(ctx, inst, userID, acc.ID, req) +} - s.applyWarmupSettings(ctx, orgID, userID, acc.ID, req.Warmup) - if _, xerr := s.emailSvc.SetWarmupLifecycle(ctx, orgID.String(), acc.ID.String(), "start"); xerr != nil { - log.Warn().Str("account_id", acc.ID.String()).Str("code", xerr.Identifier).Str("error", xerr.Message).Msg("pool link: warmup start failed after enrollment") +// adoptHostedSMTPIMAP enrolls the workspace's own copy of the address, and only +// when the instance presents exactly the credentials Cloud holds for it and no +// active Cloud campaign sends from it, so an address alone never attaches. +func (s *service) adoptHostedSMTPIMAP(ctx context.Context, inst *models.PoolLinkInstance, req models.PoolLinkEnrollRequest, userID string) (*models.PoolLinkMailboxState, *errx.Error) { + ref, xerr := s.emails.FindInOrganization(ctx, inst.OrganizationID, req.Email) + if xerr != nil { + return nil, xerr } - if err := s.emailSvc.LoadAccountOntoWorker(ctx, acc.ID); err != nil { - log.Warn().Err(err).Str("account_id", acc.ID.String()).Msg("pool link: worker load failed; reconciler will retry") + if ref == nil || ref.Provider != string(models.InboxProviderSMTPIMAP) || ref.Status != "active" || + ref.AuthMethod == models.MailAuthDelegated || ref.Managed { + return nil, ErrMailboxConflict + } + if existing, err := s.repo.GetMailboxByAccount(ctx, ref.ID); err != nil { + return nil, errx.InternalError() + } else if existing != nil { + return nil, ErrMailboxConflict + } + stored, xerr := s.emails.GetSMTPCredentials(ctx, ref.ID) + if xerr == errx.ErrNotFound { + return nil, ErrMailboxConflict + } + if xerr != nil { + return nil, xerr + } + if !sameSMTPIMAPCredentials(stored, req.SMTPIMAP) { + return nil, ErrMailboxConflict + } + // An unenrolled mirror is deleted, so a mailbox Cloud campaigns send from never becomes one. + if s.campaigns == nil { + return nil, ErrMailboxConflict + } + if busy, err := s.campaigns.AccountHasActiveCampaign(ctx, ref.ID); err != nil { + return nil, errx.InternalError() + } else if busy { + return nil, ErrMailboxConflict + } + if err := s.repo.EnrollMailbox(ctx, &models.PoolLinkMailbox{InstanceID: inst.ID, RemoteID: req.RemoteID, EmailAccountID: ref.ID}); err != nil { + return nil, errx.InternalError() + } + log.Info().Str("instance_id", inst.ID.String()).Str("account_id", ref.ID.String()).Msg("pool link: adopted the workspace's existing mailbox on enrollment") + return s.startEnrolled(ctx, inst, userID, ref.ID, req) +} + +func sameSMTPIMAPCredentials(stored *repository.SMTPCredentials, got *models.SmtpImap) bool { + if stored == nil || got == nil || got.SMTP == nil || got.IMAP == nil { + return false + } + same := func(host string, port int, user, password string, svc *models.Service) bool { + return strings.EqualFold(strings.TrimSpace(host), strings.TrimSpace(svc.Host)) && port == svc.Port && + strings.TrimSpace(user) == strings.TrimSpace(svc.Username) && password == svc.Password + } + return same(stored.SMTPHost, stored.SMTPPort, stored.SMTPUser, stored.SMTPPassword, got.SMTP) && + same(stored.IMAPHost, stored.IMAPPort, stored.IMAPUser, stored.IMAPPassword, got.IMAP) +} + +func (s *service) startEnrolled(ctx context.Context, inst *models.PoolLinkInstance, userID string, accountID uuid.UUID, req models.PoolLinkEnrollRequest) (*models.PoolLinkMailboxState, *errx.Error) { + orgID := inst.OrganizationID + s.applyWarmupSettings(ctx, orgID, userID, accountID, req.Warmup) + if _, xerr := s.emailSvc.SetWarmupLifecycle(ctx, orgID.String(), accountID.String(), "start"); xerr != nil { + log.Warn().Str("account_id", accountID.String()).Str("code", xerr.Identifier).Str("error", xerr.Message).Msg("pool link: warmup start failed after enrollment") + } + if err := s.emailSvc.LoadAccountOntoWorker(ctx, accountID); err != nil { + log.Warn().Err(err).Str("account_id", accountID.String()).Msg("pool link: worker load failed; reconciler will retry") } if s.scheduler != nil { - _ = s.scheduler.EnsureWarmupScheduled(ctx, acc.ID) + _ = s.scheduler.EnsureWarmupScheduled(ctx, accountID) } return s.GetMailbox(ctx, inst, req.RemoteID) }