IMAP

Published .. 29-04-2025
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}')
    }
}