Files
warmbly/internal/errx/email.go
T
Matthew Meszaros 1b8c51ad06 feat: read spam complaints and domain-auth refusals, the last two open delivery signals (#232)
* feat: close the two halves of the delivery-signal loop that were still open, complaints and domain-auth refusals: a spam complaint arrives as mail long after the send succeeded, and nothing read those reports, so the strongest negative signal a sender gets never reached the complaint rate, the suppression list or the breaker; internal/pkg/arf parses RFC 5965 feedback reports worker-side alongside the existing DSN path, takes the LAST Message-ID because the reported mail's headers follow the report's own, and records only abuse-type reports so a not-spam report cannot be inverted into a complaint; separately a receiving server refusing mail because the SENDING DOMAIN failed its authentication (5.7.515, 5.7.26) was classified as SERVER_UNREACHABLE and retried forever, and it is now its own hard code that blames the domain rather than the address, leaves the recipient unsuppressed, and brings that domain's DNS re-check forward so the sweep confirms the verdict the send gate acts on

* feat: make the complaint path actually reachable, and stop it trusting the report: selectTextParts only ever picked text/plain and text/html, so an RFC 5965 report's message/feedback-report and message/rfc822 parts never reached the worker and both Feedback-Type and the reported Message-ID were invisible, which would have left this feature inert and has been quietly weakening DSN parsing too; report parts are now selected as plain text; the complainer is taken from the RESOLVED SEND rather than the report body, because a report is unauthenticated mail anyone able to reach the mailbox could forge and honouring the address it names would let a forger suppress a contact the send never went to; Original-Mail-From is no longer read as the complainer since that address is the sender; and a domain-auth refusal now releases the reservation instead of spending one of the lead's attempts, because the recipient received nothing and the problem is the mailbox's domain, so another mailbox in the pool picks the lead up

* feat: fix the same missing-parts gap in the Gmail adapter, and say plainly that Microsoft Graph cannot see reports at all: goog.extractBody took only text/plain and text/html exactly as the IMAP path did, so a feedback report synced from Gmail was as invisible as one synced over IMAP, and the tests that would have caught either called the parser directly rather than going through the adapter where it actually broke; the new tests exercise that seam, and Graph returns one rendered body with no parts so reports there are undetectable without a MIME fetch that is not built, which the docs now state rather than implying full coverage
2026-08-28 11:02:45 -07:00

262 lines
11 KiB
Go

package errx
import (
"fmt"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/config"
)
type MailErrorLogType string
const (
MailErrorLogTypeExternal MailErrorLogType = "EXTERNAL"
MailErrorLogTypeInternal MailErrorLogType = "INTERNAL"
)
func (l MailErrorLogType) IsExternal() bool {
return l == MailErrorLogTypeExternal
}
type MailErrorType string
const (
MailErrorCritical MailErrorType = "CRITICAL"
MailErrorWarning MailErrorType = "WARNING"
MailErrorInformational MailErrorType = "INFORMATIONAL"
)
type MailErrorCode string
const (
MailErrorCodeFolderLimit MailErrorCode = "MAX_FOLDERS_REACHED"
MailErrorCodeUpdateLimit MailErrorCode = "MAX_FOLDERS_REACHED"
MailErrorCodeGoogleAuth MailErrorCode = "GOOGLE_AUTHENTICATION_FAILED"
MailErrorCodeGooglePayment MailErrorCode = "GOOGLE_PAYMENT_REQUIRED"
MailErrorCodeGoogleForbidden MailErrorCode = "GOOGLE_FORBIDDEN"
MailErrorCodeServerUnreachable MailErrorCode = "SERVER_UNREACHABLE"
// MailErrorCodeNotFound is the provider saying the addressed resource is
// absent, kept apart from SERVER_UNREACHABLE so "this folder does not
// exist" is never inferred from a catch-all that also covers a 503.
MailErrorCodeNotFound MailErrorCode = "RESOURCE_NOT_FOUND"
MailErrorCodeUnsupported MailErrorCode = "UNSUPPORTED"
MailErrorCodeInvalidCredentials MailErrorCode = "INVALID_CREDENTIALS" // e.g. invalid username or password
MailErrorCodeAuthorizationFailed MailErrorCode = "AUTHORIZATION_FAILED" // e.g. imap disabled
MailErrorCodeAuthenticationFailed MailErrorCode = "AUTHENTICATION_FAILED" // e.g. invalid token
MailErrorCodeConnectionLost MailErrorCode = "CONNECTION_LOST"
MailErrorCodeImapUnknown MailErrorCode = "IMAP_UNKNOWN"
// Rate limiting and abuse detection
MailErrorCodeRateLimitExceeded MailErrorCode = "RATE_LIMIT_EXCEEDED"
// Sync fair use (internal/app/worker/wmail/governor.go). SYNC_FLOOD is
// an hourly volume no real mailbox produces; SYNC_FAIR_USE is repeated
// daily overage. Both deactivate the mailbox until someone reactivates it.
MailErrorCodeSyncFlood MailErrorCode = "SYNC_FLOOD"
MailErrorCodeSyncFairUse MailErrorCode = "SYNC_FAIR_USE"
MailErrorCodeSendingTooFast MailErrorCode = "SENDING_TOO_FAST"
MailErrorCodeRecipientRejected MailErrorCode = "RECIPIENT_REJECTED"
// MailErrorCodeDomainAuthRejected is the receiving side refusing the mail
// because the SENDING DOMAIN failed its authentication bar (Outlook's
// 5.7.515, Gmail's 5.7.26). Not a dead server and not a bad recipient:
// retrying from the same domain fails identically until DNS is fixed.
MailErrorCodeDomainAuthRejected MailErrorCode = "DOMAIN_AUTH_REJECTED"
MailErrorCodeQuotaExceeded MailErrorCode = "QUOTA_EXCEEDED"
MailErrorCodeAccountSuspended MailErrorCode = "ACCOUNT_SUSPENDED"
)
var MailErrorCodeGoogleUnknown = func(code int) MailErrorCode {
return MailErrorCode(fmt.Sprintf("Unknown (%d)", code))
}
type MailErrorResolveMethod string
const (
MailErrorResolveMethodNone MailErrorResolveMethod = ""
MailErrorResolveMethodAuth MailErrorResolveMethod = "OAUTH"
MailErrorResolveMethodRetry MailErrorResolveMethod = "RETRY"
MailErrorResolveMethodReload MailErrorResolveMethod = "RELOAD"
)
type MailError struct {
ID string `json:"id"`
Type MailErrorType `json:"type"`
Code MailErrorCode `json:"code"`
ResolveMethod MailErrorResolveMethod `json:"resolve_method"`
ResolvedAt *time.Time `json:"resolved_at"`
Message string `json:"message"`
CreatedAt time.Time `json:"created_at"`
}
func (e *MailError) Error() string {
return fmt.Sprintf("Email (%s): %s", e.ID, e.Message)
}
func (e *MailError) Unwrap() error {
return fmt.Errorf("Email (%s): %s", e.ID, e.Message)
}
func MError(eType MailErrorType, code MailErrorCode, message string, resolveMethod MailErrorResolveMethod) *MailError {
return &MailError{
ID: uuid.NewString(),
Type: eType,
Code: code,
Message: message,
ResolveMethod: resolveMethod,
}
}
var (
ErrMailFoldersMax = MError(MailErrorCritical, MailErrorCodeFolderLimit, fmt.Sprintf("You reached the maximum limit of %d folders reached.", config.MaxEmailFolders), MailErrorResolveMethodReload)
ErrMailUpdateLimit = MError(MailErrorCritical, MailErrorCodeUpdateLimit, "Your inbox has received an unusually large number of updates. Please reactivate your inbox once the issue is resolved.", MailErrorResolveMethodReload)
ErrMailGoogleAuth = MError(MailErrorCritical, MailErrorCodeGoogleAuth, "Cannot access your Gmail account. Please re-authorize your account to restore mailbox access.", MailErrorResolveMethodReload)
ErrMailGooglePayment = MError(MailErrorCritical, MailErrorCodeGooglePayment, "Gmail access blocked due to unpaid invoices. Please resolve the payment with Google.", MailErrorResolveMethodReload)
ErrMailGoogleForbidden = func(message string) *MailError {
return MError(MailErrorWarning, MailErrorCodeGoogleForbidden, fmt.Sprintf("Gmail access blocked: %s", message), MailErrorResolveMethodReload)
}
ErrMailGoogleUnknown = func(code int, message string) *MailError {
return MError(MailErrorWarning, MailErrorCodeGoogleUnknown(code), message, MailErrorResolveMethodRetry)
}
ErrMailServerUnreachable = MError(MailErrorWarning, MailErrorCodeServerUnreachable, "The connection to the mail server could not be established. The server may be offline or blocking the connection.", MailErrorResolveMethodRetry)
ErrMailResourceNotFound = MError(MailErrorWarning, MailErrorCodeNotFound, "The mail server does not have the folder or message that was requested.", MailErrorResolveMethodRetry)
ErrMailCondStoreNotSupported = MError(MailErrorCritical, MailErrorCodeUnsupported, "The mail server does not support the required CONDSTORE extension. Synchronization cannot continue.", MailErrorResolveMethodReload)
ErrMailInvalidCredentials = MError(
MailErrorCritical,
MailErrorCodeInvalidCredentials,
"The email address or password is incorrect. Please check your credentials and try again.",
MailErrorResolveMethodReload,
)
ErrMailAuthenticationFailed = MError(
MailErrorCritical,
MailErrorCodeAuthenticationFailed,
"Authentication failed. This often happens with OAuth2 providers (like Google for Gmail or Microsoft for Outlook). Possible causes: invalid or expired token, wrong permissions/scope, or two-factor authentication requiring an app password. Please re-authenticate or check your account security settings.",
MailErrorResolveMethodAuth,
)
ErrMailAuthorizationFailed = MError(
MailErrorCritical,
MailErrorCodeAuthorizationFailed,
"This account lacks permission to access certain required resources.",
MailErrorResolveMethodReload,
)
ErrMailUnknownImapError = func(errStatus string) *MailError {
return MError(
MailErrorCritical,
MailErrorCodeImapUnknown,
fmt.Sprintf("Something went wrong: %s", errStatus),
MailErrorResolveMethodReload,
)
}
// Rate limiting and abuse errors
ErrMailRateLimitExceeded = MError(
MailErrorWarning,
MailErrorCodeRateLimitExceeded,
"This email account has exceeded the sync rate limit. This may indicate suspicious activity.",
MailErrorResolveMethodNone,
)
ErrMailSyncFlood = MError(
MailErrorWarning,
MailErrorCodeSyncFlood,
"This mailbox received far more new mail in one hour than sync fair use allows, so syncing was stopped to protect the platform.",
MailErrorResolveMethodNone,
)
ErrMailSyncFairUse = MError(
MailErrorWarning,
MailErrorCodeSyncFairUse,
"This mailbox exceeded its daily sync budget on several recent days, so syncing was stopped. Reactivate it once the volume is back to normal.",
MailErrorResolveMethodNone,
)
ErrMailSendingTooFast = MError(
MailErrorWarning,
MailErrorCodeSendingTooFast,
"Emails are being sent too quickly. Please wait before sending more emails.",
MailErrorResolveMethodRetry,
)
ErrMailRecipientRejected = MError(
MailErrorWarning,
MailErrorCodeRecipientRejected,
"The recipient email address was rejected by the mail server.",
MailErrorResolveMethodNone,
)
ErrMailDomainAuthRejected = MError(
MailErrorCritical,
MailErrorCodeDomainAuthRejected,
"The receiving mail server refused this message because your sending domain failed its authentication checks. Fix the domain's SPF, DKIM and DMARC records.",
MailErrorResolveMethodNone,
)
ErrMailQuotaExceeded = MError(
MailErrorCritical,
MailErrorCodeQuotaExceeded,
"Your email sending quota has been exceeded. Please try again later.",
MailErrorResolveMethodRetry,
)
ErrMailAccountSuspended = MError(
MailErrorCritical,
MailErrorCodeAccountSuspended,
"This email account has been suspended. Please contact your email provider.",
MailErrorResolveMethodReload,
)
)
// UserErrorInfo contains user-visible error information
type UserErrorInfo struct {
Title string
Message string
ActionRequired string
}
// GetUserErrorInfo returns user-friendly error information for display
func (e *MailError) GetUserErrorInfo() UserErrorInfo {
info := UserErrorInfo{
Title: "Email Error",
Message: e.Message,
}
switch e.Code {
case MailErrorCodeGoogleAuth, MailErrorCodeAuthenticationFailed:
info.Title = "Authentication Required"
info.ActionRequired = "Please re-authorize your email account"
case MailErrorCodeInvalidCredentials:
info.Title = "Invalid Credentials"
info.ActionRequired = "Please update your email credentials"
case MailErrorCodeServerUnreachable:
info.Title = "Connection Error"
info.ActionRequired = "The email server is temporarily unavailable. We'll retry automatically."
case MailErrorCodeNotFound:
info.Title = "Mailbox Item Missing"
info.ActionRequired = "The folder or message is no longer on the mail server. Nothing to do; we'll skip it."
case MailErrorCodeRateLimitExceeded:
info.Title = "Rate Limit Exceeded"
info.ActionRequired = "Your account has been temporarily limited due to unusual activity"
case MailErrorCodeSyncFlood:
info.Title = "Sync stopped: unusual volume"
info.ActionRequired = "Check what is delivering mail into this mailbox, then reactivate it under Mailboxes"
case MailErrorCodeSyncFairUse:
info.Title = "Sync stopped: fair use"
info.ActionRequired = "Reduce the volume landing in this mailbox or ask your administrator to raise the sync budget, then reactivate it"
case MailErrorCodeSendingTooFast:
info.Title = "Sending Too Fast"
info.ActionRequired = "Please wait before sending more emails"
case MailErrorCodeQuotaExceeded:
info.Title = "Quota Exceeded"
info.ActionRequired = "Your daily sending limit has been reached"
case MailErrorCodeAccountSuspended:
info.Title = "Account Suspended"
info.ActionRequired = "Contact your email provider to resolve this issue"
case MailErrorCodeRecipientRejected:
info.Title = "Recipient Rejected"
info.ActionRequired = "The recipient address was not accepted"
}
return info
}
// IsUserVisible returns true if this error should be shown to users
func (e *MailError) IsUserVisible() bool {
return e.Type == MailErrorCritical || e.Type == MailErrorWarning
}