mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-04 08:02:01 +00:00
123 lines
4.2 KiB
Go
123 lines
4.2 KiB
Go
package goog
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/pkg/mailauth"
|
|
"google.golang.org/api/googleapi"
|
|
)
|
|
|
|
// IsThreadRefusal reports a send Gmail rejected because of the threadId it was
|
|
// given rather than because of the message. Gmail only files a message in a
|
|
// thread when the subject and the reference headers line up with it, and the
|
|
// thread has to still exist in this mailbox. The send provably did not happen,
|
|
// so the caller can safely try again without the thread handle and deliver the
|
|
// email as its own conversation instead of failing the step.
|
|
//
|
|
// Only ask this about a send that carried a threadId.
|
|
func IsThreadRefusal(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var gerr *googleapi.Error
|
|
if !errors.As(err, &gerr) {
|
|
return false
|
|
}
|
|
// Beyond the message it is sending, the only entity the request names is
|
|
// the thread, so a 404 is about the thread whatever it says. Gmail's
|
|
// wording for a thread that is gone is the generic "Requested entity was
|
|
// not found", which is why this cannot be a match on the word.
|
|
if gerr.Code == 404 {
|
|
return true
|
|
}
|
|
// A 400 usually is about the message (a rejected address, a malformed
|
|
// body), and re-sending those without the handle would only fail again, so
|
|
// only one that names the thread counts.
|
|
return gerr.Code == 400 && strings.Contains(strings.ToLower(gerr.Message), "thread")
|
|
}
|
|
|
|
// googleThrottleReasons are the reasons Google returns for "you are going too
|
|
// fast", all of which arrive as a 403 alongside the ones that mean the grant is
|
|
// genuinely refused. Treating the whole status as refusal told the owner of a
|
|
// mailbox that had merely hit a per-minute quota to re-authorize, and reported
|
|
// every retry as an incident.
|
|
var googleThrottleReasons = map[string]struct{}{
|
|
"rateLimitExceeded": {},
|
|
"userRateLimitExceeded": {},
|
|
"quotaExceeded": {},
|
|
"dailyLimitExceeded": {},
|
|
}
|
|
|
|
func isGoogleThrottle(gerr *googleapi.Error) bool {
|
|
if gerr.Code == 429 {
|
|
return true
|
|
}
|
|
if gerr.Code != 403 {
|
|
return false
|
|
}
|
|
for _, item := range gerr.Errors {
|
|
if _, ok := googleThrottleReasons[item.Reason]; ok {
|
|
return true
|
|
}
|
|
}
|
|
// Some Gmail 403s carry no structured reason, only the sentence. "Quota
|
|
// exceeded for quota metric ... limit ... per minute per user" is the one
|
|
// that flooded error tracking.
|
|
msg := strings.ToLower(gerr.Message)
|
|
return strings.Contains(msg, "quota exceeded") || strings.Contains(msg, "rate limit exceeded")
|
|
}
|
|
|
|
func HandleError(err error) *errx.MailError {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
// errors.As, not a type assertion: the API client wraps its error on some
|
|
// paths, and an unwrapped assertion sends a real 401 down the transport
|
|
// branch below.
|
|
var gerr *googleapi.Error
|
|
if errors.As(err, &gerr) {
|
|
// Throttles come back as 403 and 429, so the status alone does not say
|
|
// whether the caller should back off or the owner should re-authorize.
|
|
if isGoogleThrottle(gerr) {
|
|
return errx.ErrMailSendingTooFast
|
|
}
|
|
switch gerr.Code {
|
|
case 401:
|
|
return errx.ErrMailGoogleAuth
|
|
case 402:
|
|
return errx.ErrMailGooglePayment
|
|
case 403:
|
|
return errx.ErrMailGoogleForbidden(gerr.Message)
|
|
default:
|
|
respErr := errx.ErrMailGoogleUnknown(gerr.Code, gerr.Message)
|
|
log.Debug().Err(err).Msg("Google Api Error")
|
|
return respErr
|
|
}
|
|
}
|
|
|
|
// The token source runs inside the API call, so a grant the user revoked
|
|
// (or Google expired) never reaches Gmail to become a 401: it fails the
|
|
// call itself and would otherwise read as an unreachable server, promising
|
|
// a retry that can never succeed.
|
|
if f := mailauth.ClassifyTokenError(err); f.Refused() {
|
|
log.Debug().
|
|
Str("oauth_error", f.ErrorCode).
|
|
Str("oauth_description", f.Description).
|
|
Int("status", f.Status).
|
|
Bool("revoked", f.Revoked()).
|
|
Msg("Gmail token refresh refused")
|
|
if f.Revoked() {
|
|
return errx.ErrMailGoogleAuth
|
|
}
|
|
}
|
|
|
|
// Non-API failures (DNS, TLS, timeouts) are transient transport errors.
|
|
// Returning nil here would silently swallow them and leave callers holding
|
|
// a typed-nil *MailError in an error interface.
|
|
log.Debug().Err(err).Msg("Google transport error")
|
|
return errx.ErrMailServerUnreachable
|
|
}
|