Files
warmbly/internal/pkg/mailcause/classify.go
T

301 lines
9.5 KiB
Go

package mailcause
import (
"net"
"regexp"
"strings"
"unicode"
"unicode/utf8"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/mailhost"
)
// Leg is one side of a connect attempt.
type Leg struct {
Name string // "SMTP" or "IMAP"
OK bool
Reason string // models.MailProbe*
Detail string
Host string
Port int
}
// Input is a failed connect. Password is read for its shape only and never
// appears in a Cause.
type Input struct {
MailHost string
SMTP Leg
IMAP Leg
Password string
}
// reasonRank matches the connect form's: the leg that says the most leads.
var reasonRank = map[string]int{
models.MailProbeAuthRefused: 6,
models.MailProbeTLS: 5,
models.MailProbeUnreachable: 4,
models.MailProbeCleartext: 3,
models.MailProbeProtocol: 2,
models.MailProbeTemporary: 1,
models.MailProbeTimeout: 0,
}
// Classify names the cause of a failed connect. Rules run from the most
// specific server wording to the probe reason; a connect where both legs
// passed returns the zero Cause.
func Classify(in Input) Cause {
if in.SMTP.OK && in.IMAP.OK {
return Cause{}
}
return must(classify(in))
}
func classify(in Input) string {
fam := family(in)
var failed []Leg
for _, l := range []Leg{in.SMTP, in.IMAP} {
if !l.OK {
failed = append(failed, l)
}
}
var sb strings.Builder
for _, l := range failed {
sb.WriteString(strings.ToLower(l.Detail))
sb.WriteByte('\n')
}
text := sb.String()
google := fam.Google() || hasAny(text, "gsmtp", "support.google.com", "accounts.google.com")
microsoft := fam.Microsoft() || hasAny(text, "outlook.com", "office365.com", "aka.ms/")
imapAuthRefused := !in.IMAP.OK && (in.IMAP.Reason == models.MailProbeAuthRefused ||
hasAny(strings.ToLower(in.IMAP.Detail), "authenticate failed", "login failed"))
anyAuthRefused := (!in.SMTP.OK && in.SMTP.Reason == models.MailProbeAuthRefused) || imapAuthRefused
// Exchange Online refuses a password over IMAP whatever else is wrong, so no SMTP fix connects it.
if microsoft && imapAuthRefused {
return MicrosoftBasicAuthDisabled
}
if microsoft {
switch {
case has(text, "smtpclientauthentication is disabled"):
return MicrosoftSMTPAuthDisabled
case has(text, "security defaults"):
return MicrosoftSecurityDefaults
case hasAny(text, "did not meet the criteria", "conditional access"):
return MicrosoftConditionalAccess
case has(text, "basic authentication is disabled"):
return MicrosoftBasicAuthDisabled
case hasAny(text, "504 5.7.4", "unrecognized authentication type"):
return MicrosoftSMTPAuthDisabled
}
}
if google {
switch {
case hasAny(text, "application-specific password required", "5.7.9", "invalidsecondfactor"):
return GoogleAppPasswordRequired
case hasAny(text, "5.7.14", "log in via your web browser", "web login required", "weblogin"):
return GoogleWebLoginRequired
case hasAny(text, "not enabled for imap", "imap access is disabled"):
return GoogleIMAPDisabled
}
} else if has(text, "application-specific password required") {
if k := appPasswordKey(fam); k != "" {
return k
}
}
if imapDisabled(text) {
if google {
return GoogleIMAPDisabled
}
return IMAPDisabled
}
switch {
case locked(text):
return AccountLocked
case tooMany(text):
return TooManyLogins
case hasAny(text, "must issue a starttls", "must issue starttls", "starttls first", "requires tls", "encryption required", "must use tls"):
return CleartextRefused
case hasAny(text, "no such host", "nxdomain", "name or service not known"):
return HostNotFound
case hasAny(text, "no supported authentication", "auth not supported", "authentication not supported",
"authentication not enabled", "unrecognized authentication type", "does not support auth",
"authentication mechanism not supported", "auth not available", "authentication not available"):
return AuthUnavailable
}
if google && (anyAuthRefused || hasAny(text, "5.7.8", "username and password not accepted", "badcredentials")) {
if in.Password != "" && !mailhost.LooksLikeGoogleAppPassword(in.Password) {
return GoogleAppPasswordRequired
}
return GoogleBadCredentials
}
if microsoft && (anyAuthRefused || hasAny(text, "5.7.3", "5.7.139", "credentials were incorrect")) {
return MicrosoftBadCredentials
}
if anyAuthRefused {
if k := appPasswordKey(fam); k != "" {
return k
}
}
if !in.SMTP.OK && portBlocked(in) {
return PortBlocked
}
lead := failed[0]
for _, l := range failed[1:] {
if reasonRank[l.Reason] > reasonRank[lead.Reason] {
lead = l
}
}
switch lead.Reason {
case models.MailProbeAuthRefused:
return AuthRefused
case models.MailProbeUnreachable:
return HostUnreachable
case models.MailProbeTLS:
return TLSFailed
case models.MailProbeCleartext:
return CleartextRefused
case models.MailProbeTemporary:
return Temporary
case models.MailProbeTimeout:
return Timeout
}
return ServerDeclined
}
// family resolves the provider from the stored mail_host, then from the servers.
func family(in Input) mailhost.Host {
if h := mailhost.Host(in.MailHost); h != mailhost.Unknown && h != mailhost.Other && mailhost.Valid(in.MailHost) {
return h
}
if h := mailhost.FromServer(in.SMTP.Host); h != mailhost.Unknown {
return h
}
return mailhost.FromServer(in.IMAP.Host)
}
// appPasswordKey is the cause for a refused password on a host that wants an app password.
func appPasswordKey(h mailhost.Host) string {
switch h {
case mailhost.GoogleWorkspace, mailhost.Gmail:
return GoogleAppPasswordRequired
case mailhost.Yahoo:
return YahooAppPasswordRequired
case mailhost.AOL:
return AOLAppPasswordRequired
case mailhost.ICloud:
return ICloudAppPasswordRequired
case mailhost.Zoho:
return ZohoAppPasswordRequired
case mailhost.Fastmail:
return FastmailAppPasswordRequired
case mailhost.Yandex:
return YandexAppPasswordRequired
}
return ""
}
// portBlocked: SMTP stayed silent on 465, or on 25/465 while IMAP signed in.
func portBlocked(in Input) bool {
silent := in.SMTP.Reason == models.MailProbeTimeout || in.SMTP.Reason == models.MailProbeUnreachable
if !silent || has(strings.ToLower(in.SMTP.Detail), "no such host") {
return false
}
if in.SMTP.Reason == models.MailProbeTimeout && in.SMTP.Port == 465 {
return true
}
return in.IMAP.OK && (in.SMTP.Port == 25 || in.SMTP.Port == 465)
}
func imapDisabled(text string) bool {
return hasAny(text, "imap access is disabled", "imap is disabled", "imap access disabled",
"not enabled for imap", "imap is not enabled", "yet to enable imap", "imap access not enabled",
"imap not enabled", "enable imap access")
}
func locked(text string) bool {
return hasAny(text, "account is locked", "account locked", "account has been locked", "account is temporarily locked",
"account is disabled", "account disabled", "account has been disabled", "account suspended",
"account is suspended", "account has been suspended", "account is blocked", "account has been blocked",
"user is disabled", "mailbox is disabled", "mailbox disabled", "account deactivated",
"account has been deactivated", "account is deactivated", "user account is locked")
}
func tooMany(text string) bool {
return hasAny(text, "454 4.7.0", "454-4.7.0", "too many login", "too many bad auth", "too many auth",
"too many failed", "too many invalid", "too many simultaneous", "too many connections",
"[limit]", "rate limit", "ratelimit", "rate-limit", "rate limited", "throttl")
}
func has(s, sub string) bool { return strings.Contains(s, sub) }
func hasAny(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
const maxScrubbed = 500
var (
emailRe = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)+`)
ipv4Re = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
ipv6Re = regexp.MustCompile(`[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7}(?:%[0-9A-Za-z]+)?`)
hexRe = regexp.MustCompile(`\b[0-9A-Fa-f]{16,}\b`)
tokenRe = regexp.MustCompile(`[A-Za-z0-9+/=_\-]{20,}`)
// userRe catches "user=bob", "username: bob", "login=bob".
userRe = regexp.MustCompile(`(?i)\b(user(?:name)?|login|account|mailbox|uid|authzid|authcid)(\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)`)
// quotedUserRe catches "for user 'bob'" and "user \"bob\"".
quotedUserRe = regexp.MustCompile(`(?i)\b(user(?:name)?|for|account|mailbox)(\s+)("[^"]{1,128}"|'[^']{1,128}'|<[^>]{1,128}>)`)
)
// Scrub removes addresses, IPs, long tokens and usernames from a server's
// reply so it can be stored and shown without leaking who or what signed in.
func Scrub(detail string) string {
s := strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' {
return ' '
}
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) {
return -1
}
return r
}, detail)
s = emailRe.ReplaceAllString(s, "[address]")
s = userRe.ReplaceAllString(s, "$1$2[user]")
s = quotedUserRe.ReplaceAllString(s, "$1$2[user]")
s = ipv6Re.ReplaceAllStringFunc(s, func(m string) string {
if net.ParseIP(strings.SplitN(m, "%", 2)[0]) != nil {
return "[ip]"
}
return m
})
s = ipv4Re.ReplaceAllStringFunc(s, func(m string) string {
if net.ParseIP(m) != nil {
return "[ip]"
}
return m
})
s = hexRe.ReplaceAllString(s, "[token]")
s = tokenRe.ReplaceAllStringFunc(s, func(m string) string {
if strings.ContainsAny(m, "0123456789") && strings.IndexFunc(m, unicode.IsLetter) >= 0 {
return "[token]"
}
return m
})
s = strings.Join(strings.Fields(s), " ")
if utf8.RuneCountInString(s) > maxScrubbed {
r := []rune(s)
s = string(r[:maxScrubbed]) + "..."
}
return s
}