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

This commit is contained in:
Matthew Meszaros
2026-10-21 06:20:14 +02:00
parent 005d8d12b4
commit f8fbca7cbb
6 changed files with 208 additions and 8 deletions
+3
View File
@@ -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)
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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.
+121
View File
@@ -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")
}
})
}
}
@@ -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} {
+77 -6
View File
@@ -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)
}