DEV Community

Joy Biswas
Joy Biswas

Posted on

Email Validation and SMTP Handshake With Go

I was implementing a signup endpoint for an API where users can register via email. There are many ways to verify an email address. You could use a popular library like go-playground/validator to check if the input is in an email format. However, this doesn't confirm if the email address actually exists or if its domain can receive mail.

If you prefer not to use regex or an external package, Go's standard library offers the net/mail package to validate an email address format.

This approach is generally better than a simple regex validation because mail.ParseAddress parses an address according to the official RFC 5322 specification, which defines the standard for email format.

Basic Format Validation

Let's start by checking if a string has a valid email format.

Example 1: A Well-Formatted but Non-Existent Email

email := "example-user@exampledomain.com"
_, err := mail.ParseAddress(email)
if err != nil {
    log.Panic(err)
}
Enter fullscreen mode Exit fullscreen mode

This code will run without an error. It passes the test because the string contains a local part (example-user), an @ symbol, and a domain (exampledomain.com). The ParseAddress function only cares about the format, not whether the address is real.

Example 2: An Incorrect Format

email := "example-user"
_, err := mail.ParseAddress(email)
if err != nil {
    log.Panic(err)
}
Enter fullscreen mode Exit fullscreen mode

In this case, the program will panic and show the error: mail: missing '@' in address.

Validating the Domain

Format validation is a good first step, but it doesn't prevent users from signing up with an address like test@nonexistent-domain.com. Our next thought might be to check the domain's DNS records.

A common but flawed approach is to look up the host directly.

import "net"

domain := "exampledomain.com"
_, err := net.LookupHost(domain)
if err != nil {
       log.Panic(err)
}
Enter fullscreen mode Exit fullscreen mode

This check for an A or AAAA record is not what we need for email validation. Many people purchase domains exclusively for email services and never set up a website, so they may not have an A record pointing to a web server. Letting users pass verification based on this check is a bad idea.

The correct approach is to check for MX (Mail Exchanger) records. These DNS records specify the mail servers responsible for accepting email on behalf of a domain. If a domain has MX records, it's configured to receive email.

Looking Up MX Records

First, let's get the domain name from the email address and use net.LookupMX to find the MX records for that domain.

email := "contact@gmail.com"
parts := strings.Split(email, "@")
domain := parts[1] // "gmail.com"

mxRecords, err := net.LookupMX(domain)
if err != nil {
    log.Panicf("Failed to look up MX records for %s: %v", domain, err)
}

if len(mxRecords) == 0 {
    log.Fatalf("No MX records found for %s", domain)
}

fmt.Printf("MX Records for %s:\n", domain)
for _, mx := range mxRecords {
    fmt.Printf("  Host: %s, Preference: %d\n", mx.Host, mx.Pref)
}
Enter fullscreen mode Exit fullscreen mode

For a valid address like contact@gmail.com, you'll see a result similar to this, confirming the domain is set up to receive mail:

MX Records for gmail.com:
  Host: gmail-smtp-in.l.google.com., Preference: 5
  Host: alt1.gmail-smtp-in.l.google.com., Preference: 10
  Host: alt2.gmail-smtp-in.l.google.com., Preference: 20
  Host: alt3.gmail-smtp-in.l.google.com., Preference: 30
  Host: alt4.gmail-smtp-in.l.google.com., Preference: 40
Enter fullscreen mode Exit fullscreen mode

If you run the same code on a domain with no MX records, net.LookupMX will return an error, such as lookup joybtw.com: no such host. This tells us the domain is not configured to receive email, and we should reject the address.

The SMTP Handshake to Verify the Mailbox

Now that we know the domain is configured to receive mail, we can go one step further by asking the server if the specific mailbox (e.g., contact@gmail.com) actually exists. We can do this by performing a partial SMTP handshake.

This process involves connecting to the mail server and simulating the first few steps of sending an email, stopping just before transmitting any actual content. The key is the RCPT TO command, where we ask the server if it will accept a message for our target email address. The server's response tells us whether the user likely exists.

Here’s how it works:

  1. Connect to the Mail Server: We connect to the mail server with the highest priority (lowest preference number) on port 25.
  2. Start the Conversation (EHLO): We greet the server to start the conversation.
  3. Specify the Sender (MAIL FROM): We state the sender's address.
  4. Check the Recipient (RCPT TO): We provide the email address to validate.
    • A response code starting with 250 means "OK," and the address is likely valid.
    • A response code like 550 means the address does not exist.
  5. End the Conversation (QUIT): We properly close the connection without sending an email.

Important Considerations

While powerful, this method isn't foolproof:

  • Catch-All Addresses: Some servers are configured with a "catch-all" policy, meaning they accept emails for any address at that domain to prevent spammers from guessing valid addresses. In this case, the RCPT TO check will always succeed, even for non-existent mailboxes.
  • Security Measures: Mail servers may use security features like greylisting or block automated checks altogether. This can lead to false negatives, where a valid address appears invalid.

Here is the complete code:

Here is a simple, runnable Go program that combines these validation steps.

package main

import (
    "bufio"
    "fmt"
    "log"
    "net"
    "net/mail"
    "strings"
)

func main() {
    email := "contact@gmail.com" // Change this to test other emails

    // Validate Email Format
    _, err := mail.ParseAddress(email)
    if err != nil {
        log.Fatalf("'%s' is not a valid email format: %v", email, err)
    }

    // Extract Domain and Find MX Records
    i := strings.LastIndexByte(email, '@')
    domain := email[i+1:]

    mxRecords, err := net.LookupMX(domain)
    if err != nil {
        log.Fatalf("Could not look up MX records for '%s': %v", domain, err)
    }
    if len(mxRecords) == 0 {
        log.Fatalf("No MX records found for '%s'.", domain)
    }

    // Perform SMTP Handshake
    mxServer := mxRecords[0].Host
    conn, err := net.Dial("tcp", mxServer+":25")
    if err != nil {
        log.Fatalf("Could not connect to SMTP server: %v", err)
    }
    defer conn.Close()

    reader := bufio.NewReader(conn)
    resp, err := reader.ReadString('\n')
    if err != nil {
        log.Fatalf("Could not read from connection: %v", err)
    }
    fmt.Printf("S: %s", resp)

    // EHLO command
    fmt.Fprintf(conn, "EHLO mydomain.com\r\n")
    resp, _ = reader.ReadString('\n')
    fmt.Printf("S: %s", resp)

    // MAIL FROM command
    fmt.Fprintf(conn, "MAIL FROM:<test@mydomain.com>\r\n")
    resp, _ = reader.ReadString('\n')
    fmt.Printf("S: %s", resp)

    // RCPT TO command (The actual check)
    fmt.Fprintf(conn, "RCPT TO:<"+email+">\r\n")
    resp, _ = reader.ReadString('\n')
    fmt.Printf("S: %s", resp)

    // Check the response for the recipient
    if strings.HasPrefix(resp, "250") {
        fmt.Println("Result: Email address is likely valid.")
    } else {
        fmt.Println("Result: Email address is likely invalid.")
    }

    // QUIT
    fmt.Fprintf(conn, "QUIT\r\n")
    resp, _ = reader.ReadString('\n')
    fmt.Printf("S: %s", resp)
}
Enter fullscreen mode Exit fullscreen mode

Note: This code is for educational purposes to demonstrate the concepts of email validation. It is not production-level code. For a real-world application, you would need more robust error handling, connection timeouts, and logic to iterate through multiple MX records if the primary one fails.

Top comments (0)