>  기사  >  백엔드 개발  >  Go에서 나만의 SMTP 서버 구축

Go에서 나만의 SMTP 서버 구축

PHPz
PHPz원래의
2024-07-28 02:01:031059검색

Build Your Own SMTP Server in Go

Valyent에서는 개발자를 위한 오픈 소스 소프트웨어를 구축하고 있습니다.

이 사명의 일환으로 현재 개발자를 위한 이메일 전송 서비스인 Ferdinand를 개발 중입니다(현재 알파 버전).

이메일 인프라는 여러 주요 프로토콜을 사용하며 가장 중요한 프로토콜은 다음과 같습니다.

  1. SMTP(Simple Mail Transfer Protocol): 메일 서버 간 이메일 주고받기에 사용됩니다.
  2. IMAP(인터넷 메시지 액세스 프로토콜): 사용자가 서버에서 직접 이메일을 읽고 관리할 수 있습니다.
  3. POP3(Post Office Protocol 버전 ​​3): 이메일을 서버에서 로컬 장치로 다운로드하며 일반적으로 서버에서 제거합니다.

오늘 기사에서는 Ferdinand에서 취한 접근 방식을 반영하여 자체 아웃바운드 SMTP 서버를 구축하는 데 중점을 둘 예정입니다. 이를 통해 이메일 전송 인프라에서 가장 중요한 구성 요소를 깊이 이해하게 될 것입니다.

"내가 만들 수 없는 것은 이해할 수 없습니다."

— 리차드 파인만

아웃바운드 SMTP 서버를 처음부터 구축하면 대부분의 개발자가 결코 달성하지 못하는 이메일 전송에 대한 통찰력을 얻을 수 있습니다.

계속 진행하려면 Simon Ser의 멋진 메일 라이브러리와 함께 Go 프로그래밍 언어를 사용할 예정입니다. 프로세스를 쉽게 설명하고 이메일을 다른 서버로 보내는 방법을 보여주며 SPF, DKIM, DMARC와 같은 전달 가능성에 대한 주요 개념도 설명합니다.

마지막에는 프로덕션에 즉시 사용 가능한 SMTP 서버가 없더라도 최소한 이메일 인프라에 대해 더 깊이 이해하게 될 것입니다.

SMTP 이해: 기본 사항

코드에 대해 자세히 알아보기 전에 SMTP가 무엇인지, 어떻게 작동하는지 살펴보겠습니다. SMTP(Simple Mail Transfer Protocol)는 인터넷을 통해 이메일을 보내기 위한 표준 프로토콜입니다. 클라이언트-서버 모델에서 작동하는 비교적 간단한 텍스트 기반 프로토콜입니다.

SMTP 명령

SMTP 프로토콜은 명령을 사용합니다. SMTP의 각 명령은 이메일 전송 프로세스에서 특정 목적을 수행합니다. 이를 통해 서버는 자신을 소개하고, 보낸 사람과 받는 사람을 지정하고, 실제 이메일 콘텐츠를 전송하고, 전체 통신 세션을 관리할 수 있습니다. 이러한 명령을 두 이메일 서버 간의 구조화된 대화로 생각하세요. 여기서 각 명령은 해당 대화의 특정 문장이나 질문을 나타냅니다.

SMTP 서버를 구축하면 기본적으로 이 언어를 유창하게 구사하고, 들어오는 명령을 해석하고 적절하게 응답하고, 이메일을 보낼 때 올바른 명령을 내릴 수 있는 프로그램을 만드는 것입니다.

이 대화가 어떻게 진행되는지 알아보기 위해 가장 중요한 SMTP 명령을 살펴보겠습니다.

  • HELO/EHLO(안녕하세요): 이 명령은 SMTP 대화를 시작합니다. EHLO는 확장된 SMTP 버전으로 추가 기능을 지원합니다. 구문은 HELO 도메인 또는 EHLO 도메인입니다. 예: EHLO example.com.
  • MAIL FROM: 이 명령은 보낸 사람의 이메일 주소를 지정하고 새 메일 거래를 시작합니다. MAIL FROM: 구문을 사용합니다. 예를 들면 MAIL FROM:입니다.
  • RCPT TO: 수신자의 이메일 주소를 지정하는 데 사용됩니다. 이 명령은 여러 수신자에게 여러 번 사용할 수 있습니다. 구문은 RCPT TO:입니다. 예: RCPT TO:.
  • DATA: 이 명령은 메시지 내용의 시작을 나타냅니다. 단일 마침표(.)만 포함하는 줄로 끝납니다. DATA 명령 후에 메시지 내용을 입력합니다. 예를 들어:
DATA
From: john@example.com
To: jane@example.com
Subject: Hello

This is the body of the email.
.
  • QUIT: 이 간단한 명령은 SMTP 세션을 종료합니다. 구문은 QUIT입니다.
  • RSET (재설정): RSET 명령은 현재 메일 트랜잭션을 중단하지만 연결은 열린 상태로 유지합니다. 새로운 연결을 시작하지 않고 다시 시작하는 데 유용합니다. 구문은 간단히 RSET입니다.
  • AUTH (인증): 이 명령은 클라이언트를 서버에 인증하는 데 사용되며 다양한 인증 메커니즘을 지원합니다. 구문은 AUTH 메커니즘입니다(예: AUTH LOGIN).

일반적인 SMTP 대화는 다음과 같습니다.

C: EHLO client.example.com
S: 250-smtp.example.com Hello client.example.com
S: 250-SIZE 14680064
S: 250-AUTH LOGIN PLAIN
S: 250 HELP

C: MAIL FROM:<sender@example.com>
S: 250 OK

C: RCPT TO:<recipient@example.com>
S: 250 OK

C: DATA
S: 354 Start mail input; end with <CRLF>.<CRLF>

C: From: sender@example.com
C: To: recipient@example.com
C: Subject: Test Email
C:
C: This is a test email.
C: .

S: 250 OK: queued as 12345

C: QUIT
S: 221 Bye

SMTP에서 인증

인증은 특히 아웃바운드 이메일 서버의 경우 SMTP의 중요한 측면입니다. 서버의 무단 사용을 방지하고 스팸을 줄이는 데 도움이 됩니다. SMTP에는 여러 가지 인증 방법이 사용됩니다.

  1. PLAIN: This is a simple authentication method where the username and password are sent in clear text. It should only be used over encrypted connections.
  2. LOGIN: Similar to PLAIN, but the username and password are sent in separate commands.
  3. CRAM-MD5: This method uses a challenge-response mechanism to avoid sending the password in clear text.
  4. OAUTH2: This method allows the use of OAuth 2.0 tokens for authentication.

Here's an example of how PLAIN authentication looks in an SMTP conversation:

C: EHLO example.com
S: 250-STARTTLS
S: 250 AUTH PLAIN LOGIN
C: AUTH PLAIN AGVtYWlsQGV4YW1wbGUuY29tAHBhc3N3b3Jk
S: 235 2.7.0 Authentication successful

In this example, AGVtYWlsQGV4YW1wbGUuY29tAHBhc3N3b3Jk is the base64-encoded version of \0email@example.com\0password.

When implementing authentication in your SMTP server, you'll need to:

  1. Advertise supported authentication methods in response to the EHLO command.
  2. Implement handlers for the AUTH command that can process the chosen authentication method.
  3. Verify the provided credentials against your user database.
  4. Maintain the authenticated state for the duration of the SMTP session.

Now, let's move on to implementing these concepts in our Go SMTP server.

Achieving deliverability: DKIM, SPF, DMARC

Imagine sending a letter through the postal service without a return address or an official stamp. It might reach its destination, but there's a good chance it'll end up in the "suspicious mail" pile. In the digital world of email, we face a similar challenge.

How do we ensure our emails aren't just sent, but actually delivered and trusted?

Enter the holy trinity of email authentication: DKIM, SPF, and DMARC.

DKIM: Your Email's Digital Signature

DKIM (DomainKeys Identified Mail) is like a wax seal on a medieval letter. It proves the email hasn't been tampered with during transit.

How it works:

  • Your email server adds a digital signature to every outgoing email.
  • The receiving server checks this signature against a public key published in your DNS records.
  • If the signature is valid, the email passes the DKIM check.

Think of it as your email's passport, stamped and verified at each checkpoint.

Example DKIM DNS Record:

<selector>._domainkey.<domain>.<tld>. IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC3QEKyU1fSma0axspqYK5iAj+54lsAg4qRRCnpKK68hawSd8zpsDz77ntGCR0X2mHVvkHbX6dX...oIDAQAB"

Here, 'selector' is a unique identifier for this DKIM key, and the long string is your public key.

SPF: The Guest List for Your Domain's Party

SPF (Sender Policy Framework) is like the bouncer at an exclusive club. It specifies which email servers are allowed to send emails on behalf of your domain.

How it works:

  • You publish a list of authorized IP addresses in your DNS records.
  • When an email arrives claiming to be from your domain, the receiving server checks if it came from an IP on your list.
  • If it matches, the email passes the SPF check.

It's like saying, "If the email didn't come from one of these guys, it's not with us!"

Example SPF DNS Record:

<domain>.<tld>. IN TXT "v=spf1 ip4:192.0.2.0/24 include:_spf.google.com ~all"

This record says:

  • Emails can come from IP addresses in the range 192.0.2.0 to 192.0.2.255.
  • Emails can also come from servers specified in Google's SPF record.
  • The ~all means to soft-fail emails from other sources (treat as suspicious but don't reject).

DMARC: The Rule Maker and Enforcer

DMARC (Domain-based Message Authentication, Reporting & Conformance) is the wise judge that decides what happens to emails that fail DKIM or SPF checks.

How it works:

  • You set a policy in your DNS records specifying how to handle emails that fail authentication.
  • Options range from "let it through anyway" to "reject it outright."
  • DMARC also provides reports on email authentication results, helping you monitor and improve your email security.

Think of DMARC as your email bouncer's rulebook and incident report.

Example DMARC DNS Record:

_dmarc.<domain>.<tld>. IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@<domain>.<tld>"

This record says:

  • If an email fails DKIM and SPF checks, quarantine it (typically send to spam folder).
  • Send aggregate reports about email authentication results to dmarc-reports@example.com.

Why This Trinity Matters

Together, DKIM, SPF, and DMARC form a powerful shield against email spoofing and phishing. They tell receiving servers, "This email is really from us, sent by someone we trust, and here's what to do if something seems fishy."

Implementing this trinity not only improves your email deliverability but also protects your domain's reputation. It's like having a state-of-the-art security system for your email infrastructure.

As we build our SMTP server, keeping these authentication methods in mind will be crucial for ensuring our emails don't just get sent, but actually reach their destination and are trusted when they arrive. Remember, when implementing these records on a production domain, start with permissive policies and gradually tighten them as you confirm everything is working correctly.

Building the SMTP Server with Go

1. Project Initialization

First, let's create a new directory for our project and initialize a Go module:

mkdir go-smtp-server
cd go-smtp-server
go mod init github.com/yourusername/go-smtp-server

2. Installing Dependencies

We'll need a few dependencies for our SMTP server. Run the following commands:

go get github.com/emersion/go-smtp
go get github.com/emersion/go-sasl
go get github.com/emersion/go-msgauth

3. Basic SMTP Server Setup

  1. Create a new file named main.go and add the following code:
package main

import (
    "log"
    "time"
    "io"

    "github.com/emersion/go-smtp"
)

func main() {
    s := smtp.NewServer(&Backend{})

    s.Addr = ":2525"
    s.Domain = "localhost"
    s.WriteTimeout = 10 * time.Second
    s.ReadTimeout = 10 * time.Second
    s.MaxMessageBytes = 1024 * 1024
    s.MaxRecipients = 50
    s.AllowInsecureAuth = true

    log.Println("Starting server at", s.Addr)
    if err := s.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

// Backend implements SMTP server methods.
type Backend struct{}

func (bkd *Backend) NewSession(_ *smtp.Conn) (smtp.Session, error) {
    return &Session{}, nil
}

// A Session is returned after EHLO.
type Session struct{}

// We'll implement the Session methods next

This creates an SMTP server, listening on the 2525 port, a convenient choice for development purposes, since this port doesn’t require administrative privileges, unlike the standard ports 25 (standard SMTP), 465 (TLS), 587 (STARTTLS).

  1. Implementing EHLO/HELO

The EHLO/HELO command is handled automatically by the go-smtp library. We don't need to implement it ourselves.

  1. Implementing MAIL FROM

Add this method to the Session struct:

func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
    fmt.Println("Mail from:", from) s.From = from
    return nil
}

This method is called when the server receives a MAIL FROM command. It logs the sender's address and stores it in the session.

  1. Implementing RCPT TO

Add this method to the Session struct:

func (s *Session) Rcpt(to string) error {
    fmt.Println("Rcpt to:", to)
    s.To = append(s.To, to)
    return nil
}

This method is called for each RCPT TO command. It logs the recipient's address and adds it to the list of recipients for this session.

  1. Implementing DATA

Add this method to the Session struct:

import (
    "fmt"
    "io"
)

func (s *Session) Data(r io.Reader) error {
    if b, err := io.ReadAll(r); err != nil {
        return err
    } else {
        fmt.Println("Received message:", string(b))

        // Here you would typically process the email
        return nil
    }
}

This method is called when the server receives the DATA command. It reads the entire email message and logs it. In a real server, you would process the email here.

  1. Implementing AUTH

Add this method to the Session struct:

func (s *Session) AuthPlain(username, password string) error {
    if username != "testuser" || password != "testpass" {
        return fmt.Errorf("Invalid username or password")
    }

    return nil
}

This implements a basic authentication mechanism. Note that this is for demonstration purposes only and should not be used in production.

  1. Implementing RSET

Add this method to the Session struct:

func (s *Session) Reset() {
    s.From = "" s.To = []string{}
}

This method is called when the server receives a RSET command. It resets the session state.

  1. Implementing QUIT

Add this method to the Session struct:

func (s *Session) Logout() error {
    return nil
}

This method is called when the server receives a QUIT command. In this simple implementation, we don't need to do anything special.

  1. Sending Emails: MX Lookup, Port Selection, and DKIM Signing

Once we've received and processed an email, the next step is to send it to its destination. This involves two key steps: finding the recipient's mail server using MX (Mail Exchanger) records, and attempting to send the email using standard SMTP ports.

First, let's add a function to look up MX records:

import "net"

func lookupMX(domain string) ([]*net.MX, error) {
    mxRecords, err := net.LookupMX(domain)
    if err != nil {
        return nil, fmt.Errorf("Error looking up MX records: %v", err)
    }

    return mxRecords, nil
}

Next, let's create a function that attempts to send an email using different ports:

import (
    "crypto/tls"
    "net/smtp"
    "strings"
)

func sendMail(from string, to string, data []byte) error {
    domain := strings.Split(to, "@")[1]

    mxRecords, err := lookupMX(domain)
    if err != nil {
        return err
    }

    for _, mx := range mxRecords {
        host := mx.Host

        for _, port := range []int{25, 587, 465} {
            address := fmt.Sprintf("%s:%d", host, port)

            var c *smtp.Client

            var err error

            switch port {
            case 465:
                // SMTPS
                tlsConfig := &tls.Config{ServerName: host}
                conn, err := tls.Dial("tcp", address, tlsConfig)
                if err != nil {
                    continue
                }

                c, err = smtp.NewClient(conn, host)

            case 25, 587:
                // SMTP or SMTP with STARTTLS
                c, err = smtp.Dial(address)
                if err != nil {
                    continue
                }

                if port == 587 {
                    if err = c.StartTLS(&tls.Config{ServerName: host}); err != nil {
                        c.Close()
                        continue
                    }
                }
            }

            if err != nil {
                continue
            }

            // SMTP conversation
            if err = c.Mail(from); err != nil {
                c.Close()
                continue
            }

            if err = c.Rcpt(to); err != nil {
                c.Close()
                continue
            }

            w, err := c.Data()
            if err != nil {
                c.Close()
                continue
            }

            if _, err := w.Write(data); err != nil {
                c.Close()
                continue
            }

            err = w.Close()
            if err != nil {
                c.Close()
                continue
            }

            c.Quit()

            return nil
        }
    }

    return fmt.Errorf("Failed to send email to %s", to)
}

This function does the following:

  • Looks up the MX records for the recipient's domain.
  • For each MX record, it tries to connect using ports 25, 587, and 465 in that order.
  • It uses the appropriate connection method for each port:
    • Port 25: Plain SMTP
    • Port 587: SMTP with STARTTLS
    • Port 465: SMTPS (SMTP over TLS)
  • If a connection is successful, it attempts to send the email using the SMTP protocol.
  • If the email is sent successfully, it returns. Otherwise, it tries the next port or MX record.

Now, let's modify our Data method in the Session struct to use this new sendMail function:

func (s *Session) Data(r io.Reader) error {
    if data, err := io.ReadAll(r); err != nil {
        return err
    } else {
        fmt.Println("Received message:", string(data))
        for _, recipient := range s.To {
            if err := sendMail(s.From, recipient, data); err != nil {
                fmt.Printf("Failed to send email to %s: %v", recipient, err)
            } else {
                fmt.Printf("Email sent successfully to %s", recipient)
            }

        }

        return nil
    }
}

This implementation will attempt to send the received email to each recipient using the appropriate mail server and port.

Now, let's add DKIM signing to our email sending process. First, we need to import the necessary packages and set up our DKIM options:

import (
    // ... other imports ...
    "crypto/rsa"
    "crypto/x509"
    "encoding/pem"
    "github.com/emersion/go-msgauth/dkim"
)

// Load your DKIM private key
var dkimPrivateKey *rsa.PrivateKey

func init() {
    // Load your DKIM private key from a file
    privateKeyPEM, err := ioutil.ReadFile("path/to/your/private_key.pem")
    if err != nil {
        log.Fatalf("Failed to read private key: %v", err)
    }

    block, _ := pem.Decode(privateKeyPEM)
    if block == nil {
        log.Fatalf("Failed to parse PEM block containing the private key")
    }

    privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
    if err != nil {
        log.Fatalf("Failed to parse private key: %v", err)
    }

    dkimPrivateKey = privateKey
}

// DKIM options
var dkimOptions = &dkim.SignOptions{
    Domain: "example.com",
    Selector: "default",
    Signer: dkimPrivateKey,
}

Next, let's modify our sendMail function to include DKIM signing:

func sendMail(from string, to string, data []byte) error {
    // ... [previous MX lookup code] ...

    for _, mx := range mxRecords {
        host := mx.Host
        for _, port := range []int{25, 587, 465} {
            // ... [previous connection code] ...

            // DKIM sign the message
            var b bytes.Buffer
            if err := dkim.Sign(&b, bytes.NewReader(data), dkimOptions); err != nil {
                return fmt.Errorf("Failed to sign email with DKIM: %v", err)
            }
            signedData := b.Bytes()

            // SMTP conversation
            if err = c.Mail(from); err != nil {
                c.Close()
                continue
            }
            if err = c.Rcpt(to); err != nil {
                c.Close()
                continue
            }
            w, err := c.Data()
            if err != nil {
                c.Close()
                continue
            }
            _, err = w.Write(signedData) // Use the DKIM signed message
            if err != nil {
                c.Close()
                continue
            }
            err = w.Close()
            if err != nil {
                c.Close()
                continue
            }
            c.Quit()
            return nil
        }
    }

    return fmt.Errorf("Failed to send email to %s", to)
}

In this updated sendMail function:

  • We sign the email data with DKIM before sending it.
  • We use the signed data (signedData) when writing to the SMTP connection.

This implementation will add a DKIM signature to your outgoing emails, which will help improve deliverability and authenticity of your emails.

"path/to/your/private_key.pem"을 DKIM 개인 키의 실제 경로로 바꾸고, DKIM DNS 레코드와 일치하도록 dkimOptions의 도메인 및 선택기를 업데이트하세요.

  1. 고려사항 및 다음 단계

이 구현은 이메일을 주고받을 수 있는 기본적으로 작동하는 SMTP 서버를 제공하지만 프로덕션 준비 서버에 대한 몇 가지 중요한 고려 사항이 있습니다.

  • 속도 제한: 남용을 방지하고 이메일 폭탄 공격을 방지하기 위해 속도 제한을 구현합니다.
  • 스팸 방지: 서버가 스팸 발송에 사용되는 것을 방지하기 위한 조치를 구현하세요.
  • 오류 처리: 더 나은 디버깅 및 모니터링을 위해 오류 처리 및 로깅을 개선합니다.
  • 대기열 관리: 이메일 전송 실패 시 재시도 논리를 위한 대기열 시스템을 구현합니다.

결론

이 게시물을 읽으면서 많은 것을 배웠기를 바랍니다. 이메일 보내기에 대해 자세히 알아보려면 Ferdinand의 GitHub 저장소를 살펴보고 코드를 살펴보세요.

위 내용은 Go에서 나만의 SMTP 서버 구축의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.