mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-04 16:02:03 +00:00
176 lines
6.5 KiB
Go
176 lines
6.5 KiB
Go
package email
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"net"
|
|
"net/smtp"
|
|
"net/textproto"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/warmbly/warmbly/internal/client/netbind"
|
|
wsmtp "github.com/warmbly/warmbly/internal/client/smtpimap/smtp"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// probeConversation bounds the SMTP conversation when the context carries no
|
|
// deadline of its own.
|
|
const probeConversation = 10 * time.Second
|
|
|
|
// VerifySMTP probes a mailbox's SMTP credentials the same way the send path
|
|
// connects: the caller's security mode decides implicit TLS versus STARTTLS,
|
|
// and any port is accepted. security may be empty, in which case the port
|
|
// convention decides. The result says why a probe failed, so a refused
|
|
// password and an unreachable host are not reported as the same thing.
|
|
func VerifySMTP(ctx context.Context, host string, port int, user, pass, security string) ProbeResult {
|
|
// Brackets belong to the address, not to the host, and JoinHostPort is
|
|
// what puts them back for an IPv6 literal.
|
|
host = models.NormalizeMailHost(host)
|
|
|
|
// Matches the send client's TLS policy: MAIL_TLS_INSECURE is a dev-only
|
|
// knob for the local self-signed sandbox, never set in production.
|
|
tlsConf := &tls.Config{
|
|
ServerName: host,
|
|
InsecureSkipVerify: netbind.InsecureTLS(), //nolint:gosec // MAIL_TLS_INSECURE, local dev only
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
|
|
// The unencrypted mode only ever addresses this machine. Refusing it here
|
|
// as well as at send time means a mailbox that could never be dialled
|
|
// safely fails at connect, where the user is standing in front of the
|
|
// form, rather than at the first send.
|
|
resolved := models.ResolveSMTPSecurity(security, port)
|
|
if resolved == models.MailSecurityNone && !models.CleartextMailAllowed(host) {
|
|
return probeFailText(models.MailProbeCleartext, "unencrypted SMTP is only allowed to a loopback host on a self-hosted instance")
|
|
}
|
|
// TCP first and TLS second, like the IMAP probe, so a port that answers
|
|
// and then fails the handshake reads as a TLS problem, not as unreachable.
|
|
// The dial leaves from WORKER_BIND_IP like the sends it vouches for, and
|
|
// comes back holding 587 when the mailbox's 465 never answered.
|
|
dialed, err := wsmtp.DialSubmission(ctx, netbind.FromEnv(), host, port, security)
|
|
// A bad host is ordinary user input, not an exceptional case: dial failed
|
|
// means conn is nil, and closing it would panic this goroutine and take
|
|
// the whole worker down with it.
|
|
if err != nil || dialed.Conn == nil {
|
|
if err == nil {
|
|
err = errors.New("dial returned no connection")
|
|
}
|
|
return probeFail(ctx, models.MailProbeUnreachable, err)
|
|
}
|
|
conn := dialed.Conn
|
|
defer conn.Close()
|
|
if dialed.Security == models.MailSecurityNone && !netbind.LoopbackPeer(conn) {
|
|
return probeFailText(models.MailProbeCleartext, "the host did not resolve to this machine")
|
|
}
|
|
// The greeting, EHLO and STARTTLS read with no deadline of their own, so a
|
|
// server that accepts and never speaks would park this goroutine, and the
|
|
// handler waiting on it, for good.
|
|
deadline := time.Now().Add(probeConversation)
|
|
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
|
|
deadline = d
|
|
}
|
|
if err := conn.SetDeadline(deadline); err != nil {
|
|
return probeFail(ctx, models.MailProbeProtocol, err)
|
|
}
|
|
res := verifySMTPSession(ctx, conn, dialed.Security, host, user, pass, tlsConf)
|
|
if dialed.FellBack {
|
|
res.Port, res.Security = dialed.Port, dialed.Security
|
|
}
|
|
return res
|
|
}
|
|
|
|
// verifySMTPSession runs the sign-in on an open socket in the given mode.
|
|
func verifySMTPSession(ctx context.Context, conn net.Conn, resolved, host, user, pass string, tlsConf *tls.Config) ProbeResult {
|
|
implicitTLS := resolved == models.MailSecurityTLS
|
|
if implicitTLS {
|
|
tlsConn := tls.Client(conn, tlsConf)
|
|
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
|
return probeFail(ctx, models.MailProbeTLS, err)
|
|
}
|
|
conn = tlsConn
|
|
}
|
|
|
|
c, err := smtp.NewClient(conn, host)
|
|
if err != nil {
|
|
return probeFail(ctx, models.MailProbeProtocol, err)
|
|
}
|
|
defer c.Close()
|
|
// Explicit EHLO: Extension() swallows a failed greeting exchange and then
|
|
// reports no AUTH, which read as a mailbox with nothing to verify.
|
|
if err := c.Hello(ehloName(user)); err != nil {
|
|
return probeFail(ctx, models.MailProbeProtocol, err)
|
|
}
|
|
|
|
if !implicitTLS && resolved != models.MailSecurityNone {
|
|
// TLS stays mandatory, with the same dev-only escape hatch the send
|
|
// path uses for the local no-STARTTLS sink.
|
|
if ok, _ := c.Extension("STARTTLS"); ok {
|
|
if err := c.StartTLS(tlsConf); err != nil {
|
|
return probeFail(ctx, models.MailProbeTLS, err)
|
|
}
|
|
} else if !netbind.InsecureTLS() {
|
|
return probeFailText(models.MailProbeTLS, "the server offers no STARTTLS on this port")
|
|
}
|
|
}
|
|
|
|
// Negotiated from what the server advertised, like the send path: a
|
|
// server that offers only LOGIN refuses a blind AUTH PLAIN, and probing
|
|
// with PLAIN alone rejected mailboxes whose credentials were correct.
|
|
auth, aerr := wsmtp.NegotiateAuth(c, user, pass, host)
|
|
if aerr != nil {
|
|
return probeFail(ctx, models.MailProbeProtocol, aerr)
|
|
}
|
|
if auth == nil {
|
|
// No AUTH offered at all: nothing to verify, and the send path will
|
|
// not authenticate either.
|
|
return probeOK()
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- c.Auth(auth) }()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
return probeOK()
|
|
}
|
|
return probeFail(ctx, smtpAuthReason(err), err)
|
|
case <-ctx.Done():
|
|
return probeFail(ctx, models.MailProbeTimeout, ctx.Err())
|
|
}
|
|
}
|
|
|
|
// smtpAuthReason reads an AUTH refusal by its reply code. Only the two codes
|
|
// that answer the credentials themselves are a refusal: 535 (RFC 4954) and
|
|
// 534, which Google uses for "application-specific password required" and
|
|
// "log in via your web browser". A 4xx asks to come back later; any other
|
|
// 5xx (504 mechanism unsupported, 530 must STARTTLS, 538 encryption
|
|
// required) is the conversation failing, not the password.
|
|
func smtpAuthReason(err error) string {
|
|
var te *textproto.Error
|
|
if !errors.As(err, &te) {
|
|
if errors.Is(err, wsmtp.ErrSMTPCleartextAuth) {
|
|
return models.MailProbeCleartext
|
|
}
|
|
return models.MailProbeProtocol
|
|
}
|
|
switch {
|
|
case te.Code == 534 || te.Code == 535:
|
|
return models.MailProbeAuthRefused
|
|
case te.Code >= 500:
|
|
return models.MailProbeProtocol
|
|
default:
|
|
return models.MailProbeTemporary
|
|
}
|
|
}
|
|
|
|
// ehloName announces the sender's own domain, as the send path does.
|
|
func ehloName(user string) string {
|
|
if at := strings.LastIndexByte(user, '@'); at >= 0 && at < len(user)-1 {
|
|
return user[at+1:]
|
|
}
|
|
return "localhost"
|
|
}
|