mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-28 16:02:33 +00:00
525 lines
25 KiB
Go
525 lines
25 KiB
Go
package email
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/warmbly/warmbly/internal/app/instancesettings"
|
|
"github.com/warmbly/warmbly/internal/client/smtpimap/imap"
|
|
"golang.org/x/oauth2"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/app/cipher"
|
|
"github.com/warmbly/warmbly/internal/app/feature"
|
|
warmupapp "github.com/warmbly/warmbly/internal/app/warmup"
|
|
"github.com/warmbly/warmbly/internal/app/webhook"
|
|
"github.com/warmbly/warmbly/internal/app/worker"
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/events"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/cache"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/dnsauth"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
type EmailService interface {
|
|
Search(ctx context.Context, userID, search, cursor, tag, limit string, allowedAccountIDs []uuid.UUID) (*models.EmailsResult, *errx.Error)
|
|
Get(ctx context.Context, orgID, emailAccountID string) (*models.Email, *errx.Error)
|
|
// Update writes a mailbox's settings. orgID scopes the write (the mailbox
|
|
// is a workspace asset); userID only names who to tell the worker about.
|
|
Update(ctx context.Context, orgID, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error)
|
|
// BulkUpdateTags adds/removes tags across many of the workspace's
|
|
// mailboxes in one call; returns how many of the requested mailboxes the
|
|
// workspace owns.
|
|
BulkUpdateTags(ctx context.Context, orgID string, emailIDs, addTags, removeTags []uuid.UUID) (int, *errx.Error)
|
|
// SetWarmupLifecycle starts, pauses, resumes, or disables warmup for a
|
|
// mailbox. start/resume preserve ramp progress; disable turns warmup off.
|
|
SetWarmupLifecycle(ctx context.Context, orgID, emailAccountID, action string) (*models.Email, *errx.Error)
|
|
// SetSendHold holds a mailbox in reserve or releases it; a release lands
|
|
// wherever its warmup health says, so an unhealthy mailbox rests.
|
|
SetSendHold(ctx context.Context, orgID, emailAccountID string, hold bool) (*models.SendLifecycleState, *errx.Error)
|
|
// UpdateTrackingDomain sets or clears the custom open/click tracking
|
|
// domain and resolves it once, persisting the verdict.
|
|
UpdateTrackingDomain(ctx context.Context, orgID, emailAccountID, domain string) (*models.TrackingDomainStatus, *errx.Error)
|
|
UpdateTrackDirectMail(ctx context.Context, orgID, emailAccountID string, enabled bool) *errx.Error
|
|
// GetTrackingDomain reports the stored state plus the CNAME target this
|
|
// install expects. Read-only: it does no DNS work.
|
|
GetTrackingDomain(ctx context.Context, orgID, emailAccountID string) (*models.TrackingDomainStatus, *errx.Error)
|
|
// VerifyTrackingDomain re-resolves the stored domain and PERSISTS the
|
|
// verdict, which is what lets a fixed record start routing links. Same
|
|
// read/write split as CheckDomainAuth and RefreshDomainAuth.
|
|
VerifyTrackingDomain(ctx context.Context, orgID, emailAccountID string) (*models.TrackingDomainStatus, *errx.Error)
|
|
// StartTrackingDomainSweep re-resolves every custom tracking domain on a
|
|
// schedule, so a record that propagates starts being used without anybody
|
|
// pressing anything, and one that disappears stops being used at all.
|
|
StartTrackingDomainSweep(ctx context.Context, interval, staleAfter time.Duration)
|
|
// CheckDomainAuth runs a live SPF/DKIM/DMARC lookup for a mailbox's
|
|
// sending domain and returns it without touching stored state.
|
|
CheckDomainAuth(ctx context.Context, orgID, emailAccountID string) (*dnsauth.Result, *errx.Error)
|
|
// RefreshDomainAuth does the same and PERSISTS the verdict. That write can
|
|
// lift the cold-send and warmup gate, so it sits behind the write
|
|
// permission while CheckDomainAuth stays readable.
|
|
RefreshDomainAuth(ctx context.Context, orgID, emailAccountID string) (*dnsauth.Result, *errx.Error)
|
|
Delete(ctx context.Context, orgID, emailAccountID string) *errx.Error
|
|
|
|
// GetSendIdentity reports which addresses the mailbox's provider will let
|
|
// it send as, which one is in use, and where the stored signature came
|
|
// from. Read-only: it never calls the provider.
|
|
GetSendIdentity(ctx context.Context, orgID, emailAccountID string) (*models.SendIdentity, *errx.Error)
|
|
// RefreshSendIdentity re-reads that list from the provider and stores it,
|
|
// importing the provider's signature too when asked. Gmail only; every
|
|
// other provider is refused with mailbox_send_as_unsupported.
|
|
RefreshSendIdentity(ctx context.Context, orgID, emailAccountID string, importSignature bool) (*models.SendIdentity, *errx.Error)
|
|
|
|
// Onboarding flow. OAuthFinish's second return is true when the round
|
|
// trip renewed an existing mailbox (OAuthReauth) rather than connecting
|
|
// a new one, so the handler can audit and answer accordingly.
|
|
// loginHint preselects an address in the provider's picker; "" for none.
|
|
OAuthStart(ctx context.Context, userID string, orgID *uuid.UUID, provider models.InboxProvider, loginHint string, web bool, returnOrigin string) (*models.EmailOnboardingStartResponse, *errx.Error)
|
|
OAuthReturnOrigin(ctx context.Context, state string) string
|
|
// authorize runs before the code is exchanged, against the organization the
|
|
// state names, so a caller removed mid-flow cannot finish it.
|
|
OAuthFinish(ctx context.Context, userID, code, state string, authorize FinishAuthorizer) (*models.Email, bool, *errx.Error)
|
|
OnboardSMTPIMAP(ctx context.Context, userID string, orgID *uuid.UUID, data *models.NewSMTPIMAPAccount) (*models.Email, *errx.Error)
|
|
// OnboardSMTPIMAPBulk connects many SMTP/IMAP mailboxes in one call and
|
|
// answers per row, so one bad password never fails the file. Rows past the
|
|
// workspace's allowance are refused before any credential is dialled.
|
|
OnboardSMTPIMAPBulk(ctx context.Context, userID string, orgID *uuid.UUID, rows []models.NewSMTPIMAPAccount) *models.MailboxBulkResult
|
|
// OAuthReauth starts an OAuth round trip that renews the tokens of an
|
|
// existing Gmail/Outlook mailbox after the provider invalidated them.
|
|
OAuthReauth(ctx context.Context, userID string, orgID *uuid.UUID, accountID uuid.UUID, returnOrigin string) (*models.EmailOnboardingStartResponse, *errx.Error)
|
|
// UpdateSMTPIMAPCredentials validates replacement credentials against a
|
|
// live worker, stores them, and puts the mailbox back to work.
|
|
UpdateSMTPIMAPCredentials(ctx context.Context, orgID *uuid.UUID, accountID uuid.UUID, creds *models.SmtpImap) (*models.Email, *errx.Error)
|
|
|
|
// Optional: wire in the webhook dispatcher after construction. Once
|
|
// set, account-lifecycle events fan out to customer webhook endpoints.
|
|
WireWebhooks(w webhook.Service)
|
|
// WireMailboxAllowance attaches the allowance resolver every connect path
|
|
// checks. Without it the feature gate's free-or-paid split stands in.
|
|
WireMailboxAllowance(src MailboxAllowanceSource)
|
|
// WireGraphDelta attaches the Graph delta-cursor repository so the worker
|
|
// reconciler can seed a mailbox's saved cursors when loading it.
|
|
WireGraphDelta(repo repository.EmailGraphDeltaRepository)
|
|
// WireEmailHistoryID attaches the Gmail history-cursor repository, the
|
|
// Google counterpart of WireGraphDelta, so a reloaded mailbox resumes from
|
|
// its saved checkpoint instead of re-bootstrapping.
|
|
WireEmailHistoryID(repo repository.EmailHistoryIDRepository)
|
|
// WireSyncState, WireMailboxes and WireSyncBudget feed the sync fair-use
|
|
// payload: resumable backfill state, saved IMAP folder cursors, and the
|
|
// operator-editable budget the mailbox syncs under.
|
|
WireSyncState(repo repository.EmailSyncStateRepository)
|
|
WireMailboxes(repo repository.MailboxRepository)
|
|
WireUnibox(repo repository.UniboxRepository)
|
|
WireSyncBudget(src SyncBudgetSource)
|
|
// WireSeedScope lets the loader give a placement seed mailbox the sync
|
|
// allowance a whole instance's test traffic needs.
|
|
WireSeedScope(src SeedScopeSource)
|
|
WirePoolLink(repo repository.PoolLinkRepository)
|
|
// WireCloudLink marks managed mailboxes, which ship to the worker without a credential.
|
|
WireCloudLink(repo repository.CloudLinkRepository)
|
|
// WireCloudUnenroll attaches cloud credential revocation to mailbox deletion.
|
|
WireCloudUnenroll(u CloudUnenroller)
|
|
// WireCloudCredentials hands Warmbly Cloud a mailbox's credential after it changes here.
|
|
WireCloudCredentials(r CloudCredentialRefresher)
|
|
// ConnectDelegated stores a Gmail or Outlook mailbox reached through an
|
|
// administrator's grant and loads it; tokens are minted per use.
|
|
ConnectDelegated(ctx context.Context, userID string, orgID *uuid.UUID, data models.NewDelegatedAccount) (*models.Email, *errx.Error)
|
|
// SwitchToAppPassword moves a per-mailbox Google sign-in onto an app password in place.
|
|
SwitchToAppPassword(ctx context.Context, orgID *uuid.UUID, accountID uuid.UUID, appPassword string) (*models.Email, *errx.Error)
|
|
// ReactivateDelegated puts a delegated mailbox back to work after its grant recovered.
|
|
ReactivateDelegated(ctx context.Context, accountID uuid.UUID) (*models.Email, *errx.Error)
|
|
// WireImportSignin lets an OAuth connect close the import rows that were
|
|
// waiting for someone to sign in as that mailbox.
|
|
WireImportSignin(r ImportSigninResolver)
|
|
// WireAvatars lets a connect keep the profile photo its provider returns.
|
|
WireAvatars(a AvatarSaver)
|
|
// WireAccountErrors lets a successful reconnect resolve the credential
|
|
// errors it just fixed, which is what clears the mailbox's error banner.
|
|
WireAccountErrors(repo repository.EmailAccountErrorRepository)
|
|
// Brokered OAuth (cloud side): consent on this deployment's OAuth app for a linked instance.
|
|
// OAuthAuthorizeURL carries the S256 challenge of verifier; OAuthConnectWithCode redeems with it.
|
|
OAuthAuthorizeURL(provider models.InboxProvider, state, verifier string) (string, *errx.Error)
|
|
OAuthCallbackOrigin(provider models.InboxProvider) (string, *errx.Error)
|
|
OAuthConnectWithCode(ctx context.Context, userID string, orgID *uuid.UUID, provider models.InboxProvider, code, verifier string) (*models.Email, *errx.Error)
|
|
OAuthConnectWithCodeForAccount(ctx context.Context, userID string, orgID *uuid.UUID, provider models.InboxProvider, code, verifier string, accountID uuid.UUID) (*models.Email, *errx.Error)
|
|
// OAuthAccessToken is a live access token for an OAuth mailbox, refreshed when near expiry.
|
|
OAuthAccessToken(ctx context.Context, accountID uuid.UUID) (*oauth2.Token, *errx.Error)
|
|
// LoadAccountOntoWorker assigns a worker if needed and ships the mailbox
|
|
// to it (idempotent; the reconciler calls it too).
|
|
LoadAccountOntoWorker(ctx context.Context, accountID uuid.UUID) error
|
|
// SyncWarmupPool re-evaluates one mailbox's local warmup pool membership,
|
|
// for a change outside the mailbox row (Warmbly Cloud enrollment).
|
|
SyncWarmupPool(ctx context.Context, accountID uuid.UUID)
|
|
// GetSyncState is the dashboard's view of a mailbox's sync: nil state when
|
|
// the worker has not reported yet, the policy in force, and the folders
|
|
// the sync has seen on the server so a client can name one to skip
|
|
// (empty for providers without folders).
|
|
GetSyncState(ctx context.Context, userID, emailID string) (*models.SyncState, models.SyncPolicy, []models.SyncFolder, *errx.Error)
|
|
// UpdateSyncSettings replaces the mailbox's skip list, drops the mail
|
|
// already stored from those folders, and re-ships the mailbox so the
|
|
// worker applies it on its next pass. Returns the list as stored.
|
|
UpdateSyncSettings(ctx context.Context, orgID, emailID string, body *models.UpdateSyncSettings) ([]string, *errx.Error)
|
|
// StartWorkerReconciler periodically ensures every active mailbox is
|
|
// assigned to a worker and loaded onto it (blocks until ctx is cancelled).
|
|
StartWorkerReconciler(ctx context.Context, interval time.Duration)
|
|
// ReloadWorkerAccounts re-ships every active mailbox assigned to one
|
|
// worker, for a worker that just booted and holds none in memory.
|
|
ReloadWorkerAccounts(ctx context.Context, workerID uuid.UUID)
|
|
}
|
|
|
|
type emailService struct {
|
|
emailRepository repository.EmailRepository
|
|
cipherService cipher.CipherService
|
|
featureGate feature.FeatureGateService
|
|
warmupService warmupapp.Service
|
|
publisher events.Publisher
|
|
streamingPublisher *pubsub.StreamingPublisher
|
|
r *cache.Cache
|
|
oauthInbox *config.Oauth2Inbox
|
|
workerAssignment worker.WorkerAssignmentService
|
|
allowance MailboxAllowanceSource
|
|
graphDelta repository.EmailGraphDeltaRepository
|
|
historyID repository.EmailHistoryIDRepository
|
|
syncState repository.EmailSyncStateRepository
|
|
mailboxes repository.MailboxRepository
|
|
syncBudget SyncBudgetSource
|
|
seedScope SeedScopeSource
|
|
// poolLink marks linked warmup-only mailboxes, which sync with no history.
|
|
poolLink repository.PoolLinkRepository
|
|
// cloudLink marks managed mailboxes whose credential the cloud holds.
|
|
cloudLink repository.CloudLinkRepository
|
|
// cloudUnenroll revokes a Warmbly Cloud enrollment on delete.
|
|
cloudUnenroll CloudUnenroller
|
|
// cloudCredentials re-sends a changed credential to Warmbly Cloud.
|
|
cloudCredentials CloudCredentialRefresher
|
|
// webhookService is optional. When non-nil, account lifecycle events
|
|
// (email_account.connected, email_account.removed) are dispatched to
|
|
// subscribed customer webhooks.
|
|
webhookService webhook.Service
|
|
// orgRiskRepo bars a restricted organization from the paid warmup pool.
|
|
// Optional/nil-safe.
|
|
orgRiskRepo repository.OrgRiskRepository
|
|
// lifecycleRepo backs the owner's hold; without it SetSendHold refuses.
|
|
lifecycleRepo repository.SendLifecycleRepository
|
|
// accountErrors is resolved-on-reconnect error state. Optional/nil-safe.
|
|
accountErrors repository.EmailAccountErrorRepository
|
|
// unibox is where a skipped folder's already-stored mail is dropped from.
|
|
// Optional: without it the worker's retirement of the folder does it.
|
|
unibox repository.UniboxRepository
|
|
// importSignin closes import rows waiting on a sign-in. Optional.
|
|
importSignin ImportSigninResolver
|
|
// avatars keeps the photo a connect handshake returns. Optional.
|
|
avatars AvatarSaver
|
|
}
|
|
|
|
// WireUnibox attaches the unified inbox store, for the purge that follows a
|
|
// folder being excluded from sync.
|
|
func (s *emailService) WireUnibox(repo repository.UniboxRepository) {
|
|
s.unibox = repo
|
|
}
|
|
|
|
// WireAccountErrors attaches the mailbox error log so reconnects can resolve it.
|
|
func (s *emailService) WireAccountErrors(repo repository.EmailAccountErrorRepository) {
|
|
s.accountErrors = repo
|
|
}
|
|
|
|
// WireLifecycle attaches the cold-sending lifecycle.
|
|
func (s *emailService) WireLifecycle(r repository.SendLifecycleRepository) {
|
|
s.lifecycleRepo = r
|
|
}
|
|
|
|
// LifecycleAware is the optional capability the caller uses to attach it.
|
|
type LifecycleAware interface {
|
|
WireLifecycle(r repository.SendLifecycleRepository)
|
|
}
|
|
|
|
// WireOrgRisk attaches the organization risk posture.
|
|
func (s *emailService) WireOrgRisk(r repository.OrgRiskRepository) {
|
|
s.orgRiskRepo = r
|
|
}
|
|
|
|
// OrgRiskAware is the optional capability the caller uses to attach org risk.
|
|
type OrgRiskAware interface {
|
|
WireOrgRisk(r repository.OrgRiskRepository)
|
|
}
|
|
|
|
// main.go attaches the posture by type assertion, which fails silently.
|
|
var _ OrgRiskAware = (*emailService)(nil)
|
|
|
|
// SyncBudgetSource is the operator-editable sync fair-use section, satisfied
|
|
// by instancesettings.Service. Injected post-construction; when unset the
|
|
// loader ships compiled defaults.
|
|
type SyncBudgetSource interface {
|
|
SyncBudget(ctx context.Context) instancesettings.Sync
|
|
}
|
|
|
|
// WireSyncState attaches the mailbox sync-state repository so a (re)loaded
|
|
// mailbox resumes its backfill and the API can report progress.
|
|
func (s *emailService) WireSyncState(repo repository.EmailSyncStateRepository) {
|
|
s.syncState = repo
|
|
}
|
|
|
|
// WireMailboxes attaches the IMAP folder-state repository so a reloaded IMAP
|
|
// mailbox resumes incrementally from its saved HIGHESTMODSEQ per folder
|
|
// instead of re-walking every folder from scratch.
|
|
func (s *emailService) WireMailboxes(repo repository.MailboxRepository) {
|
|
s.mailboxes = repo
|
|
}
|
|
|
|
// WireSyncBudget attaches the instance settings the sync policy is read from.
|
|
func (s *emailService) WireSyncBudget(src SyncBudgetSource) {
|
|
s.syncBudget = src
|
|
}
|
|
|
|
// SeedScopeSource reports whether a mailbox is a placement seed.
|
|
type SeedScopeSource interface {
|
|
SeedScope(ctx context.Context, accountID uuid.UUID) (string, error)
|
|
}
|
|
|
|
// WireSeedScope attaches the seed lookup the loader reads.
|
|
func (s *emailService) WireSeedScope(src SeedScopeSource) {
|
|
s.seedScope = src
|
|
}
|
|
|
|
// WirePoolLink attaches the pool-link repository so linked mailboxes get the
|
|
// warmup-only sync policy.
|
|
func (s *emailService) WireCloudLink(repo repository.CloudLinkRepository) {
|
|
s.cloudLink = repo
|
|
}
|
|
|
|
// CloudUnenroller confirms remote revocation before a mailbox is deleted locally.
|
|
type CloudUnenroller interface {
|
|
RevokeForDelete(ctx context.Context, orgID, accountID uuid.UUID) *errx.Error
|
|
}
|
|
|
|
// WireCloudUnenroll attaches remote revocation after service construction.
|
|
func (s *emailService) WireCloudUnenroll(u CloudUnenroller) {
|
|
s.cloudUnenroll = u
|
|
}
|
|
|
|
// CloudCredentialRefresher re-sends an enrolled mailbox to Warmbly Cloud, which holds its own copy of the credential.
|
|
type CloudCredentialRefresher interface {
|
|
RefreshCredentials(ctx context.Context, orgID, accountID uuid.UUID) *errx.Error
|
|
}
|
|
|
|
func (s *emailService) WireCloudCredentials(r CloudCredentialRefresher) {
|
|
s.cloudCredentials = r
|
|
}
|
|
|
|
func (s *emailService) WirePoolLink(repo repository.PoolLinkRepository) {
|
|
s.poolLink = repo
|
|
}
|
|
|
|
// MailboxAllowanceSource answers how many mailboxes a workspace may hold.
|
|
// Satisfied by the organization service; injected post-construction so this
|
|
// package needs no import of it.
|
|
type MailboxAllowanceSource interface {
|
|
MailboxAllowance(ctx context.Context, orgID uuid.UUID) (*models.MailboxAllowance, *errx.Error)
|
|
}
|
|
|
|
func (s *emailService) WireMailboxAllowance(src MailboxAllowanceSource) {
|
|
s.allowance = src
|
|
}
|
|
|
|
// WireWebhooks attaches the webhook dispatcher after construction. Done
|
|
// post-construction so callers without a webhook stack (tests, jobs) need
|
|
// not provide one.
|
|
func (s *emailService) WireWebhooks(w webhook.Service) {
|
|
s.webhookService = w
|
|
}
|
|
|
|
func NewService(
|
|
emailRepository repository.EmailRepository,
|
|
cipherService cipher.CipherService,
|
|
featureGate feature.FeatureGateService,
|
|
warmupService warmupapp.Service,
|
|
publisher events.Publisher,
|
|
streamingPublisher ...*pubsub.StreamingPublisher,
|
|
) EmailService {
|
|
var realtime *pubsub.StreamingPublisher
|
|
if len(streamingPublisher) > 0 {
|
|
realtime = streamingPublisher[0]
|
|
}
|
|
|
|
return &emailService{
|
|
emailRepository: emailRepository,
|
|
cipherService: cipherService,
|
|
featureGate: featureGate,
|
|
warmupService: warmupService,
|
|
publisher: publisher,
|
|
streamingPublisher: realtime,
|
|
}
|
|
}
|
|
|
|
// NewServiceWithWorker builds the email service with the deps needed for
|
|
// worker-facing flows (credential validation over the event bus, mailbox OAuth,
|
|
// worker assignment). Publishing goes through the events.Publisher, so this is
|
|
// transport-agnostic (Kafka or NATS).
|
|
func NewServiceWithWorker(
|
|
emailRepository repository.EmailRepository,
|
|
cipherService cipher.CipherService,
|
|
featureGate feature.FeatureGateService,
|
|
warmupService warmupapp.Service,
|
|
publisher events.Publisher,
|
|
r *cache.Cache,
|
|
oauthInbox *config.Oauth2Inbox,
|
|
workerAssignment worker.WorkerAssignmentService,
|
|
streamingPublisher ...*pubsub.StreamingPublisher,
|
|
) EmailService {
|
|
var realtime *pubsub.StreamingPublisher
|
|
if len(streamingPublisher) > 0 {
|
|
realtime = streamingPublisher[0]
|
|
}
|
|
|
|
return &emailService{
|
|
emailRepository: emailRepository,
|
|
cipherService: cipherService,
|
|
featureGate: featureGate,
|
|
warmupService: warmupService,
|
|
publisher: publisher,
|
|
streamingPublisher: realtime,
|
|
r: r,
|
|
oauthInbox: oauthInbox,
|
|
workerAssignment: workerAssignment,
|
|
}
|
|
}
|
|
|
|
func (s *emailService) publishAccountEvent(ctx context.Context, eventType pubsub.EventType, account *models.Email) {
|
|
if s.streamingPublisher == nil || account == nil {
|
|
return
|
|
}
|
|
|
|
var orgID string
|
|
if account.OrganizationID != nil {
|
|
orgID = account.OrganizationID.String()
|
|
}
|
|
s.streamingPublisher.PublishAccountEvent(ctx, &pubsub.AccountEvent{
|
|
BaseEvent: pubsub.BaseEvent{
|
|
EventType: eventType,
|
|
UserID: account.UserID,
|
|
},
|
|
OrgID: orgID,
|
|
EmailAccountID: account.ID.String(),
|
|
Email: account.Email,
|
|
Provider: account.Provider,
|
|
Status: account.Status,
|
|
})
|
|
}
|
|
|
|
// GetSyncState returns the persisted sync state and the policy currently in
|
|
// force. It goes through Get so ownership is checked the same way as every
|
|
// other per-mailbox read.
|
|
func (s *emailService) GetSyncState(ctx context.Context, orgID, emailID string) (*models.SyncState, models.SyncPolicy, []models.SyncFolder, *errx.Error) {
|
|
acc, xerr := s.Get(ctx, orgID, emailID)
|
|
if xerr != nil {
|
|
return nil, models.SyncPolicy{}, nil, xerr
|
|
}
|
|
data, err := s.syncDataFor(ctx, acc.ID)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("email_id", acc.ID.String()).Msg("sync state: policy lookup failed")
|
|
return nil, models.SyncPolicy{}, nil, errx.InternalError()
|
|
}
|
|
return data.State, data.Policy, s.syncFoldersFor(ctx, acc), nil
|
|
}
|
|
|
|
// syncFoldersFor lists the IMAP folders the worker has reported for this
|
|
// mailbox, INBOX first and then by name, each with the canonical folder it
|
|
// files under. Gmail and Outlook mailboxes have no folder list here.
|
|
func (s *emailService) syncFoldersFor(ctx context.Context, acc *models.Email) []models.SyncFolder {
|
|
out := []models.SyncFolder{}
|
|
for _, box := range s.imapFoldersFor(ctx, acc) {
|
|
out = append(out, models.SyncFolder{Name: box.Name, Folder: imap.CanonicalFolder(box)})
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
li, lj := strings.EqualFold(out[i].Name, "INBOX"), strings.EqualFold(out[j].Name, "INBOX")
|
|
if li != lj {
|
|
return li
|
|
}
|
|
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
|
})
|
|
return out
|
|
}
|
|
|
|
// imapFoldersFor is the saved folder listing of an IMAP mailbox, and nothing
|
|
// for any other provider.
|
|
func (s *emailService) imapFoldersFor(ctx context.Context, acc *models.Email) []models.Mailbox {
|
|
userID, err := uuid.Parse(acc.UserID)
|
|
if acc.Provider != string(models.InboxProviderSMTPIMAP) || err != nil {
|
|
return nil
|
|
}
|
|
return s.mailboxesFor(ctx, userID, acc.ID)
|
|
}
|
|
|
|
// UpdateSyncSettings stores a normalized skip list for an IMAP mailbox and
|
|
// drops the mail already stored from every saved folder the list covers,
|
|
// decided by the same matcher the worker applies (case, subfolders), plus
|
|
// the names themselves for a folder not listed yet. The re-ship makes the
|
|
// worker's next pass apply the list within a minute rather than at the
|
|
// reconciler's next republish; a folder it retires then is purged again by
|
|
// name, which is idempotent.
|
|
func (s *emailService) UpdateSyncSettings(ctx context.Context, orgID, emailID string, body *models.UpdateSyncSettings) ([]string, *errx.Error) {
|
|
acc, xerr := s.Get(ctx, orgID, emailID)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
if body == nil {
|
|
return nil, errx.ErrInvalid
|
|
}
|
|
if acc.Provider != string(models.InboxProviderSMTPIMAP) {
|
|
return nil, errx.NewWithIdentifier(errx.BadRequest, "invalid_sync_folder", "folders can only be skipped on an IMAP mailbox")
|
|
}
|
|
folders, xerr := imap.NormalizeSkipFolders(body.SkipFolders)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
// The purge reaches exactly what the worker will stop following: every
|
|
// saved folder the matcher skips, plus a name no saved folder answers to
|
|
// (a folder not listed yet). A name that IS a saved folder the matcher
|
|
// refuses, by attribute, is refused here too rather than purged.
|
|
saved := s.imapFoldersFor(ctx, acc)
|
|
var purge []string
|
|
for _, name := range folders {
|
|
listed := false
|
|
for _, box := range saved {
|
|
if strings.EqualFold(box.Name, name) {
|
|
listed = true
|
|
if !imap.SkipsFolder(box, folders) {
|
|
return nil, errx.NewWithIdentifier(errx.BadRequest, "invalid_sync_folder", fmt.Sprintf("folder %q is a folder the sync always follows", box.Name))
|
|
}
|
|
}
|
|
}
|
|
if !listed {
|
|
purge = append(purge, name)
|
|
}
|
|
}
|
|
for _, box := range saved {
|
|
if imap.SkipsFolder(box, folders) && !slices.Contains(purge, box.Name) {
|
|
purge = append(purge, box.Name)
|
|
}
|
|
}
|
|
if xerr := s.emailRepository.SetSyncSkipFolders(ctx, orgID, emailID, folders); xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
if s.unibox != nil && len(purge) > 0 {
|
|
if n, err := s.unibox.DeleteByFolderPaths(ctx, acc.ID, purge); err != nil {
|
|
log.Warn().Err(err).Str("email_id", acc.ID.String()).Msg("sync skip folders: purge of stored mail failed; the worker retires the folders on its next pass")
|
|
} else if n > 0 {
|
|
log.Info().Str("email_id", acc.ID.String()).Int64("messages", n).Msg("sync skip folders: stored mail from skipped folders dropped")
|
|
}
|
|
}
|
|
s.loadAccountBestEffort(ctx, acc.ID)
|
|
return folders, nil
|
|
}
|
|
|
|
// FinishAuthorizer refuses an OAuth finish when the caller no longer holds the
|
|
// rights to connect (or, with reauth, to renew) a mailbox in orgID.
|
|
type FinishAuthorizer func(ctx context.Context, orgID uuid.UUID, reauth bool) *errx.Error
|