Billionmail/BillionMail · error

failed to connect: %w

Error message

failed to connect: %w

What it means

Send() found the sender disconnected (connected==false or client==nil) and attempted an on-demand Connect(); this error wraps that Connect failure. The wrapped error is one of the connect-time errors (TLS dial, SMTP dial, STARTTLS, or auth), so diagnose by unwrapping. The mutex is unlocked/relocked around the blocking Connect call so it is not held during network I/O.

Source

Thrown at core/internal/service/mail_service/sending.go:320

	return fmt.Sprintf("<%d.%s@%s>", timestampMillis, randomID, domainPart)
}

// Send sends an email to specified recipients
func (e *EmailSender) Send(message Message, recipients []string) error {
	if len(recipients) == 0 {
		return fmt.Errorf("no recipients specified")
	}

	e.mutex.Lock()
	defer e.mutex.Unlock()

	// Make sure we have a connection
	if !e.connected || e.client == nil {
		e.mutex.Unlock()
		if err := e.Connect(); err != nil {
			e.mutex.Lock()
			return fmt.Errorf("failed to connect: %w", err)
		}
		e.mutex.Lock()
	}

	// Try to send the message, with reconnect on failure
	err := e.doSend(message, recipients)
	if err != nil {
		// Connection might be stale, try to reconnect once
		g.Log().Debug(context.Background(), "SMTP send failed, attempting reconnection")

		// Clean up
		e.client.Close()
		e.connected = false
		e.client = nil

		// Try to reconnect
		e.mutex.Unlock()
		if err := e.Connect(); err != nil {

View on GitHub (pinned to fc36c76c05)

Solutions

  1. Unwrap the error chain (errors.Unwrap / errors.As) to see whether it was dial, TLS, or auth, and fix that specific root cause.
  2. Call Connect() explicitly at sender creation/startup so failures surface early rather than at first Send.
  3. Add retry with backoff around reconnects for transient network errors before surfacing to the caller.
  4. Verify SMTP host/port/credentials in the environment the sender was constructed from.

Example fix

// before
if err := e.Connect(); err != nil {
    e.mutex.Lock()
    return fmt.Errorf("failed to connect: %w", err)
}
// after
if err := e.Connect(); err != nil {
    e.mutex.Lock()
    var netErr net.Error
    if errors.As(err, &netErr) && netErr.Timeout() {
        time.Sleep(2 * time.Second)
        if retryErr := e.Connect(); retryErr == nil {
            return nil // continue send flow
        }
    }
    return fmt.Errorf("failed to connect: %w", err)
}
Defensive patterns

Strategy: try-catch

Validate before calling

if err := sender.Connect(); err != nil {
    return fmt.Errorf("SMTP pre-flight connect failed: %w", err)
}
// sender is now warm; Send() will skip its lazy-connect path

Type guard

func isConnectErr(err error) bool {
    return strings.Contains(err.Error(), "failed to connect")
}

Try / catch

if err := sender.Send(msg, rcpts); err != nil {
    if isConnectErr(err) {
        var dnsErr *net.DNSError
        switch {
        case errors.As(err, &dnsErr):
            return fmt.Errorf("bad SMTP host config: %w", err)
        case strings.Contains(err.Error(), "SMTP auth"):
            return ErrBadCredentials
        default:
            time.Sleep(2 * time.Second) // transient: retry once
            return sender.Send(msg, rcpts)
        }
    }
    return err
}

Prevention

When it happens

Trigger: First Send on a never-connected sender, or Send after Disconnect/server idle timeout, where Connect() fails — bad host/port, network down, or rejected credentials.

Common situations: Long-lived sender left idle until the server dropped the connection, then reconnect fails because credentials expired; misconfigured host discovered only at first send; transient network outage at send time; container lost DNS/egress.

Related errors


AI-assisted analysis of Billionmail/BillionMail@fc36c76c05 (2026-09-05). Data as JSON: /api/errors/21983108ba1b8202. Report an issue: GitHub.