net/email
Sending email over SMTP and reading it over IMAP and POP3.
import net.email
This package speaks the three mail protocols directly over net/tcp and tls: SMTP to send, POP3 and IMAP to read. Use it for password reset mails, alerts and reports from a server, or a script that reads a support inbox. It is a protocol client, not a mail library: it builds simple text and HTML messages, but it has no attachments and no MIME parser, so a fetched message comes back as raw lines.
The pieces:
BuildMessagewrites the message text: the headers plus a text body, an HTML body, or both asmultipart/alternative.Senddoes a whole SMTP delivery to one recipient in one call. For several recipients, Bcc or a useful error message, drive anSMTPClientstep by step; every step returns true or false and leaves the server's answer inlast_reply.- POP3 (
POP3Client) downloads messages and can delete them; IMAP (IMAPClient) leaves them on the server and lets you search, so it is the one to use for a shared mailbox.
Things people get wrong:
Sendalways encrypts. Withuse_tlstrue it uses TLS from the first byte (port 465); with false it upgrades with STARTTLS (port 587) and fails if the server cannot. It logs in with AUTH PLAIN when a user name is given.BuildMessagewrites only From, To, Subject and the content headers. It adds noDateorMessage-IDand does not encode a non-ASCII subject; see the first guide. Write line breaks in bodies as\r\n: bare\nis passed through, and some servers reject it.- IMAP
Loginputs the user name and password in double quotes without escaping. A password that contains"or\fails; useCommandwith an IMAP literal orLoginXOAuth2for those. Fetchreturns the reply lines only, not whether the command worked; callCommandwhen you need the status.
The protocol examples talk to a tiny fake server in a thread of the same program, so you can run them offline and see every command that goes over the wire.
What BuildMessage writes
BuildMessage(from, to, subject, text, html) returns the full message text that SMTP sends. With only a text body it is a plain text/plain message, with only HTML it is text/html, and with both it becomes multipart/alternative so that each mail program shows the version it prefers. Bodies are UTF-8, so any language works in the body.
import net.email
import str
import encoding
func show(message: str):
each line in str.Split(message, "\r\n"):
println line
end
println "-----"
end
func main:
text := email.BuildMessage("shop@example.com", "sara@example.com", "Your code", "Your login code is 4821.", "")
show(text)
html := email.BuildMessage("shop@example.com", "sara@example.com", "Welcome", "", "<h1>Welcome, Sara</h1>")
show(html)
both := email.BuildMessage("shop@example.com", "sara@example.com", "News", "Plain version", "<p>HTML version</p>")
parts := str.Split(both, "Content-Type: ")
each i, part in parts:
if i > 0: println "Content-Type", i, "is", str.Split(part, ";")[0] end
end
subject := "=?UTF-8?B?" + encoding.Base64Encode("Café menu") + "?="
println "encoded subject:", subject
endThe subject is written as it is. Plain ASCII subjects are always safe; for a subject in another script, encode it the way mail headers expect, =?UTF-8?B? plus the Base64 of the text plus ?=, as the last lines do, and pass that string as the subject. To add a Date or Message-ID header, put the line in front of what BuildMessage returns, for example "Date: " + time.FormatHTTPDate(time.Now()) + "\r\n" + message; mail without a date is more likely to be marked as spam.
An SMTP session step by step
Send does everything in one call, but driving the client yourself lets you add several recipients, use another login method, or see where a server says no. The steps are always the same: DialSMTP (or DialSMTPTLS), Hello, an Auth... call on a real server, MailFrom, one RcptTo per recipient, Data with the message, and Quit. Each step returns true or false and leaves the server's answer in last_reply.
import net.email
import net.tcp
mut server := tcp.Server {}
mut seen := Vector {} as Vector<str>
func next_line(conn: tcp.Conn, buf &: str): str:
until buf.find("\r\n") < 0:
buf += tcp.Read(conn, 4096)
end
at := buf.find("\r\n")
line := buf.substr(0, at)
buf = buf.substr(at + 2, buf.len() - at - 2)
ret line
end
func mail_server():
conn := tcp.Accept(server)
mut buf := ""
_ := tcp.Write(conn, "220 mail.test ready\r\n")
mut open := true
until open:
line := next_line(conn, buf)
seen.push(line)
if line == "DATA":
_ := tcp.Write(conn, "354 end with a dot\r\n")
mut body := next_line(conn, buf)
until body != ".":
body = next_line(conn, buf)
end
_ := tcp.Write(conn, "250 queued as 1001\r\n")
else line == "QUIT":
_ := tcp.Write(conn, "221 bye\r\n")
open = false
else:
_ := tcp.Write(conn, "250 ok\r\n")
end
end
tcp.Close(conn)
end
func main:
server = tcp.Bind(0)
t := spawn(mail_server)
mut c := email.DialSMTP("127.0.0.1", tcp.Port(server))
println "connected:", c.ok
_ := email.Hello(c, "shop.test")
_ := email.MailFrom(c, "orders@shop.test")
each rcpt in ["sara@example.com", "reza@example.com"]:
_ := email.RcptTo(c, rcpt)
end
message := email.BuildMessage("orders@shop.test", "sara@example.com, reza@example.com", "Order 1001", "Your order has shipped.", "")
println "accepted:", email.Data(c, message), c.last_reply.trim()
email.Quit(c)
join(t)
tcp.CloseServer(server)
println "the server saw:"
each line in seen:
println " ", line
end
endThe To header in the message and the RcptTo calls are separate: the header is what people see, the RcptTo list is where the mail is delivered. A Bcc recipient is simply one who gets a RcptTo but is not in the header.
Send with use_tls false still switches to TLS with STARTTLS before it logs in, so it cannot talk to a local test server without TLS such as MailHog. For those, use the steps above without StartTLS.
Reading a mailbox with POP3
POP3 is the simple way to fetch mail: log in with User and Pass, ask for the count and size with Stat, list the messages with POP3List, fetch one with Retr, and mark it for deletion with Dele. Deletions happen when POP3Quit ends the session.
import net.email
import net.tcp
import str
mut server := tcp.Server {}
func next_line(conn: tcp.Conn, buf &: str): str:
until buf.find("\r\n") < 0:
buf += tcp.Read(conn, 4096)
end
at := buf.find("\r\n")
line := buf.substr(0, at)
buf = buf.substr(at + 2, buf.len() - at - 2)
ret line
end
func pop_server():
mails := ["Subject: Welcome\r\n\r\nHello Sara.", "Subject: Invoice 12\r\n\r\nTotal: 40 USD\r\n..end of invoice"]
conn := tcp.Accept(server)
mut buf := ""
_ := tcp.Write(conn, "+OK ready\r\n")
mut line := next_line(conn, buf)
until line != "QUIT":
if line == "STAT":
_ := tcp.Write(conn, "+OK 2 " + (mails[0].len() + mails[1].len()) + "\r\n")
else line == "LIST":
_ := tcp.Write(conn, "+OK\r\n1 " + mails[0].len() + "\r\n2 " + mails[1].len() + "\r\n.\r\n")
else str.StartsWith(line, "RETR "):
n := line.substr(5, line.len() - 5).to_int()
_ := tcp.Write(conn, "+OK\r\n" + mails[n - 1] + "\r\n.\r\n")
else:
_ := tcp.Write(conn, "+OK\r\n")
end
line = next_line(conn, buf)
end
_ := tcp.Write(conn, "+OK bye\r\n")
tcp.Close(conn)
end
func main:
server = tcp.Bind(0)
t := spawn(pop_server)
mut c := email.DialPOP3("127.0.0.1", tcp.Port(server))
if not (c.ok and email.User(c, "sara") and email.Pass(c, "secret")):
println "login failed"
ret 1
end
println "stat:", email.Stat(c)
each entry in email.POP3List(c):
parts := str.Split(entry, " ")
number := parts[0].to_int()
text := email.Retr(c, number)
subject := str.Split(text, "\r\n")[0]
println "message", number, "(" + parts[1] + " bytes):", subject
end
println "last line of 2:", str.Split(email.Retr(c, 2), "\r\n")[3]
println "delete 1:", email.Dele(c, 1)
email.POP3Quit(c)
join(t)
tcp.CloseServer(server)
ret 0
endIn POP3 a line of the message that starts with a dot is sent with an extra dot. Retr removes it again, which is why the stored ..end of invoice comes back as .end of invoice.
Finding unread mail with IMAP
IMAP keeps the mail on the server and lets you search it. After Login, Command sends any IMAP command and returns its lines and final status, and Fetch asks for parts of chosen messages. The usual pattern for new mail is SELECT a mailbox, SEARCH UNSEEN for the numbers of unread messages, then Fetch what you need of them.
import net.email
import net.tcp
import str
mut server := tcp.Server {}
func next_line(conn: tcp.Conn, buf &: str): str:
until buf.find("\r\n") < 0:
buf += tcp.Read(conn, 4096)
end
at := buf.find("\r\n")
line := buf.substr(0, at)
buf = buf.substr(at + 2, buf.len() - at - 2)
ret line
end
func imap_server():
conn := tcp.Accept(server)
mut buf := ""
_ := tcp.Write(conn, "* OK IMAP ready\r\n")
mut done := false
until not done:
line := next_line(conn, buf)
tag := str.Split(line, " ")[0]
if str.Contains(line, " SELECT "):
_ := tcp.Write(conn, "* 3 EXISTS\r\n* 2 RECENT\r\n")
else str.Contains(line, " SEARCH "):
_ := tcp.Write(conn, "* SEARCH 2 3\r\n")
else str.Contains(line, " FETCH "):
_ := tcp.Write(conn, "* 2 FETCH (FLAGS (\\Recent))\r\n* 3 FETCH (FLAGS (\\Recent \\Flagged))\r\n")
else str.Contains(line, " LOGOUT"):
_ := tcp.Write(conn, "* BYE\r\n")
done = true
end
_ := tcp.Write(conn, tag + " OK done\r\n")
end
tcp.Close(conn)
end
func main:
server = tcp.Bind(0)
t := spawn(imap_server)
mut c := email.DialIMAP("127.0.0.1", tcp.Port(server))
println "logged in:", c.ok and email.Login(c, "sara", "secret")
selected := email.Command(c, "SELECT \"INBOX\"")
each line in selected.lines:
if str.EndsWith(line, " EXISTS"): println "inbox:", line end
end
unread := email.Command(c, "SEARCH UNSEEN")
println "unread:", unread.lines[0], "status:", unread.status
each line in email.Fetch(c, "2:3", "FLAGS"):
println " ", line
end
email.Logout(c)
join(t)
tcp.CloseServer(server)
endFetch puts the parentheses around the item list itself, so pass FLAGS or BODY[HEADER], not (FLAGS).
Reading subjects and senders with IMAP
To list a mailbox without downloading whole messages, fetch only the header fields you need with BODY.PEEK[HEADER.FIELDS (SUBJECT FROM)]; PEEK keeps the messages unread. The server sends each header block as an IMAP literal ({57} followed by that many bytes), and Fetch returns it split into lines: the * 2 FETCH line, the header lines, the empty line that ends the headers and a closing ). The program walks those lines and prints one row per message.
import net.email
import net.tcp
import str
mut server := tcp.Server {}
func next_line(conn: tcp.Conn, buf &: str): str:
until buf.find("\r\n") < 0:
buf += tcp.Read(conn, 4096)
end
at := buf.find("\r\n")
line := buf.substr(0, at)
buf = buf.substr(at + 2, buf.len() - at - 2)
ret line
end
func imap_server():
conn := tcp.Accept(server)
mut buf := ""
_ := tcp.Write(conn, "* OK IMAP ready\r\n")
mut done := false
until not done:
line := next_line(conn, buf)
tag := str.Split(line, " ")[0]
if str.Contains(line, " FETCH "):
first := "Subject: Invoice 12\r\nFrom: billing@shop.test\r\n\r\n"
second := "Subject: Lunch?\r\nFrom: reza@example.com\r\n\r\n"
_ := tcp.Write(conn, "* 2 FETCH (BODY[HEADER.FIELDS (SUBJECT FROM)] {" + first.len() + "}\r\n" + first + ")\r\n")
_ := tcp.Write(conn, "* 3 FETCH (BODY[HEADER.FIELDS (SUBJECT FROM)] {" + second.len() + "}\r\n" + second + ")\r\n")
else str.Contains(line, " LOGOUT"):
_ := tcp.Write(conn, "* BYE\r\n")
done = true
end
_ := tcp.Write(conn, tag + " OK done\r\n")
end
tcp.Close(conn)
end
func main:
server = tcp.Bind(0)
t := spawn(imap_server)
mut c := email.DialIMAP("127.0.0.1", tcp.Port(server))
_ := email.Login(c, "sara", "secret") and email.Select(c, "INBOX")
lines := email.Fetch(c, "2:3", "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM)]")
println "lines received:", lines.len()
mut subject := ""
mut from := ""
each line in lines:
if str.StartsWith(line, "Subject: "):
subject = line.substr(9, line.len() - 9)
else str.StartsWith(line, "From: "):
from = line.substr(6, line.len() - 6)
else line == ")":
println from, "wrote:", subject
end
end
email.Logout(c)
join(t)
tcp.CloseServer(server)
endHeader values can be folded over several lines and encoded as =?UTF-8?B?...?=; the lines come back exactly as the server sent them, so decode them yourself if your mail is not plain ASCII.
Sending through Gmail
Gmail, like most providers, accepts only encrypted connections and does not take your normal password. Turn on two step verification and create an app password, or use an OAuth token with AuthXOAuth2. Port 465 with use_tls true connects with TLS from the start. The program reads the address and secrets from environment variables so they stay out of the source:
MAIL_USER=you@gmail.com MAIL_APP_PASSWORD='abcd efgh ijkl mnop' salam run net-email-gmail.salam
import net.email
import os
func main:
user := os.Env("MAIL_USER")
password := os.Env("MAIL_APP_PASSWORD")
sent := email.Send("smtp.gmail.com", 465, true, user, password, user, "sara@example.com", "Weekly report", "The report is ready.", "<p>The <b>report</b> is ready.</p>")
println "sent with a password:", sent
mut c := email.DialSMTPTLS("smtp.gmail.com", 465)
if not (c.ok and email.Hello(c, "example.com")):
println "could not connect:", c.last_reply
ret 1
end
if not email.AuthXOAuth2(c, user, os.Env("MAIL_OAUTH_TOKEN")):
println "login refused:", c.last_reply
email.Quit(c)
ret 1
end
ok := email.MailFrom(c, user) and email.RcptTo(c, "sara@example.com") and email.Data(c, email.BuildMessage(user, "sara@example.com", "Hello", "Sent with an OAuth token.", ""))
println "sent with a token:", ok
email.Quit(c)
ret 0
endTypes
POP3Client
struct POP3Client| Name | Type | Description |
|---|---|---|
conn | tcp.Conn | The connection. |
secure | bool = false | It is encrypted. |
tls_idx | int = -1 | The internal TLS index. |
ok | bool = false | The connection is up. |
rxbuf | str = "" | The data not yet read. |
import net.email
func main:
mut smtp := email.DialSMTPTLS("smtp.example.com", 465)
if smtp.ok and email.Hello(smtp, "example.com") and email.AuthLogin(smtp, "ali", "secret"):
_ := email.MailFrom(smtp, "ali@example.com")
_ := email.RcptTo(smtp, "sara@example.com")
_ := email.Data(smtp, email.BuildMessage("ali@example.com", "sara@example.com", "hello", "some text", ""))
end
email.Quit(smtp)
mut smtp2 := email.DialSMTP("smtp.example.com", 587)
_ := email.StartTLS(smtp2, "smtp.example.com")
_ := email.AuthPlain(smtp2, "ali", "secret")
_ := email.AuthXOAuth2(smtp2, "ali", "token")
println smtp2.last_reply
_ := email.Send("smtp.example.com", 465, true, "ali", "secret", "ali@example.com", "sara@example.com", "hello", "some text", "")
mut imap := email.DialIMAPTLS("imap.example.com", 993)
_ := email.Login(imap, "ali", "secret")
_ := email.LoginXOAuth2(imap, "ali", "token")
println email.List(imap, "", "*")
_ := email.Select(imap, "INBOX")
println email.Fetch(imap, "1:5", "(FLAGS BODY[HEADER.FIELDS (SUBJECT)])")
reply := email.Command(imap, "NOOP")
println reply.ok, reply.status, reply.lines.len()
email.Logout(imap)
_ := email.DialIMAP("imap.example.com", 143)
mut pop := email.DialPOP3TLS("pop.example.com", 995)
_ := email.User(pop, "ali")
_ := email.Pass(pop, "secret")
println email.Stat(pop), email.POP3List(pop)
println email.Retr(pop, 1)
_ := email.Dele(pop, 1)
email.POP3Quit(pop)
_ := email.DialPOP3("pop.example.com", 110)
endSMTPClient
struct SMTPClient| Name | Type | Description |
|---|---|---|
conn | tcp.Conn | The connection. |
secure | bool = false | It is encrypted. |
tls_idx | int = -1 | The internal TLS index. |
ok | bool = false | The connection is up. |
rxbuf | str = "" | The data not yet read. |
last_reply | str = "" | The last reply of the server; for debugging. |
The example for this item is shown with the ones above: net-email-Clients.salam
IMAPClient
struct IMAPClient| Name | Type | Description |
|---|---|---|
conn | tcp.Conn | The connection. |
secure | bool = false | It is encrypted. |
tls_idx | int = -1 | The internal TLS index. |
ok | bool = false | The connection is up. |
rxbuf | str = "" | The data not yet read. |
tag_counter | int = 0 | The command tag counter. |
The example for this item is shown with the ones above: net-email-Clients.salam
IMAPReply
struct IMAPReply| Name | Type | Description |
|---|---|---|
ok | bool = false | The reply was OK. |
status | str = "" | The closing status line. |
lines | Vector | The lines of the reply. |
The example for this item is shown with the ones above: net-email-Clients.salam
Functions
DialPOP3
func DialPOP3(host: str, port: int): POP3ClientThe example for this item is shown with the ones above: net-email-Clients.salam
DialPOP3TLS
func DialPOP3TLS(host: str, port: int): POP3ClientThe example for this item is shown with the ones above: net-email-Clients.salam
User
func User(c &: POP3Client, username: str): boolThe example for this item is shown with the ones above: net-email-Clients.salam
Pass
func Pass(c &: POP3Client, password: str): boolThe example for this item is shown with the ones above: net-email-Clients.salam
Stat
func Stat(c &: POP3Client): str+OK reply with the number of messages and their total size in bytes.The example for this item is shown with the ones above: net-email-Clients.salam
POP3List
func POP3List(c &: POP3Client): Vector<str>The example for this item is shown with the ones above: net-email-Clients.salam
Retr
func Retr(c &: POP3Client, msgnum: int): strThe example for this item is shown with the ones above: net-email-Clients.salam
Dele
func Dele(c &: POP3Client, msgnum: int): boolThe example for this item is shown with the ones above: net-email-Clients.salam
POP3Quit
func POP3Quit(c &: POP3Client)The example for this item is shown with the ones above: net-email-Clients.salam
DialSMTP
func DialSMTP(host: str, port: int): SMTPClientok is true when the server greets with 220.The example for this item is shown with the ones above: net-email-Clients.salam
DialSMTPTLS
func DialSMTPTLS(host: str, port: int): SMTPClientThe example for this item is shown with the ones above: net-email-Clients.salam
Hello
func Hello(c &: SMTPClient, domain: str): boolThe example for this item is shown with the ones above: net-email-Clients.salam
StartTLS
func StartTLS(c &: SMTPClient, host: str): boolThe example for this item is shown with the ones above: net-email-Clients.salam
AuthLogin
func AuthLogin(c &: SMTPClient, username: str, password: str): boolThe example for this item is shown with the ones above: net-email-Clients.salam
AuthPlain
func AuthPlain(c &: SMTPClient, username: str, password: str): boolThe example for this item is shown with the ones above: net-email-Clients.salam
AuthXOAuth2
func AuthXOAuth2(c &: SMTPClient, username: str, access_token: str): boolThe example for this item is shown with the ones above: net-email-Clients.salam
MailFrom
func MailFrom(c &: SMTPClient, addr: str): boolThe example for this item is shown with the ones above: net-email-Clients.salam
RcptTo
func RcptTo(c &: SMTPClient, addr: str): boolTo header, decides who gets the mail.The example for this item is shown with the ones above: net-email-Clients.salam
Data
func Data(c &: SMTPClient, raw_message: str): boolThe example for this item is shown with the ones above: net-email-Clients.salam
Quit
func Quit(c &: SMTPClient)The example for this item is shown with the ones above: net-email-Clients.salam
BuildMessage
func BuildMessage(from: str, to_addr: str, subject: str, text_body: str, html_body: str): strmultipart/alternative so every mail program can show the version that suits it.import net.email
import str
func main:
message := email.BuildMessage("ali@example.com", "sara@example.com", "hello", "plain text", "<p>some <b>nice</b> text</p>")
each line in str.Split(message, "\r\n"):
if str.StartsWith(line, "From:") or str.StartsWith(line, "To:") or str.StartsWith(line, "MIME-Version:") or str.StartsWith(line, "Subject:"):
println line
end
end
println str.Contains(message, "multipart/alternative")
println email.BuildXOAuth2("ali@example.com", "ya29.token")
endSend
func Send(host: str, port: int, use_tls: bool, username: str, password: str, from: str, to_addr: str, subject: str, text_body: str, html_body: str): boolBuildMessage writes. When use_tls is true it connects encrypted from the start; otherwise it must be able to upgrade with STARTTLS. Returns only true or false.The example for this item is shown with the ones above: net-email-Clients.salam
BuildXOAuth2
func BuildXOAuth2(username: str, access_token: str): strThe example for this item is shown with the ones above: net-email-Message.salam
DialIMAP
func DialIMAP(host: str, port: int): IMAPClientThe example for this item is shown with the ones above: net-email-Clients.salam
DialIMAPTLS
func DialIMAPTLS(host: str, port: int): IMAPClientThe example for this item is shown with the ones above: net-email-Clients.salam
Command
func Command(c &: IMAPClient, cmd: str): IMAPReplyThe example for this item is shown with the ones above: net-email-Clients.salam
Login
func Login(c &: IMAPClient, username: str, password: str): boolThe example for this item is shown with the ones above: net-email-Clients.salam
LoginXOAuth2
func LoginXOAuth2(c &: IMAPClient, username: str, access_token: str): boolThe example for this item is shown with the ones above: net-email-Clients.salam
Select
func Select(c &: IMAPClient, mailbox: str): boolThe example for this item is shown with the ones above: net-email-Clients.salam
List
func List(c &: IMAPClient, reference: str, pattern: str): Vector<str>* LIST lines for the mailboxes that match a reference and a pattern, such as "" and "*".The example for this item is shown with the ones above: net-email-Clients.salam
Fetch
func Fetch(c &: IMAPClient, seq: str, items: str): Vector<str>2:3 and the items without parentheses. Returns the reply lines without the status.The example for this item is shown with the ones above: net-email-Clients.salam
Logout
func Logout(c &: IMAPClient)The example for this item is shown with the ones above: net-email-Clients.salam