IMAP
Published .. 29-04-2025
Type ....... article
Tags ....... imap
Type ....... article
Tags ....... imap
For me the primary problem with V is lack of libraries. Here are a very basic start of a IMAP client library.
module main
import net
import net.ssl
import io
import time
import encoding.base64
import encoding.hex
import encoding.iconv
pub struct Client {
mut:
conn net.TcpConn
ssl_conn &ssl.SSLConn = unsafe { nil }
reader ?&io.BufferedReader
tag_num int
pub:
server string
port int = 993
username string
password string
pub mut:
is_open bool
}
// Creates a new IMAP client and connects
pub fn new_client(config Client) !&Client {
mut c := &Client{
...config
}
c.connect()!
return c
}
fn (mut c Client) connect() ! {
c.conn = net.dial_tcp('${c.server}:${c.port}')!
c.ssl_conn = ssl.new_ssl_conn()!
c.ssl_conn.connect(mut c.conn, c.server)!
c.reader = io.new_buffered_reader(reader: c.ssl_conn)
c.read_greeting()!
c.send_cmd('CAPABILITY')!
c.login()!
c.is_open = true
}
fn (mut c Client) read_greeting() ! {
for {
line := c.read_line() or { return error('Failed reading greeting: ${err}') }
// println('GREETING: ${line.trim_space()}')
if line.contains('OK') {
return
}
}
}
fn (mut c Client) next_tag() string {
c.tag_num++
return 'A${c.tag_num:04d}'
}
fn (mut c Client) send_cmd(cmd string) ![]string {
tag := c.next_tag()
c.send_line('${tag} ${cmd}')
return c.expect_tagged_response(tag)
}
fn (mut c Client) send_line(line string) {
// println('>>> ${line}')
c.ssl_conn.write('${line}\r\n'.bytes()) or { eprintln('Failed to send line: ${line}') }
}
fn (mut c Client) read_line() !string {
return c.reader or { return error('no reader') }.read_line()!
}
fn (mut c Client) login() ! {
tag := c.next_tag()
c.send_line('${tag} LOGIN "${c.username}" "${c.password}"')
c.expect_tagged_response(tag) or {
// println('login failed: ${err}')
return err
}
}
fn (mut c Client) expect_tagged_response(expected_tag string) ![]string {
mut lines := []string{}
for {
line := c.read_line() or { return error('read_line failed: ${err}') }
// println('<<< ${line}')
if line.starts_with(expected_tag) {
if line.contains('OK') {
return lines
} else {
return error('Server replied with failure: ${line}')
}
} else {
lines << line.trim_space()
}
}
}
struct Mailbox {
name string
flags []string
delimiter string
}
fn (mut c Client) list_mailbox() ![]Mailbox {
lines := c.send_cmd('LIST "" "*"')!
mut mailboxes := []Mailbox{}
for line in lines {
if !line.starts_with('* LIST') {
continue
}
// * LIST (\HasNoChildren \UnMarked) "." Kunder.LinoLiving
// * LIST (\HasNoChildren \UnMarked) "." "Kunder.Nordic Digital"
mut rest := line[6..].trim_space() // Remove "* LIST "
// find flags
if !rest.starts_with('(') {
continue
}
flags_end := rest.index_after(')', 1) or { continue }
flags_str := rest[1..flags_end + 1]
flags := flags_str.split(' ').map(it.trim_space())
rest = rest[flags_end + 1..].trim_space()
// delimiter: quoted
if !rest.starts_with('"') {
continue
}
rest = rest[1..]
delimiter_end := rest.index('"') or { continue }
delimiter := rest[..delimiter_end]
rest = rest[delimiter_end + 1..].trim_space()
// now rest is the mailbox name
mut name := rest
if name.starts_with('"') && name.ends_with('"') {
name = name[1..name.len - 1]
}
mailboxes << Mailbox{
name: name
flags: flags
delimiter: delimiter
}
}
return mailboxes
}
fn (mut c Client) select_mailbox(mailbox_name string) !int {
tag := c.next_tag()
c.send_line('${tag} SELECT "${mailbox_name}"')
lines := c.expect_tagged_response(tag)!
for line in lines {
if line.contains('EXISTS') {
parts := line.split(' ')
if parts.len > 1 {
count := parts[1].int()
return count
}
}
}
return error('EXISTS not found')
}
struct Message {
mut:
subject string
from string
date time.Time
}
fn simple_parse_email_date(date_str string) !time.Time {
// Try parsing standard email date
// Mon, 28 Apr 2025 06:42:57 +0000
// Tue, 29 Apr 2025 10:37:36 +0000
d := date_str.split(' ')
d2 := d[0..d.len - 1].join(' ')
return time.parse_format(d2, 'ddd, DD MMM YYYY HH:mm:ss')
}
fn parse_email_date(date_str string) !time.Time {
mut d := date_str.trim_space()
// Find timezone at end
mut timezone := ''
last_space := d.last_index(' ') or { return error('invalid date string') }
if last_space + 5 < d.len {
timezone = d[last_space + 1..]
d = d[..last_space]
} else {
return error('invalid timezone format')
}
// Parse the date without timezone
parsed := time.parse_format(d, 'ddd, DD MMM YYYY HH:mm:ss') or {
return error('date parse failed: ${err}')
}
// Parse timezone offset manually
if timezone.len == 5 && (timezone.starts_with('+') || timezone.starts_with('-')) {
hours := timezone[1..3].int()
minutes := timezone[3..5].int()
mut total_minutes := hours * 60 + minutes
if timezone.starts_with('-') {
total_minutes = -total_minutes
}
return parsed.add_seconds(-total_minutes * 60) // adjust time
} else {
return parsed
}
}
fn (mut c Client) fetch_messages() ![]Message {
tag := c.next_tag()
c.send_line('${tag} FETCH 1:* (BODY[HEADER.FIELDS (FROM DATE SUBJECT)])')
mut lines := c.expect_tagged_response(tag)!
mut messages := []Message{}
mut current := Message{}
for mut line in lines {
line = line.trim_space()
if line.starts_with('*') && current.subject != '' {
// new message starting, push last one if it had subject
messages << current
current = Message{}
continue
}
if line.starts_with('Subject:') {
raw_subject := line[8..].trim_space()
current.subject = decode_mime_subject(raw_subject)
} else if line.starts_with('From:') {
current.from = decode_mime_subject(line[5..].trim_space())
} else if line.starts_with('Date:') {
date_str := line[5..].trim_space()
current.date = parse_email_date(date_str) or {
println('${err}')
time.now()
}
}
}
// Push the last message if any
if current.subject != '' {
messages << current
}
return messages
}
fn decode_mime_encoded_word(s string) string {
if !s.starts_with('=?') || !s.ends_with('?=') {
return s
}
mut inner := s[2..s.len - 2] // strip =? and ?=
mut parts := inner.split('?')
if parts.len != 3 {
return s
}
charset := parts[0].to_upper()
encoding := parts[1].to_upper()
encoded_text := parts[2]
mut decoded := ''
if encoding == 'Q' {
// Quoted-Printable style decoding
mut i := 0
for i < encoded_text.len {
if encoded_text[i] == `=` {
if i + 2 < encoded_text.len {
h := encoded_text[i + 1..i + 3]
b := hex.decode(h) or { [] }
decoded += b[0].ascii_str()
i += 3
} else {
break
}
} else if encoded_text[i] == `_` {
decoded += ' '
i++
} else {
decoded += encoded_text[i].ascii_str()
i++
}
}
decoded = iconv.encoding_to_vstring(decoded.bytes(), charset) or { '${err}' }
} else if encoding == 'B' {
// Base64 encoded
decoded = base64.decode(encoded_text).bytestr()
} else {
// unknown encoding
return s
}
// Assume charset is UTF-8 — for now.
return decoded
}
fn decode_mime_subject(s string) string {
mut result := ''
mut i := 0
for i < s.len {
if i + 1 < s.len && s[i] == `=` && s[i + 1] == `?` {
// Start of an encoded word
end := s.index_after('?=', i) or { -1 }
if end == -1 {
// malformed, stop
result += s[i..]
break
}
encoded_word := s[i..end + 2]
result += decode_mime_encoded_word(encoded_word)
i = end + 2
} else {
// Normal text (not encoded)
result += s[i].ascii_str()
i++
}
}
return result
}
fn main() {
mut client := new_client(Client{
server: ''
username: ''
password: ''
}) or {
eprintln('Failed: ${err}')
return
}
boxes := client.list_mailbox() or {
eprintln('Failed to list mailbox: ${err}')
return
}
for box in boxes {
println('${box.name}')
}
// select the first one for now (later you can add user input)
selected := boxes[boxes.len - 1]
println('Selecting mailbox: ${selected.name}')
client.select_mailbox(selected.name) or {
eprintln('Failed to select mailbox: ${err}')
return
}
mut messages := client.fetch_messages() or {
eprintln('Failed to fetch_messages: ${err}')
return
}
println('Subjects in ${selected.name}:')
for m in messages {
println(' - ${m.subject} ${m.date} ${m.from}')
}
}