mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-04 08:02:01 +00:00
301 lines
9.5 KiB
Go
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
|
|
}
|