text: package toolbox
  
  import (
  	"bufio"
  	"bytes"
  	"crypto/tls"
  	"errors"
  	"fmt"
  	"io"
  	"math/rand"
  	"net"
  	"net/textproto"
  	"regexp"
  	"strconv"
  	"strings"
  	"sync"
  	"time"
  
  	"github.com/HouzuoGuo/laitos/inet"
  )
  
  const (
  	MailboxList    = "l" // Prefix string to trigger listing messages.
  	MailboxRead    = "r" // Prefix string to trigger reading message body.
  	IMAPTimeoutSec = 30  // IMAPTimeoutSec is the IO timeout (in seconds) used for each IMAP conversation.
  )
  
  var (
  	RegexMailboxAndNumber     = regexp.MustCompile(`(\w+)[^\w]+(\d+)`)            // Capture one mailbox shortcut name and a number
  	RegexMailboxAndTwoNumbers = regexp.MustCompile(`(\w+)[^\w]+(\d+)[^\d]+(\d+)`) // Capture one mailbox shortcut name and two numbers
  	ErrBadMailboxParam        = fmt.Errorf("%s box skip# count# | %s box to-read#", MailboxList, MailboxRead)
  )
  
  // IMAPSConnection is an established TLS client connection that is ready for IMAP conversations.
  type IMAPSConnection struct {
  	tlsConn *tls.Conn   // tlsConn is a TLS client opened toward IMAP host. It had gone through handshake external to IMAPSConnection.
  	mutex   *sync.Mutex // mutex allows only one conversation to take place at a time.
  }
  
  /*
  converse sends an IMAP request and waits for a response, then return IMAP response status and body.
  If the response status is not OK, an error will be returned. If IO error occurs, client connection will be closed and an
  error will be returned.
  */
  func (conn *IMAPSConnection) converse(request string) (status, body string, err error) {
  	// Expect both request and response to complete within the timeout constraint
  	_ = conn.tlsConn.SetDeadline(time.Now().Add(time.Duration(IMAPTimeoutSec) * time.Second))
  	// Random challenge is a string prefixed to an IMAP request
  	challenge := randomChallenge()
  	_, err = conn.tlsConn.Write([]byte(fmt.Sprintf("%s %s\r\n", challenge, request)))
  	if err != nil {
  		conn.disconnect()
  		return
  	}
  	// IMAP protocol is very much line-oriented in both of its request and response
  	var allLines bytes.Buffer
  	// Allow up to 32MB of data to be received per conversation
  	reader := textproto.NewReader(bufio.NewReader(io.LimitReader(conn.tlsConn, 32*1048576)))
  	for {
  		var line string
  		line, err = reader.ReadLine()
  		if err != nil {
  			conn.disconnect()
  			return
  		}
  		lowerLine := strings.TrimSpace(strings.ToLower(string(line)))
  		if strings.Index(lowerLine, challenge) == 0 {
  			// Conversation is finished when the response line comes with random challenge that was sent moments ago
  			body = allLines.String()
  			withoutChallenge := strings.TrimSpace(lowerLine[len(challenge):])
  			// There is a single-word status at the beginning
  			afterStatusWord := strings.IndexRune(withoutChallenge, ' ')
  			if afterStatusWord == -1 {
  				status = withoutChallenge
  				err = fmt.Errorf("cannot find IMAP status word among line - %s", withoutChallenge)
  				conn.disconnect()
  				return
  			}
  			statusWord := withoutChallenge[:afterStatusWord]
  			if len(withoutChallenge) > afterStatusWord {
  				status = withoutChallenge[afterStatusWord:]
  			}
  			if strings.ToLower(statusWord) != "ok" {
  				err = fmt.Errorf("bad IMAP response status - %s", status)
  				// Bad status does not prevent further conversations from taking place
  			}
  			return
  		} else {
  			// Continue to receive response body
  			allLines.WriteString(line)
  			allLines.WriteRune('\n')
  		}
  	}
  }
  
  /*
  converse sends an IMAP request and waits for a response, then return IMAP response status and body.
  If the response status is not OK, an error will be returned. If IO error occurs, client connection will be closed and an
  error will be returned. A mutex prevents more than one conversation from taking place at the same time.
  */
  func (conn *IMAPSConnection) Converse(request string) (status, body string, err error) {
  	conn.mutex.Lock()
  	defer conn.mutex.Unlock()
  	if conn.tlsConn == nil {
  		return "", "", errors.New("programming mistake - IMAPS connection is missing")
  	}
  	return conn.converse(request)
  }
  
  // disconnect closes client connection.
  func (conn *IMAPSConnection) disconnect() {
  	if conn.tlsConn == nil {
  		return
  	}
  	conn.tlsConn.Close()
  	conn.tlsConn = nil
  }
  
  // LogoutDisconnect sends logout command to IMAP server, and then closes client connection.
  func (conn *IMAPSConnection) LogoutDisconnect() {
  	conn.mutex.Lock()
  	defer conn.mutex.Unlock()
  	if conn.tlsConn == nil {
  		return
  	}
  	_, _, _ = conn.converse("LOGOUT") // intentionally ignore conversation error
  	conn.disconnect()
  }
  
  // GetNumberMessages returns total number of messages in the specified mail box.
  func (conn *IMAPSConnection) GetNumberMessages(mailboxName string) (int, error) {
  	_, body, err := conn.Converse(fmt.Sprintf("EXAMINE \"%s\"", mailboxName))
  	if err != nil {
  		return 0, err
  	}
  	// Extract number of messages from response body
  	numberString := regexp.MustCompile(`(\d+) exists`).FindStringSubmatch(strings.ToLower(body))
  	if len(numberString) != 2 {
  		return 0, fmt.Errorf("IMAPS.GetNumberMessages: EXAMINE command did not return a number in body - %s", body)
  	}
  	number, err := strconv.Atoi(numberString[1])
  	if err != nil || number < 0 {
  		return 0, fmt.Errorf("IMAPS.GetNumberMessages: EXAMINE command did not return a valid positive integer in \"%s\" - %v", numberString[1], err)
  	}
  	return number, nil
  }
  
  // GetHeaders retrieves mail headers from the specified message number range.
  func (conn *IMAPSConnection) GetHeaders(from, to int) (ret map[int]string, err error) {
  	ret = make(map[int]string)
  	if from > to || from < 1 || to < 1 {
  		err = errors.New("invalid message number range")
  		return
  	}
  	_, body, err := conn.Converse(fmt.Sprintf("FETCH %d:%d BODY.PEEK[HEADER]", from, to))
  	if err != nil {
  		return
  	}
  	// Walk through body line by line to find boundary of messages
  	var thisNumber int
  	var thisMessage bytes.Buffer
  	for _, line := range strings.Split(body, "\n") {
  		trimmedLine := strings.TrimSpace(line)
  		if len(trimmedLine) == 0 {
  			continue
  		} else if len(trimmedLine) > 0 && trimmedLine[0] == '*' {
  			// Marks beginning of a message
  			// Store existing message
  			if thisMessage.Len() > 0 {
  				// Only store valid message
  				ret[thisNumber] = thisMessage.String()
  				thisMessage.Reset()
  			}
  			// Parse current message number
  			thisNumberStr := regexp.MustCompile(`\d+`).FindString(trimmedLine)
  			thisNumber, _ = strconv.Atoi(thisNumberStr)
  		} else if trimmedLine == ")" {
  			// ) on its own line signifies end of message
  			if thisMessage.Len() > 0 {
  				ret[thisNumber] = thisMessage.String()
  			}
  		} else {
  			// Place the line in the current message buffer
  			thisMessage.WriteString(line)
  			thisMessage.WriteRune('\n')
  		}
  	}
  	return
  }
  
  // GetMessage retrieves one mail message, including its entire headers, body content, and attachments if any.
  func (conn *IMAPSConnection) GetMessage(num int) (message string, err error) {
  	if num < 1 {
  		err = errors.New("message number must be positive")
  		return
  	}
  	var entireMessage bytes.Buffer
  	_, body, err := conn.Converse(fmt.Sprintf("FETCH %d BODY[]", num))
  	for _, line := range strings.Split(body, "\n") {
  		if len(line) > 0 {
  			switch line[0] {
  			// Skip fetch boundary lines
  			case '*', ')':
  				continue
  			}
  		}
  		entireMessage.WriteString(line)
  		entireMessage.WriteRune('\n')
  	}
  	message = entireMessage.String()
  	return
  }
  
  // Retrieve emails via IMAPS.
  type IMAPS struct {
  	Host               string `json:"Host"`               // Server name or IP address of IMAPS server
  	Port               int    `json:"Port"`               // Port number of IMAPS service
  	MailboxName        string `json:"MailboxName"`        // Name of mailbox (e.g. "INBOX")
  	InsecureSkipVerify bool   `json:"InsecureSkipVerify"` // Do not verify server name against its certificate
  	AuthUsername       string `json:"AuthUsername"`       // Username for plain authentication
  	AuthPassword       string `json:"AuthPassword"`       // Password for plain authentication
  }
  
  // Return a random 10 characters long string of numbers to
  func randomChallenge() string {
  	return strconv.Itoa(1000000000 + rand.Intn(1000000000))
  }
  
  // Set up TLS connection to IMAPS server and log the user in.
  func (mbox *IMAPS) ConnectLoginSelect() (conn *IMAPSConnection, err error) {
  	clientConn, err := net.DialTimeout(
  		"tcp",
  		net.JoinHostPort(mbox.Host, strconv.Itoa(mbox.Port)),
  		time.Duration(IMAPTimeoutSec)*time.Second)
  	if err != nil {
  		return nil, fmt.Errorf("IMAPS.ConnectLoginSelect: connection error - %v", err)
  	}
  	tlsWrapper := tls.Client(clientConn, &tls.Config{
  		ServerName:         mbox.Host,
  		InsecureSkipVerify: mbox.InsecureSkipVerify,
  	})
  	if err = tlsWrapper.Handshake(); err != nil {
  		clientConn.Close()
  		return nil, fmt.Errorf("IMAPS.ConnectLoginSelect: TLS connection error - %v", err)
  	}
  	// Absorb the connection greeting message sent by server
  	_ = tlsWrapper.SetReadDeadline(time.Now().Add(time.Duration(IMAPTimeoutSec) * time.Second))
  	reader := bufio.NewReader(tlsWrapper)
  	_, _, err = reader.ReadLine()
  	if err != nil {
  		clientConn.Close()
  		return nil, fmt.Errorf("IMAPS.ConnectLoginSelect: failed to read server greeting - %v", err)
  	}
  	// It is now ready for IMAP conversations
  	conn = &IMAPSConnection{
  		tlsConn: tlsWrapper,
  		mutex:   new(sync.Mutex),
  	}
  	// LOGIN && SELECT
  	_, _, err = conn.Converse(fmt.Sprintf("LOGIN %s %s", mbox.AuthUsername, mbox.AuthPassword))
  	if err != nil {
  		conn.disconnect()
  		return nil, fmt.Errorf("IMAPS.ConnectLoginSelect: LOGIN command failed - %v", err)
  	}
  	_, _, err = conn.Converse(fmt.Sprintf("SELECT \"%s\"", mbox.MailboxName))
  	if err != nil {
  		conn.LogoutDisconnect()
  		return nil, fmt.Errorf("IMAPS.ConnectLoginSelect: SELECT command failed - %v", err)
  	}
  	return
  }
  
  // Correspond IMAP account connection details to account names.
  type IMAPAccounts struct {
  	Accounts map[string]*IMAPS `json:"Accounts"` // IMAP account name vs account connectivity details
  }
  
  var TestIMAPAccounts = IMAPAccounts{} // Account details are set by init_feature_test.go
  
  func (imap *IMAPAccounts) IsConfigured() bool {
  	if imap.Accounts == nil || len(imap.Accounts) == 0 {
  		return false
  	}
  	for _, account := range imap.Accounts {
  		if account.Host == "" || account.AuthPassword == "" || account.AuthUsername == "" {
  			return false
  		}
  	}
  	return true
  }
  
  func (imap *IMAPAccounts) SelfTest() error {
  	if !imap.IsConfigured() {
  		return ErrIncompleteConfig
  	}
  	for name, account := range imap.Accounts {
  		imapConn, err := account.ConnectLoginSelect()
  		if err != nil {
  			return fmt.Errorf("IMAPAccounts.SelfTest: account \"%s\" has connection error - %v", name, err)
  		}
  		if _, err := imapConn.GetNumberMessages(account.MailboxName); err != nil {
  			imapConn.LogoutDisconnect()
  			return fmt.Errorf("IMAPAccounts.SelfTest: account \"%s\" test error - %v", name, err)
  		}
  		imapConn.LogoutDisconnect()
  	}
  	return nil
  }
  
  func (imap *IMAPAccounts) Initialise() error {
  	// Use default port number 993 and default mailbox name INBOX
  	for _, account := range imap.Accounts {
  		if account.Port < 1 {
  			account.Port = 993
  		}
  		if account.MailboxName == "" {
  			account.MailboxName = "INBOX"
  		}
  	}
  	return nil
  }
  
  func (imap *IMAPAccounts) Trigger() Trigger {
  	return ".i"
  }
  
  func (imap *IMAPAccounts) ListMails(cmd Command) *Result {
  	// Find one string parameter and two numeric parameters among the content
  	params := RegexMailboxAndTwoNumbers.FindStringSubmatch(cmd.Content)
  	if len(params) < 4 {
  		return &Result{Error: ErrBadMailboxParam}
  	}
  	var mbox string
  	var skip, count int
  	mbox = params[1]
  	var intErr error
  	skip, intErr = strconv.Atoi(params[2])
  	if intErr != nil {
  		return &Result{Error: ErrBadMailboxParam}
  	}
  	count, intErr = strconv.Atoi(params[3])
  	if intErr != nil {
  		return &Result{Error: ErrBadMailboxParam}
  	}
  	// Artificially do not allow retrieving more than 200 message headers at a time
  	if skip < 0 {
  		skip = 0
  	}
  	if count > 200 {
  		count = 200
  	}
  	if count < 1 {
  		count = 1
  	}
  	// Let IMAP magic begin!
  	account, found := imap.Accounts[mbox]
  	if !found {
  		return &Result{Error: fmt.Errorf("IMAPAccounts.ListMails: cannot find mailbox \"%s\"", mbox)}
  	}
  	conn, err := account.ConnectLoginSelect()
  	if err != nil {
  		return &Result{Error: err}
  	}
  	defer conn.LogoutDisconnect()
  	totalNumber, err := conn.GetNumberMessages(account.MailboxName)
  	if err != nil {
  		return &Result{Error: err}
  	}
  	if skip >= totalNumber {
  		return &Result{Error: fmt.Errorf("IMAPAccounts.ListMails: skip must be less than %d", totalNumber)}
  	}
  	// If count is greater than total number, retrieve all of the mails.
  	if skip+count > totalNumber {
  		count = totalNumber - skip
  	}
  	fromNum := totalNumber - count - skip + 1
  	toNum := totalNumber - skip
  	headers, err := conn.GetHeaders(fromNum, toNum)
  	if err != nil {
  		return &Result{Error: err}
  	}
  	var output bytes.Buffer
  	for i := toNum; i >= fromNum; i-- {
  		header, found := headers[i]
  		if !found {
  			continue
  		}
  		// Append \r\n\r\n to make it look like a complete message with empty body
  		prop, _, err := inet.ReadMailMessage([]byte(header + "\r\n\r\n"))
  		if err != nil {
  			continue
  		}
  		output.WriteString(fmt.Sprintf("%d %s %s\n", i, prop.FromAddress, prop.Subject))
  	}
  	return &Result{Output: output.String()}
  }
  
  func (imap *IMAPAccounts) ReadMessage(cmd Command) *Result {
  	// Find one string parameter and one numeric parameter among the content
  	params := RegexMailboxAndNumber.FindStringSubmatch(cmd.Content)
  	if len(params) < 3 {
  		return &Result{Error: ErrBadMailboxParam}
  	}
  	var mbox string
  	var number int
  	mbox = params[1]
  	var intErr error
  	number, intErr = strconv.Atoi(params[2])
  	if intErr != nil {
  		return &Result{Error: ErrBadMailboxParam}
  	}
  	// Let IMAP magic begin!
  	account, found := imap.Accounts[mbox]
  	if !found {
  		return &Result{Error: fmt.Errorf("IMAPAccounts.ReadMessage: cannot find mailbox \"%s\"", mbox)}
  	}
  	conn, err := account.ConnectLoginSelect()
  	if err != nil {
  		return &Result{Error: err}
  	}
  	defer conn.LogoutDisconnect()
  	entireMessage, err := conn.GetMessage(number)
  	if err != nil {
  		return &Result{Error: err}
  	}
  	// If mail is multi-part, prefer to retrieve the plain text mail body.
  	var anyText, plainText string
  	err = inet.WalkMailMessage([]byte(entireMessage), func(prop inet.BasicMail, body []byte) (bool, error) {
  		if !strings.Contains(prop.ContentType, "plain") {
  			anyText = string(body)
  		} else {
  			plainText = string(body)
  		}
  		return true, nil
  	})
  	if err != nil {
  		return &Result{Error: err}
  	}
  	if plainText == "" {
  		return &Result{Output: anyText}
  	} else {
  		return &Result{Output: plainText}
  	}
  }
  
  func (imap *IMAPAccounts) Execute(cmd Command) (ret *Result) {
  	if errResult := cmd.Trim(); errResult != nil {
  		return errResult
  	}
  	if cmd.FindAndRemovePrefix(MailboxList) {
  		ret = imap.ListMails(cmd)
  	} else if cmd.FindAndRemovePrefix(MailboxRead) {
  		ret = imap.ReadMessage(cmd)
  	} else {
  		ret = &Result{Error: ErrBadMailboxParam}
  	}
  	return
  }
  