package email import ( "context" "fmt" "strings" "time" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "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/pkg/trackdns" "github.com/warmbly/warmbly/internal/utils/paging" "github.com/warmbly/warmbly/internal/utils/validate" ) func (s *emailService) Search(ctx context.Context, orgID, search, cursor, tag, limit string, allowedAccountIDs []uuid.UUID) (*models.EmailsResult, *errx.Error) { cursorId, err := paging.DecodeCursor(cursor) if err != nil { return nil, err } tagId, err := validate.Uuid(tag) if err != nil { return nil, err } if limit == "" { limit = "50" } limitN, err := validate.Limit(limit) if err != nil { return nil, err } return s.emailRepository.Search(ctx, orgID, search, cursorId, tagId, limitN, allowedAccountIDs) } func (s *emailService) Get(ctx context.Context, orgID, emailAccountID string) (*models.Email, *errx.Error) { return s.emailRepository.Get(ctx, orgID, emailAccountID) } func (s *emailService) Update(ctx context.Context, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error) { account, err := s.emailRepository.Update(ctx, userID, emailAccountID, udata) if err != nil { return nil, err } s.syncWarmupPoolMembership(ctx, account) s.publishAccountEvent(ctx, pubsub.EventAccountSynced, account) return account, nil } // BulkUpdateTags is a pure tag-link rewrite: no warmup pool or worker state // depends on tags, so no per-account fanout is needed (the caller audits // once and the spine refreshes lists). func (s *emailService) BulkUpdateTags(ctx context.Context, userID string, emailIDs, addTags, removeTags []uuid.UUID) (int, *errx.Error) { if len(addTags) == 0 && len(removeTags) == 0 { return 0, errx.ErrNotEnough } return s.emailRepository.BulkUpdateTags(ctx, userID, emailIDs, addTags, removeTags) } // SetWarmupLifecycle applies a warmup start/pause/resume/disable transition, // then re-syncs pool membership and fans out the change so dashboards update // live. Seeding the actual warmup task chain is the caller's responsibility // (the API handler triggers EnsureWarmupScheduled) — this service has no // Cloud Tasks client. func (s *emailService) SetWarmupLifecycle(ctx context.Context, userID, emailAccountID, action string) (*models.Email, *errx.Error) { account, err := s.emailRepository.SetWarmupLifecycle(ctx, userID, emailAccountID, action) if err != nil { return nil, err } s.syncWarmupPoolMembership(ctx, account) s.publishAccountEvent(ctx, pubsub.EventAccountSynced, account) return account, nil } // UpdateTrackingDomain sets or clears a mailbox's custom tracking domain and // resolves it immediately. The host it has to point at is this install's // TRACKING_DOMAIN, not a hardcoded warmbly.com name: a self-hosted deployment // was previously told to CNAME at a host it has nothing to do with, so its // customers could never verify. func (s *emailService) UpdateTrackingDomain(ctx context.Context, orgID, emailAccountID, domain string) (*models.TrackingDomainStatus, *errx.Error) { // Accept what a human pastes (a full URL, a trailing dot, stray case) and // store the bare host. Anything still malformed after that is a real error // with a real message, not a domain that saves and then sits at "pending" // forever because it can never resolve. domain = config.NormalizeTrackingHost(domain) if domain != "" { if xerr := validate.ValidateTrackingDomain(domain); xerr != nil { return nil, xerr } } status := s.resolveTrackingDomain(ctx, domain) if err := s.emailRepository.UpdateTrackingDomain(ctx, orgID, emailAccountID, domain, status.TrackingDomainVerified, status.TrackingDomainVerifiedAt); err != nil { return nil, err } return status, nil } // GetTrackingDomain reports a mailbox's stored tracking-domain state plus the // CNAME target for this install. It does no DNS work: resolving is a write // (the verdict gates whether real links route through the custom host), so it // lives behind VerifyTrackingDomain, the same split as CheckDomainAuth and // RefreshDomainAuth. func (s *emailService) GetTrackingDomain(ctx context.Context, orgID, emailAccountID string) (*models.TrackingDomainStatus, *errx.Error) { account, xerr := s.Get(ctx, orgID, emailAccountID) if xerr != nil { return nil, xerr } target := config.TrackingHostname() status := &models.TrackingDomainStatus{ TrackingDomain: account.TrackingDomain, TrackingDomainVerified: account.TrackingDomainVerified, TrackingDomainVerifiedAt: account.TrackingDomainVerifiedAt, CNAMETarget: target, } switch { case account.TrackingDomain == "": status.Status = trackdns.CodeUnset status.Message = "No custom tracking domain is set, so opens and clicks go through the shared tracking host." case target == "": status.Status = trackdns.CodeNoTarget status.Message = "This Warmbly install has no tracking host configured, so there is nothing to point a CNAME at yet. Ask your administrator to set TRACKING_DOMAIN." case account.TrackingDomainVerified: status.Status = trackdns.CodeVerified status.Message = fmt.Sprintf("%s points at %s.", account.TrackingDomain, target) default: status.Status = trackingStatusPending status.Message = fmt.Sprintf("%s has not verified yet. Check it again to see what DNS returns for it right now.", account.TrackingDomain) } return status, nil } // VerifyTrackingDomain re-resolves the stored domain and persists the verdict. // This is the escape hatch from "pending": a customer who fixed their record // gets it honored now rather than on the next save. func (s *emailService) VerifyTrackingDomain(ctx context.Context, orgID, emailAccountID string) (*models.TrackingDomainStatus, *errx.Error) { account, xerr := s.Get(ctx, orgID, emailAccountID) if xerr != nil { return nil, xerr } status := s.resolveTrackingDomain(ctx, account.TrackingDomain) // Re-verifying re-resolves; it never rewrites the stored value. status.TrackingDomain = account.TrackingDomain if err := s.emailRepository.UpdateTrackingDomain(ctx, orgID, emailAccountID, account.TrackingDomain, status.TrackingDomainVerified, status.TrackingDomainVerifiedAt); err != nil { return nil, err } return status, nil } // trackingStatusPending marks stored state that has not been re-resolved, as // opposed to the codes a live lookup produces. const trackingStatusPending = "pending" // resolveTrackingDomain runs the live lookup and renders it as an API status. func (s *emailService) resolveTrackingDomain(ctx context.Context, domain string) *models.TrackingDomainStatus { target := config.TrackingHostname() res := trackdns.Verify(ctx, domain, target) status := &models.TrackingDomainStatus{ TrackingDomain: res.Domain, TrackingDomainVerified: res.Verified, CNAMETarget: target, Status: res.Code, Message: res.Reason, Observed: res.Observed, TrackingHostUnresolvable: res.TargetUnresolvable, } if res.Verified { now := time.Now().UTC() status.TrackingDomainVerifiedAt = &now } return status } // CheckDomainAuth runs a live SPF/DKIM/DMARC lookup for a mailbox's sending // domain and reports it without writing anything. func (s *emailService) CheckDomainAuth(ctx context.Context, userID, emailAccountID string) (*dnsauth.Result, *errx.Error) { _, res, xerr := s.resolveDomainAuth(ctx, userID, emailAccountID) return res, xerr } // RefreshDomainAuth runs the same lookup and persists the verdict for every // active mailbox on that domain (authentication is a per-domain property, so // one owner's fix clears it for all of their mailboxes on it at once). // // This is the escape hatch from the send gate. Without it an owner who repairs // their DNS would keep being blocked until the background sweep next reached // their domain, which can be a day away, and "I fixed it and nothing happened" // is how a correct gate still becomes a support incident. func (s *emailService) RefreshDomainAuth(ctx context.Context, userID, emailAccountID string) (*dnsauth.Result, *errx.Error) { domain, res, xerr := s.resolveDomainAuth(ctx, userID, emailAccountID) if xerr != nil { return nil, xerr } if domain == "" { return res, nil } // Persist best-effort: the caller asked for a live check and gets the live // answer either way. A failed write only means the sweep re-derives it. _, _ = s.emailRepository.UpdateDomainAuthState( ctx, domain, res.State(), res.SPFFound, res.DKIMFound, res.DMARCFound, res.DMARCPolicy, res.Summary, time.Now(), ) return res, nil } // resolveDomainAuth loads the caller's mailbox and runs the DNS lookup for its // sending domain, returning the domain alongside the result so the persisting // caller does not re-derive it. func (s *emailService) resolveDomainAuth(ctx context.Context, userID, emailAccountID string) (string, *dnsauth.Result, *errx.Error) { account, xerr := s.emailRepository.Get(ctx, userID, emailAccountID) if xerr != nil { return "", nil, xerr } if account == nil { return "", nil, errx.ErrNotFound } domain := "" if at := strings.LastIndex(account.Email, "@"); at >= 0 { domain = strings.ToLower(strings.TrimSpace(account.Email[at+1:])) } res := dnsauth.Check(ctx, domain, nil) return domain, &res, nil } func (s *emailService) Delete(ctx context.Context, userID, emailAccountID string) *errx.Error { account, xerr := s.emailRepository.Get(ctx, userID, emailAccountID) if xerr != nil && xerr != errx.ErrNotFound { return xerr } if xerr := s.emailRepository.Delete(ctx, userID, emailAccountID); xerr != nil { return xerr } s.removeFromAllWarmupPools(ctx, account) s.publishAccountEvent(ctx, pubsub.EventAccountDisconnected, account) if s.webhookService != nil && account != nil && account.OrganizationID != nil { _, _ = s.webhookService.Dispatch(ctx, *account.OrganizationID, models.WebhookEventEmailAccountRemoved, map[string]any{ "email_account_id": account.ID, "email": account.Email, "provider": account.Provider, }) } return nil } func (s *emailService) syncWarmupPoolMembership(ctx context.Context, account *models.Email) { if s.warmupService == nil || account == nil { return } if !s.canUseWarmupPool(ctx, account) { s.removeFromAllWarmupPools(ctx, account) return } role := "recipient_only" if account.Warmup != nil { role = "sender_receiver" } _ = s.warmupService.EnsurePoolMembershipWithRole(ctx, account.ID, s.resolveWarmupPoolType(ctx, account), role) } func (s *emailService) removeFromAllWarmupPools(ctx context.Context, account *models.Email) { if s.warmupService == nil || account == nil { return } for _, poolType := range []string{"premium", "free"} { _ = s.warmupService.RemovePoolMembership(ctx, account.ID, poolType) } } func (s *emailService) canUseWarmupPool(ctx context.Context, account *models.Email) bool { if account == nil || account.Status != "active" || account.OrganizationID == nil || s.featureGate == nil { return false } canWarmup, err := s.featureGate.CanUseWarmup(ctx, *account.OrganizationID) return err == nil && canWarmup } func (s *emailService) resolveWarmupPoolType(ctx context.Context, account *models.Email) string { if account == nil { return "premium" } if account.WarmupPoolType != "" { return account.WarmupPoolType } // No organization means no entitlement to check, so the mailbox gets the // lower-trust pool rather than defaulting into the paid one. if account.OrganizationID == nil { return "free" } if s.featureGate != nil { isPaid, err := s.featureGate.IsPaidOrganization(ctx, *account.OrganizationID) if err == nil && !isPaid { return "free" } } return "premium" }