Initial project import

This commit is contained in:
Agila Forge Importer committed 2026-07-13 15:20:32 +00:00
commit 23f2d29e32
11957 files changed
+1754577

No files matched your search

+442
View File
@@ -0,0 +1,442 @@
module smtp
/*
*
* smtp module
* Created by: nedimf (07/2020)
*/
import net
import net.ssl
import encoding.base64
import strings
import time
import io
import rand
const recv_size = 128
enum ReplyCode {
ready = 220
close = 221
auth_ok = 235
action_ok = 250
mail_start = 354
}
pub enum BodyType {
text
html
}
// Message stores one body variant and optional attachments for a Mail.
pub struct Message {
pub:
body string
attachments []Attachment
}
// Config stores the settings used to connect a new SMTP client.
pub struct Config {
pub:
server string
port int = 25
username string
password string
from string
ssl bool
starttls bool
timeout time.Duration
}
pub struct Client {
Config
mut:
conn net.TcpConn
ssl_conn &ssl.SSLConn = unsafe { nil }
reader ?&io.BufferedReader
pub mut:
is_open bool
encrypted bool
}
// Mail stores the message headers and MIME payload sent by Client.send.
pub struct Mail {
pub:
from string
to string
cc string
bcc string
date time.Time = time.now()
subject string
body_type BodyType
body string
attachments []Attachment
html Message
text Message
boundary string
}
pub struct Attachment {
pub:
cid string
filename string
bytes []u8
}
// new_client returns a new SMTP client and connects to it
pub fn new_client(config Config) !&Client {
if config.ssl && config.starttls {
return error('Can not use both implicit SSL and STARTTLS')
}
mut c := &Client{
Config: config
}
c.reconnect()!
return c
}
// reconnect reconnects to the SMTP server if the connection was closed
pub fn (mut c Client) reconnect() ! {
if c.is_open {
return error('Already connected to server')
}
mut conn := net.dial_tcp('${c.server}:${c.port}') or {
return error('Connecting to server failed')
}
if c.timeout != 0 {
conn.set_read_timeout(c.timeout)
conn.set_write_timeout(c.timeout)
}
c.conn = conn
if c.ssl || c.encrypted {
c.connect_ssl()!
} else {
c.reader = io.new_buffered_reader(reader: c.conn)
}
c.expect_reply(.ready) or { return error('Received invalid response from server') }
c.send_ehlo() or { return error('Sending EHLO packet failed') }
if c.starttls && !c.encrypted {
c.send_starttls() or { return error('Sending STARTTLS failed') }
}
c.send_auth() or { return error('Authenticating to server failed') }
c.is_open = true
}
// send sends an email
pub fn (mut c Client) send(config Mail) ! {
if !c.is_open {
return error('Disconnected from server')
}
from := if config.from != '' { config.from } else { c.from }
c.send_mailfrom(from) or { return error('Sending mailfrom failed') }
c.send_mailto(config.to) or { return error('Sending mailto failed') }
c.send_data() or { return error('Sending mail data failed') }
c.send_body(Mail{
...config
from: from
boundary: rand.uuid_v4()
}) or { return error('Sending mail body failed') }
}
// quit closes the connection to the server
pub fn (mut c Client) quit() ! {
c.send_str('QUIT\r\n')!
c.expect_reply(.close)!
if c.encrypted {
c.ssl_conn.shutdown()!
} else {
c.conn.close()!
}
c.is_open = false
c.encrypted = false
}
fn (mut c Client) connect_ssl() ! {
c.ssl_conn = ssl.new_ssl_conn()!
c.ssl_conn.connect(mut c.conn, c.server) or {
return error('Connecting to server using OpenSSL failed: ${err}')
}
c.reader = io.new_buffered_reader(reader: c.ssl_conn)
c.encrypted = true
}
// expect_reply checks if the SMTP server replied with the expected reply code
fn (mut c Client) expect_reply(expected ReplyCode) ! {
mut str := ''
for {
str = c.reader or { return error('the Client.reader field is not set') }.read_line()!
if str.len < 4 {
return error('Invalid SMTP response: ${str}')
}
if str.runes()[3] == `-` {
continue
} else {
break
}
}
$if smtp_debug ? {
eprintln('\n\n[RECV]')
eprint(str)
}
if str.len >= 3 {
status := str[..3].int()
if unsafe { ReplyCode(status) } != expected {
return error('Received unexpected status code ${status}, expecting ${expected}')
}
} else {
return error('Received unexpected SMTP data: ${str}')
}
}
@[inline]
fn (mut c Client) send_str(s string) ! {
$if smtp_debug ? {
eprintln('\n\n[SEND START]')
eprint(s.trim_space())
eprintln('\n[SEND END]')
}
if c.encrypted {
c.ssl_conn.write(s.bytes())!
} else {
c.conn.write(s.bytes())!
}
}
@[inline]
fn (mut c Client) send_ehlo() ! {
c.send_str('EHLO ${c.server}\r\n')!
c.expect_reply(.action_ok)!
}
@[inline]
fn (mut c Client) send_starttls() ! {
c.send_str('STARTTLS\r\n')!
c.expect_reply(.ready)!
c.connect_ssl()!
}
@[inline]
fn (mut c Client) send_auth() ! {
if c.username.len == 0 {
return
}
mut sb := strings.new_builder(100)
sb.write_u8(0)
sb.write_string(c.username)
sb.write_u8(0)
sb.write_string(c.password)
a := sb.str()
auth := 'AUTH PLAIN ${base64.encode_str(a)}\r\n'
c.send_str(auth)!
c.expect_reply(.auth_ok)!
}
// envelope_addr extracts the bare mailbox from an address that may include a
// display name. The SMTP envelope (`MAIL FROM:` / `RCPT TO:`) only accepts a
// bare mailbox (RFC 5321), while `Mail.from`/`Mail.to` may also be written in
// the RFC 5322 `Display Name <addr@example.com>` form for the message header.
//
// Only strip an angle-addr wrapper when the input actually ends in `>`; bare
// mailboxes are returned unchanged. When walking for the opening `<`, quoted
// strings are skipped so a quoted local-part like `"a<b"@example.com` is
// preserved intact.
fn envelope_addr(s string) string {
trimmed := s.trim_space()
if !trimmed.ends_with('>') {
return trimmed
}
mut in_quote := false
mut i := 0
for i < trimmed.len - 1 {
c := trimmed[i]
if c == `"` {
in_quote = !in_quote
} else if in_quote && c == `\\` && i + 1 < trimmed.len {
i++
} else if c == `<` && !in_quote {
return trimmed[i + 1..trimmed.len - 1]
}
i++
}
return trimmed
}
fn (mut c Client) send_mailfrom(from string) ! {
c.send_str('MAIL FROM:<${envelope_addr(from)}>\r\n')!
c.expect_reply(.action_ok)!
}
fn (mut c Client) send_mailto(to string) ! {
for rcpt in to.split(';') {
c.send_str('RCPT TO:<${envelope_addr(rcpt)}>\r\n')!
c.expect_reply(.action_ok)!
}
}
fn (mut c Client) send_data() ! {
c.send_str('DATA\r\n')!
c.expect_reply(.mail_start)!
}
fn (mut c Client) send_body(cfg Mail) ! {
c.send_str(cfg.message_data())!
c.expect_reply(.action_ok)!
}
fn (cfg &Mail) message_data() string {
date := cfg.date.custom_format('ddd, D MMM YYYY HH:mm ZZ')
nonascii_subject := cfg.subject.bytes().any(it < u8(` `) || it > u8(`~`))
parts, attachments := cfg.mime_parts()
mut sb := strings.new_builder(200 + cfg.body.len + cfg.text.body.len + cfg.html.body.len +
(cfg.attachments.len + cfg.text.attachments.len + cfg.html.attachments.len) * 200)
sb.write_string('From: ${cfg.from}\r\n')
sb.write_string('To: <${cfg.to.split(';').join('>; <')}>\r\n')
sb.write_string('Cc: <${cfg.cc.split(';').join('>; <')}>\r\n')
sb.write_string('Bcc: <${cfg.bcc.split(';').join('>; <')}>\r\n')
sb.write_string('Date: ${date}\r\n')
if nonascii_subject {
// handle UTF-8 subjects according RFC 1342
sb.write_string('Subject: =?utf-8?B?' + base64.encode_str(cfg.subject) + '?=\r\n')
} else {
sb.write_string('Subject: ${cfg.subject}\r\n')
}
if parts.len > 1 || attachments.len > 0 {
sb.write_string('MIME-Version: 1.0\r\n')
}
boundary := cfg.mime_boundary()
if parts.len > 1 && attachments.len > 0 {
alternative_boundary := '${boundary}-alternative'
write_multipart_header(mut sb, 'multipart/mixed', boundary)
write_multipart_boundary(mut sb, boundary)
write_multipart_header(mut sb, 'multipart/alternative', alternative_boundary)
for part in parts {
write_multipart_boundary(mut sb, alternative_boundary)
write_message_part(mut sb, part)
}
write_multipart_end(mut sb, alternative_boundary)
write_attachments(mut sb, attachments, boundary)
} else if parts.len > 1 {
write_multipart_header(mut sb, 'multipart/alternative', boundary)
for part in parts {
write_multipart_boundary(mut sb, boundary)
write_message_part(mut sb, part)
}
write_multipart_end(mut sb, boundary)
} else if attachments.len > 0 {
write_multipart_header(mut sb, 'multipart/mixed', boundary)
write_multipart_boundary(mut sb, boundary)
write_message_part(mut sb, parts[0])
write_attachments(mut sb, attachments, boundary)
} else {
write_message_part(mut sb, parts[0])
}
sb.write_string('.\r\n')
return sb.str()
}
struct MimePart {
body_type BodyType
body string
}
fn (cfg &Mail) mime_parts() ([]MimePart, []Attachment) {
if cfg.text.body != '' || cfg.html.body != '' {
mut parts := []MimePart{cap: 2}
mut attachments := []Attachment{cap: cfg.text.attachments.len + cfg.html.attachments.len}
if cfg.text.body != '' {
parts << MimePart{
body_type: .text
body: cfg.text.body
}
}
attachments << cfg.text.attachments
if cfg.html.body != '' {
parts << MimePart{
body_type: .html
body: cfg.html.body
}
}
attachments << cfg.html.attachments
return parts, attachments
}
return [MimePart{
body_type: cfg.body_type
body: cfg.body
}], cfg.attachments
}
fn (cfg &Mail) mime_boundary() string {
if cfg.boundary != '' {
return cfg.boundary
}
return 'v-smtp-boundary'
}
fn write_multipart_header(mut sb strings.Builder, multipart_type string, boundary string) {
sb.write_string('Content-Type: ${multipart_type}; boundary="${boundary}"\r\n\r\n')
}
fn write_multipart_boundary(mut sb strings.Builder, boundary string) {
sb.write_string('--${boundary}\r\n')
}
fn write_multipart_end(mut sb strings.Builder, boundary string) {
sb.write_string('--${boundary}--\r\n')
}
fn write_message_part(mut sb strings.Builder, part MimePart) {
if part.body_type == .html {
sb.write_string('Content-Type: text/html; charset=UTF-8\r\n')
} else {
sb.write_string('Content-Type: text/plain; charset=UTF-8\r\n')
}
sb.write_string('Content-Transfer-Encoding: base64\r\n\r\n')
sb.write_string(fold_base64(base64.encode_str(part.body)))
sb.write_string('\r\n')
}
fn write_attachments(mut sb strings.Builder, attachments []Attachment, boundary string) {
for attachment in attachments {
write_multipart_boundary(mut sb, boundary)
sb.write_string(attachment.to_string())
sb.write_string('\r\n')
}
write_multipart_end(mut sb, boundary)
}
fn (a &Attachment) to_string() string {
crlf := '\r\n'
cid := if a.cid != '' {
'Content-ID: <${a.cid}>${crlf}'
} else {
''
}
return 'Content-Type: application/octet-stream${crlf}${cid}Content-Transfer-Encoding: base64${crlf}Content-Disposition: attachment; filename="${a.filename}"${crlf}${crlf}${fold_base64(base64.encode(a.bytes))}'
}
fn fold_base64(encoded string) string {
if encoded.len <= 76 {
return encoded
}
mut sb := strings.new_builder(encoded.len + encoded.len / 76 * 2)
for start := 0; start < encoded.len; start += 76 {
end := if start + 76 < encoded.len { start + 76 } else { encoded.len }
sb.write_string(encoded[start..end])
if end < encoded.len {
sb.write_string('\r\n')
}
}
return sb.str()
}
+117
View File
@@ -0,0 +1,117 @@
module smtp
import encoding.base64
fn test_mail_message_data_with_attachment_has_valid_multipart_boundaries() {
mail := Mail{
from: 'sender@example.com'
to: 'receiver@example.com'
subject: 'Multipart test'
body: 'message body'
boundary: 'test-boundary'
attachments: [
Attachment{
filename: 'note.txt'
bytes: 'attachment'.bytes()
},
]
}
message := mail.message_data()
assert message.contains('Content-Type: multipart/mixed; boundary="test-boundary"\r\n\r\n--test-boundary\r\n')
assert message.contains('Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\nbWVzc2FnZSBib2R5\r\n--test-boundary\r\n')
assert message.contains('Content-Disposition: attachment; filename="note.txt"\r\n\r\nYXR0YWNobWVudA==\r\n--test-boundary--\r\n.\r\n')
assert !message.contains('Content-Type: multipart/mixed; boundary="test-boundary"\r\n--test-boundary\r\n')
assert !message.contains('YXR0YWNobWVudA==\r\n--test-boundary\r\n.\r\n')
}
fn test_mail_message_data_with_text_and_html_uses_multipart_alternative() {
mail := Mail{
from: 'sender@example.com'
to: 'receiver@example.com'
subject: 'Multipart alternative test'
body: 'legacy body'
boundary: 'test-boundary'
text: Message{
body: 'text body'
}
html: Message{
body: '<h1>Hello</h1>'
}
}
message := mail.message_data()
assert message.contains('MIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary="test-boundary"\r\n\r\n--test-boundary\r\n')
assert message.contains('Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n${base64.encode_str('text body')}\r\n--test-boundary\r\n')
assert message.contains('Content-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n${base64.encode_str('<h1>Hello</h1>')}\r\n--test-boundary--\r\n.\r\n')
assert !message.contains(base64.encode_str('legacy body'))
assert !message.contains('multipart/mixed')
}
fn test_mail_message_data_with_text_html_and_attachment_uses_nested_multipart() {
mail := Mail{
from: 'sender@example.com'
to: 'receiver@example.com'
subject: 'Multipart mixed test'
boundary: 'test-boundary'
text: Message{
body: 'text body'
}
html: Message{
body: '<p>Hello</p>'
attachments: [
Attachment{
filename: 'note.txt'
bytes: 'attachment'.bytes()
},
]
}
}
message := mail.message_data()
assert message.contains('Content-Type: multipart/mixed; boundary="test-boundary"\r\n\r\n--test-boundary\r\nContent-Type: multipart/alternative; boundary="test-boundary-alternative"\r\n\r\n--test-boundary-alternative\r\n')
assert message.contains('Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n${base64.encode_str('text body')}\r\n--test-boundary-alternative\r\n')
assert message.contains('Content-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n${base64.encode_str('<p>Hello</p>')}\r\n--test-boundary-alternative--\r\n')
assert message.contains('--test-boundary\r\nContent-Type: application/octet-stream\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename="note.txt"\r\n\r\n${base64.encode_str('attachment')}\r\n--test-boundary--\r\n.\r\n')
}
fn test_fold_base64_wraps_long_lines() {
lines := fold_base64(base64.encode_str('0123456789'.repeat(8))).split('\r\n')
assert lines.len == 2
assert lines[0].len == 76
assert lines[1].len == 32
}
fn test_envelope_addr_strips_display_name() {
assert envelope_addr('ivan@example.com') == 'ivan@example.com'
assert envelope_addr(' ivan@example.com ') == 'ivan@example.com'
assert envelope_addr('<ivan@example.com>') == 'ivan@example.com'
assert envelope_addr('Ivan Petrov <ivan@example.com>') == 'ivan@example.com'
assert envelope_addr('"Petrov, Ivan" <ivan@example.com>') == 'ivan@example.com'
// Quoted local-parts may legitimately contain '<'. Without a trailing '>',
// the input is not an angle-addr wrapper and must pass through unchanged.
assert envelope_addr('"a<b"@example.com') == '"a<b"@example.com'
// Quoted display name and quoted local-part both containing '<'/'>'.
assert envelope_addr('"a<b" <"a<b"@example.com>') == '"a<b"@example.com'
// Escaped quote inside a quoted display name must not end the quoted run.
assert envelope_addr('"a\\"<b" <ivan@example.com>') == 'ivan@example.com'
// Malformed input (no closing '>') passes through; the server can reject.
assert envelope_addr('Ivan <ivan@example.com') == 'Ivan <ivan@example.com'
}
fn test_mail_message_data_preserves_display_name_in_from_header() {
mail := Mail{
from: 'Ivan Petrov <ivan@example.com>'
to: 'recipient@example.com'
subject: 'Test'
body: 'hi'
}
message := mail.message_data()
assert message.contains('From: Ivan Petrov <ivan@example.com>\r\n')
}
+157
View File
@@ -0,0 +1,157 @@
// vtest build: !windows
import os
import net.smtp
import time
// Used to test that a function call returns an error
fn fn_errors(mut c smtp.Client, m smtp.Mail) bool {
c.send(m) or { return true }
return false
}
fn send_mail(starttls bool) {
client_cfg := smtp.Config{
server: 'smtp.mailtrap.io'
port: 465
from: 'dev@vlang.io'
username: os.getenv('VSMTP_TEST_USER')
password: os.getenv('VSMTP_TEST_PASS')
starttls: starttls
}
if client_cfg.username == '' && client_cfg.password == '' {
eprintln('Please set VSMTP_TEST_USER and VSMTP_TEST_PASS before running this test')
exit(0)
}
send_cfg := smtp.Mail{
to: 'dev@vlang.io'
subject: 'Hello from V2'
body: 'Plain text'
}
mut client := smtp.new_client(client_cfg) or {
assert false
return
}
assert true
client.send(send_cfg) or {
assert false
return
}
assert true
client.send(smtp.Mail{
...send_cfg
from: 'alexander@vlang.io'
}) or {
assert false
return
}
client.send(smtp.Mail{
...send_cfg
cc: 'alexander@vlang.io,joe@vlang.io'
bcc: 'spytheman@vlang.io'
}) or {
assert false
return
}
client.send(smtp.Mail{
...send_cfg
date: time.now().add_days(1000)
}) or {
assert false
return
}
assert true
client.quit() or {
assert false
return
}
assert true
// This call should return an error, since the connection is closed
if !fn_errors(mut client, send_cfg) {
assert false
return
}
client.reconnect() or {
assert false
return
}
client.send(send_cfg) or {
assert false
return
}
assert true
}
/*
*
* smtp_test
* Created by: nedimf (07/2020)
*/
fn test_smtp() {
$if !network ? {
return
}
// Test sending without STARTTLS
send_mail(false)
// Sleep for 10 seconds to reset the Mailtrap rate limit counter
// See: https://help.mailtrap.io/article/44-features-and-limits#rate-limit
time.sleep(10000 * time.millisecond)
// Test with STARTTLS
send_mail(true)
}
fn test_smtp_implicit_ssl() {
$if !network ? {
return
}
client_cfg := smtp.Config{
server: 'smtp.gmail.com'
port: 465
from: ''
username: ''
password: ''
ssl: true
}
mut client := smtp.new_client(client_cfg) or {
assert false
return
}
assert client.is_open && client.encrypted
}
fn test_new_client_rejects_conflicting_tls_modes() {
client_cfg := smtp.Config{
server: 'smtp.example.com'
ssl: true
starttls: true
}
smtp.new_client(client_cfg) or {
assert err.msg() == 'Can not use both implicit SSL and STARTTLS'
return
}
assert false
}
fn test_smtp_multiple_recipients() {
$if !network ? {
return
}
assert true
}
fn test_smtp_body_base64encode() {
$if !network ? {
return
}
assert true
}
+41
View File
@@ -0,0 +1,41 @@
module smtp
import net
import time
fn smtp_timeout_test_server(mut listener net.TcpListener, done chan bool) {
defer {
listener.close() or {}
done <- true
}
mut conn := listener.accept() or { panic(err) }
defer {
conn.close() or {}
}
conn.write_string('220 localhost ESMTP ready\r\n') or { panic(err) }
ehlo := conn.read_line().trim_space()
assert ehlo.starts_with('EHLO ')
conn.write_string('250-localhost\r\n250 OK\r\n') or { panic(err) }
quit := conn.read_line().trim_space()
assert quit == 'QUIT'
conn.write_string('221 Bye\r\n') or { panic(err) }
}
fn test_client_timeout_configures_tcp_conn() ! {
timeout := 7 * time.second
mut listener := net.listen_tcp(.ip, '127.0.0.1:0')!
port := listener.addr()!.port()!
done := chan bool{}
spawn smtp_timeout_test_server(mut listener, done)
mut client := new_client(Config{
server: '127.0.0.1'
port: int(port)
timeout: timeout
})!
assert client.is_open
assert client.conn.read_timeout() == timeout
assert client.conn.write_timeout() == timeout
client.quit()!
assert !client.is_open
_ := <-done
}