mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
324 lines
12 KiB
Go
324 lines
12 KiB
Go
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"
|
|
}
|