在 Valyent,我們正在為開發人員建立開源軟體。
作為此使命的一部分,我們目前正在開發 Ferdinand,這是我們為開發者提供的電子郵件發送服務(目前處於 alpha 階段)。
電子郵件基礎設施依賴幾個關鍵協議,其中最重要的是:
在今天的文章中,我們將專注於建立我們自己的出站 SMTP 伺服器,反映我們對 Ferdinand 採取的方法。透過這樣做,我們將深入了解電子郵件發送基礎架構中最關鍵的元件。
「我無法創造的東西,我就不明白。」
— 理查德·費曼
從頭開始建立出站 SMTP 伺服器,您可以深入了解大多數開發人員無法實現的電子郵件傳送。
為了繼續,我們將使用 Go 程式語言以及 Simon Ser 提供的出色的郵件庫。我們將揭開這個過程的神秘面紗,向您展示如何將電子郵件發送到其他伺服器,甚至解釋 SPF、DKIM 和 DMARC 等關鍵概念以實現可交付性。
到最後,儘管沒有可用於生產的 SMTP 伺服器,您至少會對電子郵件基礎設施有更深入的了解。
在深入研究程式碼之前,我們先回顧一下 SMTP 是什麼以及它是如何運作的。 SMTP(簡單郵件傳輸協定)是透過 Internet 傳送電子郵件的標準協定。這是一個相對簡單的基於文字的協議,在客戶端-伺服器模型上運行。
SMTP 協定使用指令。 SMTP 中的每個命令在電子郵件傳輸過程中都有特定的用途。它們允許伺服器自我介紹、指定寄件者和收件者、傳輸實際的電子郵件內容以及管理整個通訊會話。將這些命令視為兩個電子郵件伺服器之間的結構化對話,其中每個命令代表該對話中的特定陳述或問題。
當您建立 SMTP 伺服器時,您實際上是在創建一個可以流利地說這種語言的程序,解釋傳入的命令並做出適當的回應,以及在發送電子郵件時發出正確的命令。
讓我們來探索最重要的 SMTP 指令,看看這個對話是如何展開的:
DATA From: john@example.com To: jane@example.com Subject: Hello This is the body of the email. .
典型的 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 中使用了幾種身份驗證方法:
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:
Now, let's move on to implementing these concepts in our Go SMTP server.
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 (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:
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 (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:
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:
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:
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:
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.
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
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
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).
The EHLO/HELO command is handled automatically by the go-smtp library. We don't need to implement it ourselves.
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.
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.
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.
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.
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.
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.
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:
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:
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 私鑰的實際路徑,並更新 dkimOptions 中的網域和選擇器以符合您的 DKIM DNS 記錄。
雖然此實作提供了一個能夠接收和發送電子郵件的基本工作 SMTP 伺服器,但對於生產就緒伺服器有幾個重要的考慮因素:
我們希望您透過閱讀這篇文章學到了很多東西。要了解有關發送電子郵件的更多信息,請隨時查看 Ferdinand 的 GitHub 存儲庫,並探索程式碼。
以上是在 Go 中建立您自己的 SMTP 伺服器的詳細內容。更多資訊請關注PHP中文網其他相關文章!