asgard.git

commit 8538a2bba8351774c7367638dc4b89d15aa79384

Author: Adam <git@apiote.xyz>

reformat gersemi

 gersemi.go | 352 +++++++++++++++++++++-----------------------------
 hermodr.go | 18 +-
 imap.go | 13 -
 imap_errors.go | 20 ++
 imap_mailbox.go | 146 +++++++++++++++++++++
 imap_message.go | 126 ++++++++++++++++++
 mimir.go | 45 ------


diff --git a/gersemi.go b/gersemi.go
index fd278c8d0e47466c3fe5c3950fd279e46c2001b3..d3a1723a19135dc949d71e06d391a2caa11ba046 100644
--- a/gersemi.go
+++ b/gersemi.go
@@ -5,7 +5,6 @@ 	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
-	"io"
 	"log"
 	"net/http"
 	"regexp"
@@ -13,9 +12,7 @@ 	"strings"
 	"time"
 
 	"apiote.xyz/p/gott/v2"
-	"github.com/emersion/go-imap"
 	"github.com/emersion/go-imap/client"
-	"github.com/emersion/go-message"
 	_ "github.com/emersion/go-message/charset"
 )
 
@@ -56,129 +53,103 @@ type GersemiRequestBody struct {
 	Transactions []Transaction `json:"transactions"`
 }
 
-type gersemiStruct struct {
-	c      *client.Client
-	config Config
-	hc     *http.Client
+type GersemiMailbox struct {
+	Mailbox
+	hc                *http.Client
+	withdrawalRegexes []*regexp.Regexp
+	depositRegexes    []*regexp.Regexp
+}
 
-	mbox              *imap.MailboxStatus
-	section           *imap.BodySectionName
-	messages          chan *imap.Message
-	done              chan error
+type GersemiImapMessage struct {
+	ImapMessage
+	cli               *client.Client
+	hc                *http.Client
 	withdrawalRegexes []*regexp.Regexp
 	depositRegexes    []*regexp.Regexp
-
-	message             *imap.Message
-	messageBytes        []byte
-	mimeMessage         *message.Entity
-	plainTextBodyReader io.Reader
-	plainTextBody       []byte
-	src, dst            string
-	title               string
-	amount              string
-	day, month, year    string
-	requestBody         GersemiRequestBody
-	requestBodyBytes    []byte
-	request             *http.Request
-	response            *http.Response
+	config            Config
+	src, dst          string
+	title             string
+	amount            string
+	day, month, year  string
+	requestBody       GersemiRequestBody
+	requestBodyBytes  []byte
+	request           *http.Request
+	response          *http.Response
 }
 
 func gersemi(config Config) error {
-	c, err := client.DialTLS(config.Gersemi.ImapAddress, nil)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	log.Println("Connected")
-	defer c.Close()
-	defer c.Logout()
-	if err := c.Login(config.Gersemi.ImapUsername, config.Gersemi.ImapPassword); err != nil {
-		log.Fatalln(err)
-	}
-	log.Println("Logged in")
-
 	timeout, _ := time.ParseDuration("60s")
-	r := gott.R[gersemiStruct]{
-		S: gersemiStruct{
-			c:      c,
-			config: config,
-			hc: &http.Client{
-				Timeout: timeout,
-			},
+	mailbox := &GersemiMailbox{
+		Mailbox: Mailbox{
+			imapAdr:  config.Gersemi.ImapAddress,
+			imapUser: config.Gersemi.ImapUsername,
+			imapPass: config.Gersemi.ImapPassword,
+			conf:     config,
 		},
+		hc: &http.Client{
+			Timeout: timeout,
+		},
+	}
+	mailbox.setupChannels()
+
+	r := gott.R[AbstractMailbox]{
+		S: mailbox,
 	}.
 		Bind(prepareRegexes).
-		Bind(selectGersemiInbox).
-		Tee(checkGersemiEmptyBox).
-		Map(fetchGersemiMessages).
+		Bind(connect).
+		Tee(login).
+		Bind(selectInbox).
+		Tee(checkEmptyBox).
+		Map(fetchMessages).
 		Bind(createTransactions).
-		Recover(ignoreGersemiEmptyBox)
+		Recover(ignoreEmptyBox).
+		Recover(disconnect)
 
 	return r.E
 }
 
-func prepareRegexes(s gersemiStruct) (gersemiStruct, error) {
-	for i, wr := range s.config.Gersemi.WithdrawalRegexes {
+func prepareRegexes(am AbstractMailbox) (AbstractMailbox, error) {
+	m := am.(*GersemiMailbox)
+	for i, wr := range m.config().Gersemi.WithdrawalRegexes {
 		re, err := regexp.Compile(wr)
 		if err != nil {
-			return s, fmt.Errorf("while compiling withdrawal regex %d: %w", i, err)
+			return m, fmt.Errorf("while compiling withdrawal regex %d: %w", i, err)
 		}
-		s.withdrawalRegexes = append(s.withdrawalRegexes, re)
+		m.withdrawalRegexes = append(m.withdrawalRegexes, re)
 	}
 
-	for i, dr := range s.config.Gersemi.DepositRegexes {
+	for i, dr := range m.config().Gersemi.DepositRegexes {
 		re, err := regexp.Compile(dr)
 		if err != nil {
-			return s, fmt.Errorf("while compiling deposit regex %d: %w", i, err)
+			return m, fmt.Errorf("while compiling deposit regex %d: %w", i, err)
 		}
-		s.depositRegexes = append(s.depositRegexes, re)
+		m.depositRegexes = append(m.depositRegexes, re)
 	}
 
-	return s, nil
-}
-
-func selectGersemiInbox(s gersemiStruct) (gersemiStruct, error) {
-	mbox, err := s.c.Select("INBOX", false)
-	s.mbox = mbox
-	return s, err
-}
-
-func checkGersemiEmptyBox(s gersemiStruct) error {
-	return checkImapEmptyBox(s.mbox)
-}
-
-func fetchGersemiMessages(s gersemiStruct) gersemiStruct { // fixme same as in mimir
-	from := uint32(1)
-	to := s.mbox.Messages
-	seqset := new(imap.SeqSet)
-	seqset.AddRange(from, to)
-
-	s.section = &imap.BodySectionName{}
-	items := []imap.FetchItem{imap.FetchEnvelope, s.section.FetchItem(), imap.FetchUid}
-	s.messages = make(chan *imap.Message, 10)
-	s.done = make(chan error, 1)
-	go func() {
-		s.done <- s.c.Fetch(seqset, items, s.messages)
-	}()
-	return s
-}
-
-func ignoreGersemiEmptyBox(s gersemiStruct, e error) (gersemiStruct, error) {
-	var emptyBoxError EmptyBoxError
-	if errors.As(e, &emptyBoxError) {
-		log.Println("Mailbox is empty")
-		return s, nil
-	}
-	return s, e
+	return m, nil
 }
 
-func createTransactions(s gersemiStruct) (gersemiStruct, error) {
-	for msg := range s.messages {
-		s.message = msg
-		r := gott.R[gersemiStruct]{S: s}.
-			Bind(readGersemiMsgBody).
-			Bind(parseGersemiMimeMessage).
-			Bind(getGersemiBody).
-			Bind(readGersemiBody).
+func createTransactions(am AbstractMailbox) (AbstractMailbox, error) {
+	m := am.(*GersemiMailbox)
+	for msg := range m.messages() {
+		r := gott.R[AbstractImapMessage]{
+			S: &GersemiImapMessage{
+				ImapMessage: ImapMessage{
+					msg:      msg,
+					sect:     m.section(),
+					mimetype: m.config().Gersemi.MessageMime,
+				},
+				config:            m.config(),
+				withdrawalRegexes: m.withdrawalRegexes,
+				depositRegexes:    m.depositRegexes,
+				hc:                m.hc,
+				cli:               m.cli,
+			},
+		}.
+			Bind(readMessageBody).
+			Bind(parseMimeMessage).
+			Bind(getBody).
+			Bind(readBody).
 			Bind(createTransaction).
 			Bind(marshalBody).
 			Bind(createRequest).
@@ -189,156 +160,146 @@ 			Recover(ignoreGersemiInvalidMessage).
 			Recover(recoverGersemiMalformedMessage).
 			Recover(recoverGersemiErroredMessages)
 		if r.E != nil {
-			return s, r.E
-		}
-	}
-	return s, nil
-}
-
-func readGersemiMsgBody(s gersemiStruct) (gersemiStruct, error) {
-	r := s.message.GetBody(s.section)
-	if r == nil {
-		return s, MalformedMessageError{
-			Cause:     errors.New("no body in message"),
-			MessageID: s.message.Envelope.MessageId,
+			return m, r.E
 		}
 	}
-	messageBytes, err := io.ReadAll(r)
-	s.messageBytes = messageBytes
-	return s, err
-}
-
-func parseGersemiMimeMessage(s gersemiStruct) (gersemiStruct, error) {
-	r := bytes.NewReader(s.messageBytes)
-	m, err := message.Read(r)
-	s.mimeMessage = m
-	return s, err
-}
-
-func getGersemiBody(s gersemiStruct) (gersemiStruct, error) {
-	body, err := getNextPart(s.mimeMessage, s.message.Envelope.MessageId, s.config.Gersemi.MessageMime)
-	s.plainTextBodyReader = body
-	return s, err
+	return m, nil
 }
 
-func readGersemiBody(s gersemiStruct) (gersemiStruct, error) {
-	body, err := io.ReadAll(s.plainTextBodyReader)
-	s.plainTextBody = body
-	return s, err
-}
-
-func matchRegex(s gersemiStruct, match []string, groupNames []string) gersemiStruct {
+func matchRegex(m *GersemiImapMessage, match []string, groupNames []string) *GersemiImapMessage {
 	for groupIdx, group := range match {
 		names := groupNames[groupIdx]
 		for _, name := range strings.Split(names, "_") {
 			switch name {
 			case "TITLE":
-				s.title = regexp.MustCompile(" +").ReplaceAllString(group, " ")
+				m.title = regexp.MustCompile(" +").ReplaceAllString(group, " ")
 			case "SRC":
-				s.src = group
+				m.src = group
 			case "DST":
-				s.dst = regexp.MustCompile(" +").ReplaceAllString(group, " ")
+				m.dst = regexp.MustCompile(" +").ReplaceAllString(group, " ")
 			case "AMOUNTC":
-				s.amount = group
-				s.amount = strings.Replace(s.amount, ",", ".", -1)
+				m.amount = group
+				m.amount = strings.Replace(m.amount, ",", ".", -1)
 			case "AMOUNT":
-				s.amount = group
+				m.amount = group
 			case "DAY":
-				s.day = group
+				m.day = group
 			case "MONTH":
-				s.month = group
+				m.month = group
 			case "YEAR":
-				s.year = group
+				m.year = group
 			}
 		}
 	}
-	return s
+	return m
 }
 
-func createTransaction(s gersemiStruct) (gersemiStruct, error) {
-	s.src = s.config.Gersemi.DefaultSource
-	for _, wr := range s.withdrawalRegexes {
-		groupNames := wr.SubexpNames()
-		matches := wr.FindAllStringSubmatch(string(s.plainTextBody), -1)
+func createWithdrawal(m *GersemiImapMessage) *GersemiImapMessage {
+	for _, regex := range m.withdrawalRegexes {
+		groupNames := regex.SubexpNames()
+		matches := regex.FindAllStringSubmatch(string(m.messageBody()), -1)
 		if matches == nil {
 			continue
 		}
 		match := matches[0]
-		s = matchRegex(s, match, groupNames)
+		m = matchRegex(m, match, groupNames)
 		transaction := Withdrawal{
 			TransactionData: TransactionData{
 				Type:        "withdrawal",
-				Date:        s.year + "-" + s.month + "-" + s.day + "T06:00:00+00:00",
-				Amount:      s.amount,
-				Description: s.title,
+				Date:        m.year + "-" + m.month + "-" + m.day + "T06:00:00+00:00",
+				Amount:      m.amount,
+				Description: m.title,
 			},
-			SourceID:        s.config.Gersemi.Accounts[s.src],
-			DestinationName: s.dst,
+			SourceID:        m.config.Gersemi.Accounts[m.src],
+			DestinationName: m.dst,
 		}
 		body := GersemiRequestBody{
 			Transactions: []Transaction{transaction},
 		}
-		s.requestBody = body
-		return s, nil
+		m.requestBody = body
+		return m
 	}
-	for _, dr := range s.depositRegexes {
-		groupNames := dr.SubexpNames()
-		matches := dr.FindAllStringSubmatch(string(s.plainTextBody), -1)
+	return nil
+}
+
+func createDeposit(m *GersemiImapMessage) *GersemiImapMessage {
+	for _, regex := range m.depositRegexes {
+		groupNames := regex.SubexpNames()
+		matches := regex.FindAllStringSubmatch(string(m.messageBody()), -1)
 		if matches == nil {
 			continue
 		}
 		match := matches[0]
-		s = matchRegex(s, match, groupNames)
+		m = matchRegex(m, match, groupNames)
 		transaction := Deposit{
 			TransactionData: TransactionData{
 				Type:        "deposit",
-				Date:        s.year + "-" + s.month + "-" + s.day + "T06:00:00+00:00",
-				Amount:      s.amount,
-				Description: s.title,
+				Date:        m.year + "-" + m.month + "-" + m.day + "T06:00:00+00:00",
+				Amount:      m.amount,
+				Description: m.title,
 			},
-			SourceName:    s.src,
-			DestinationID: s.config.Gersemi.Accounts[s.dst],
+			SourceName:    m.src,
+			DestinationID: m.config.Gersemi.Accounts[m.dst],
 		}
 		body := GersemiRequestBody{
 			Transactions: []Transaction{transaction},
 		}
-		s.requestBody = body
-		return s, nil
+		m.requestBody = body
+		return m
 	}
+	return nil
+}
 
-	return s, InvalidMessageError{}
+func createTransaction(am AbstractImapMessage) (AbstractImapMessage, error) {
+	m := am.(*GersemiImapMessage)
+	m.src = m.config.Gersemi.DefaultSource
+	result := createWithdrawal(m)
+	if result != nil {
+		return result, nil
+	}
+	result = createDeposit(m)
+	if result != nil {
+		return result, nil
+	}
+
+	return m, InvalidMessageError{}
 }
 
-func marshalBody(s gersemiStruct) (_ gersemiStruct, err error) {
-	s.requestBodyBytes, err = json.Marshal(s.requestBody)
-	return s, err
+func marshalBody(am AbstractImapMessage) (_ AbstractImapMessage, err error) {
+	m := am.(*GersemiImapMessage)
+	m.requestBodyBytes, err = json.Marshal(m.requestBody)
+	return m, err
 }
 
-func createRequest(s gersemiStruct) (_ gersemiStruct, err error) {
-	s.request, err = http.NewRequest("POST", s.config.Gersemi.Firefly+"/api/v1/transactions", bytes.NewReader(s.requestBodyBytes))
-	s.request.Header.Add("Authorization", "Bearer "+s.config.Gersemi.FireflyToken)
-	s.request.Header.Add("Accept", "application/vnd.api+json")
-	s.request.Header.Add("Content-Type", "application/json")
-	return s, err
+func createRequest(am AbstractImapMessage) (_ AbstractImapMessage, err error) {
+	m := am.(*GersemiImapMessage)
+	m.request, err = http.NewRequest("POST", m.config.Gersemi.Firefly+"/api/v1/transactions", bytes.NewReader(m.requestBodyBytes))
+	m.request.Header.Add("Authorization", "Bearer "+m.config.Gersemi.FireflyToken)
+	m.request.Header.Add("Accept", "application/vnd.api+json")
+	m.request.Header.Add("Content-Type", "application/json")
+	return m, err
 }
 
-func doRequest(s gersemiStruct) (_ gersemiStruct, err error) {
-	s.response, err = s.hc.Do(s.request)
-	return s, err
+func doRequest(am AbstractImapMessage) (_ AbstractImapMessage, err error) {
+	m := am.(*GersemiImapMessage)
+	m.response, err = m.hc.Do(m.request)
+	return m, err
 }
 
-func handleHttpError(s gersemiStruct) (gersemiStruct, error) {
-	if s.response.StatusCode != 200 {
-		return s, fmt.Errorf(s.response.Status)
+func handleHttpError(am AbstractImapMessage) (AbstractImapMessage, error) {
+	m := am.(*GersemiImapMessage)
+	if m.response.StatusCode != 200 {
+		return m, fmt.Errorf(m.response.Status)
 	}
-	return s, nil
+	return m, nil
 }
 
-func moveGersemiMessage(s gersemiStruct) {
-	moveMsg(s.c, s.message, s.config.Gersemi.DoneFolder)
+func moveGersemiMessage(am AbstractImapMessage) { // todo collect messages out of loop and move all
+	m := am.(*GersemiImapMessage)
+	moveMsg(m.cli, m.msg, m.config.Gersemi.DoneFolder)
 }
 
-func ignoreGersemiInvalidMessage(s gersemiStruct, e error) (gersemiStruct, error) {
+func ignoreGersemiInvalidMessage(s AbstractImapMessage, e error) (AbstractImapMessage, error) {
 	var invalidMessageErr InvalidMessageError
 	if errors.As(e, &invalidMessageErr) {
 		log.Println(e.Error())
@@ -346,18 +307,3 @@ 		return s, nil
 	}
 	return s, e
 }
-
-func recoverGersemiMalformedMessage(s gersemiStruct, err error) (gersemiStruct, error) {
-	var malformedMessageError MalformedMessageError
-	if errors.As(err, &malformedMessageError) {
-		err = nil
-		log.Println(malformedMessageError.Error())
-		log.Println(string(s.messageBytes))
-	}
-	return s, err
-}
-
-func recoverGersemiErroredMessages(s gersemiStruct, err error) (gersemiStruct, error) {
-	log.Printf("message %s errored: %v\n", s.message.Envelope.MessageId, err)
-	return s, nil
-}




diff --git a/hermodr.go b/hermodr.go
index 2b070345719cf5c7da2e8cd44b6bd8199eab0ec1..e21e23942ab8a97d2f1c53d34b24686094e91ee2 100644
--- a/hermodr.go
+++ b/hermodr.go
@@ -32,20 +32,20 @@ 	PlainText string
 	Armour    string
 }
 
-func connect(args ...interface{}) (interface{}, error) {
+func connectHermodr(args ...interface{}) (interface{}, error) {
 	result := args[0].(Result)
 	c, err := client.Dial(result.Config.Hermodr.ImapAddress)
 	result.Client = c
 	return result, err
 }
 
-func login(args ...interface{}) error {
+func loginHermodr(args ...interface{}) error {
 	result := args[0].(Result)
 	err := result.Client.Login(result.Config.Hermodr.ImapUsername, result.Config.Hermodr.ImapPassword)
 	return err
 }
 
-func selectInbox(args ...interface{}) (interface{}, error) {
+func selectHermodrInbox(args ...interface{}) (interface{}, error) {
 	result := args[0].(Result)
 	mbox, err := result.Client.Select(result.Config.Hermodr.ImapFolderInbox, false)
 	result.Mailbox = mbox
@@ -59,13 +59,13 @@ 	for result.Mailbox.Messages > 0 {
 		r, err := gott.NewResult(result).
 			Bind(getMessage).
 			Bind(readMessage).
-			Bind(readBody).
+			Bind(readHermodrBody).
 			Map(composePlaintextBody).
 			Bind(encrypt).
 			Tee(send).
 			Tee(markRead).
 			Tee(moveMessage).
-			Bind(selectInbox).
+			Bind(selectHermodrInbox).
 			Finish()
 		if err != nil {
 			return r, err
@@ -107,7 +107,7 @@ 	result.Message = m
 	return result, err
 }
 
-func readBody(args ...interface{}) (interface{}, error) {
+func readHermodrBody(args ...interface{}) (interface{}, error) {
 	result := args[0].(Result)
 	body, err := io.ReadAll(result.Message.Body)
 	result.Body = string(body)
@@ -193,9 +193,9 @@
 func hermodr(config Config) {
 	r, err := gott.NewResult(Result{Config: config}).
 		SetLevelLog(gott.Debug).
-		Bind(connect).
-		Tee(login).
-		Bind(selectInbox).
+		Bind(connectHermodr).
+		Tee(loginHermodr).
+		Bind(selectHermodrInbox).
 		Bind(redirectMessages).
 		Finish()
 




diff --git a/imap.go b/imap.go
index 728babdccd0f8f449b5f86ae3fdf38edbddd3008..9e5bf0c3fff00194f30018b290b2fd086ad6a81c 100644
--- a/imap.go
+++ b/imap.go
@@ -14,12 +14,6 @@ 	"github.com/emersion/go-sasl"
 	"github.com/emersion/go-smtp"
 )
 
-type EmptyBoxError struct{}
-
-func (EmptyBoxError) Error() string {
-	return ""
-}
-
 func moveMsg(c *client.Client, msg *imap.Message, dest string) {
 	log.Printf("moving %v : %s from %s to %s\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address(), dest)
 	moveClient := move.NewClient(c)
@@ -149,10 +143,3 @@ 	item := imap.FormatFlagsOp(imap.AddFlags, true)
 	flags := []interface{}{imap.DeletedFlag}
 	return c.UidStore(seqset, item, flags, nil)
 }
-
-func checkImapEmptyBox(mbox *imap.MailboxStatus) error {
-	if mbox.Messages == 0 {
-		return EmptyBoxError{}
-	}
-	return nil
-}




diff --git a/imap_errors.go b/imap_errors.go
new file mode 100644
index 0000000000000000000000000000000000000000..628900e0d3e9e16033beea0152d3104775d0a2ad
--- /dev/null
+++ b/imap_errors.go
@@ -0,0 +1,20 @@
+package main
+
+import (
+	"fmt"
+)
+
+type EmptyBoxError struct{}
+
+func (EmptyBoxError) Error() string {
+	return ""
+}
+
+type MalformedMessageError struct {
+	MessageID string
+	Cause     error
+}
+
+func (e MalformedMessageError) Error() string {
+	return fmt.Sprintf("Malformed message %s: %s", e.MessageID, e.Cause.Error())
+}




diff --git a/imap_mailbox.go b/imap_mailbox.go
new file mode 100644
index 0000000000000000000000000000000000000000..50d3a471530675998722344c441f294ad434c7ca
--- /dev/null
+++ b/imap_mailbox.go
@@ -0,0 +1,146 @@
+package main
+
+import (
+	"errors"
+	"log"
+
+	"github.com/emersion/go-imap"
+	"github.com/emersion/go-imap/client"
+)
+
+type AbstractMailbox interface {
+	imapAddress() string
+	imapUsername() string
+	imapPassword() string
+	setClient(*client.Client)
+	client() *client.Client
+	config() Config
+	setMailbox(*imap.MailboxStatus)
+	mailbox() *imap.MailboxStatus
+	setSection(*imap.BodySectionName)
+	section() *imap.BodySectionName
+	messages() chan *imap.Message
+	done() chan error
+
+	setupChannels()
+}
+
+type Mailbox struct {
+	imapAdr  string
+	imapUser string
+	imapPass string
+	conf     Config
+
+	cli  *client.Client
+	mbox *imap.MailboxStatus
+	sect *imap.BodySectionName
+	msgs chan *imap.Message
+	dn   chan error
+}
+
+func (m Mailbox) imapAddress() string {
+	return m.imapAdr
+}
+
+func (m Mailbox) imapUsername() string {
+	return m.imapUser
+}
+
+func (m Mailbox) imapPassword() string {
+	return m.imapPass
+}
+
+func (m *Mailbox) setClient(c *client.Client) {
+	m.cli = c
+}
+
+func (m Mailbox) client() *client.Client {
+	return m.cli
+}
+
+func (m Mailbox) config() Config {
+	return m.conf
+}
+
+func (m *Mailbox) setMailbox(mbox *imap.MailboxStatus) {
+	m.mbox = mbox
+}
+
+func (m Mailbox) mailbox() *imap.MailboxStatus {
+	return m.mbox
+}
+
+func (m *Mailbox) setSection(sect *imap.BodySectionName) {
+	m.sect = sect
+}
+
+func (m Mailbox) section() *imap.BodySectionName {
+	return m.sect
+}
+
+func (m Mailbox) messages() chan *imap.Message {
+	return m.msgs
+}
+
+func (m Mailbox) done() chan error {
+	return m.dn
+}
+
+func (m *Mailbox) setupChannels() {
+	m.msgs = make(chan *imap.Message, 10)
+	m.dn = make(chan error, 1)
+}
+
+func connect(m AbstractMailbox) (AbstractMailbox, error) {
+	c, err := client.DialTLS(m.imapAddress(), nil)
+	m.setClient(c)
+	return m, err
+}
+
+func login(m AbstractMailbox) error {
+	return m.client().Login(m.imapUsername(), m.imapPassword())
+}
+
+func selectInbox(m AbstractMailbox) (AbstractMailbox, error) {
+	mbox, err := m.client().Select("INBOX", false) // todo configure inbox name
+	m.setMailbox(mbox)
+	return m, err
+}
+
+func checkEmptyBox(m AbstractMailbox) error {
+	if m.mailbox().Messages == 0 {
+		return EmptyBoxError{}
+	}
+	return nil
+}
+
+func fetchMessages(m AbstractMailbox) AbstractMailbox {
+	from := uint32(1)
+	to := m.mailbox().Messages
+	seqset := new(imap.SeqSet)
+	seqset.AddRange(from, to)
+
+	m.setSection(&imap.BodySectionName{})
+	items := []imap.FetchItem{imap.FetchEnvelope, m.section().FetchItem(), imap.FetchUid}
+	go func() {
+		m.done() <- m.client().Fetch(seqset, items, m.messages())
+	}()
+	return m
+}
+
+func ignoreEmptyBox(m AbstractMailbox, e error) (AbstractMailbox, error) {
+	var emptyBoxError EmptyBoxError
+	if errors.As(e, &emptyBoxError) {
+		log.Println("Mailbox is empty")
+		return m, nil
+	}
+	return m, e
+}
+
+func disconnect(m AbstractMailbox, e error) (AbstractMailbox, error) {
+	if m.client() != nil {
+		m.client().Logout()
+		m.client().Close()
+	}
+	return m, e
+}




diff --git a/imap_message.go b/imap_message.go
new file mode 100644
index 0000000000000000000000000000000000000000..3a9c5a4eed2cd864f071ff1cf5679cfa441bdf95
--- /dev/null
+++ b/imap_message.go
@@ -0,0 +1,126 @@
+package main
+
+import (
+	"bytes"
+	"errors"
+	"io"
+	"log"
+
+	"github.com/emersion/go-imap"
+	"github.com/emersion/go-message"
+)
+
+type AbstractImapMessage interface {
+	section() *imap.BodySectionName
+	message() *imap.Message
+	mimeType() string
+	setMessageBytes([]byte)
+	messageBytes() []byte
+	setMimeMessage(*message.Entity)
+	mimeMessage() *message.Entity
+	setMessageReader(io.Reader)
+	messageReader() io.Reader
+	setMessageBody([]byte)
+}
+
+type ImapMessage struct {
+	sect     *imap.BodySectionName
+	msg      *imap.Message
+	mimetype string
+
+	msgBytes []byte
+	mimeMsg  *message.Entity
+	bdyRdr   io.Reader
+	bdy      []byte
+}
+
+func (m ImapMessage) section() *imap.BodySectionName {
+	return m.sect
+}
+
+func (m ImapMessage) message() *imap.Message {
+	return m.msg
+}
+
+func (m ImapMessage) mimeType() string {
+	return m.mimetype
+}
+
+func (m *ImapMessage) setMessageBytes(b []byte) {
+	m.msgBytes = b
+}
+
+func (m ImapMessage) messageBytes() []byte {
+	return m.msgBytes
+}
+
+func (m *ImapMessage) setMimeMessage(msg *message.Entity) {
+	m.mimeMsg = msg
+}
+
+func (m ImapMessage) mimeMessage() *message.Entity {
+	return m.mimeMsg
+}
+
+func (m *ImapMessage) setMessageReader(r io.Reader) {
+	m.bdyRdr = r
+}
+
+func (m ImapMessage) messageReader() io.Reader {
+	return m.bdyRdr
+}
+
+func (m *ImapMessage) setMessageBody(b []byte) {
+	m.bdy = b
+}
+
+func (m ImapMessage) messageBody() []byte {
+	return m.bdy
+}
+
+func readMessageBody(m AbstractImapMessage) (AbstractImapMessage, error) {
+	r := m.message().GetBody(m.section())
+	if r == nil {
+		return m, MalformedMessageError{
+			Cause:     errors.New("no body in message"),
+			MessageID: m.message().Envelope.MessageId,
+		}
+	}
+	messageBytes, err := io.ReadAll(r)
+	m.setMessageBytes(messageBytes)
+	return m, err
+}
+
+func parseMimeMessage(m AbstractImapMessage) (AbstractImapMessage, error) {
+	r := bytes.NewReader(m.messageBytes())
+	message, err := message.Read(r)
+	m.setMimeMessage(message)
+	return m, err
+}
+
+func getBody(m AbstractImapMessage) (AbstractImapMessage, error) {
+	reader, err := getNextPart(m.mimeMessage(), m.message().Envelope.MessageId, m.mimeType())
+	m.setMessageReader(reader)
+	return m, err
+}
+
+func readBody(m AbstractImapMessage) (AbstractImapMessage, error) {
+	body, err := io.ReadAll(m.messageReader())
+	m.setMessageBody(body)
+	return m, err
+}
+
+func recoverGersemiMalformedMessage(m AbstractImapMessage, err error) (AbstractImapMessage, error) {
+	var malformedMessageError MalformedMessageError
+	if errors.As(err, &malformedMessageError) {
+		err = nil
+		log.Println(malformedMessageError.Error())
+		log.Println(string(m.messageBytes()))
+	}
+	return m, err
+}
+
+func recoverGersemiErroredMessages(m AbstractImapMessage, err error) (AbstractImapMessage, error) {
+	log.Printf("message %s errored: %v\n", m.message().Envelope.MessageId, err)
+	return m, nil
+}




diff --git a/mimir.go b/mimir.go
index 652b8e972578fa55b7edac0a8c3696e2ae89b65c..8c0bd0cd9fc6f14d1ab548dc9abbffe7878de933 100644
--- a/mimir.go
+++ b/mimir.go
@@ -49,15 +49,6 @@ 	_ "github.com/emersion/go-message/charset"
 	"github.com/emersion/go-msgauth/dkim"
 )
 
-type MalformedMessageError struct {
-	MessageID string
-	Cause     error
-}
-
-func (e MalformedMessageError) Error() string {
-	return fmt.Sprintf("Malformed message %s: %s", e.MessageID, e.Cause.Error())
-}
-
 type UnknownCategoryError struct {
 	MessageID string
 	Category  string
@@ -121,32 +112,12 @@ 	s.categoryRegexp = r
 	return s, err
 }
 
-func checkEmptyBox(s mimirStruct) error {
-	return checkImapEmptyBox(s.mbox)
-}
-
 func selectMimirInbox(s mimirStruct) (mimirStruct, error) {
 	mbox, err := s.c.Select(s.mboxName, true)
 	s.mbox = mbox
 	return s, err
 }
 
-func fetchMessages(s mimirStruct) mimirStruct {
-	from := uint32(1)
-	to := s.mbox.Messages
-	seqset := new(imap.SeqSet)
-	seqset.AddRange(from, to)
-
-	s.section = &imap.BodySectionName{}
-	items := []imap.FetchItem{imap.FetchEnvelope, s.section.FetchItem(), imap.FetchUid}
-	s.messages = make(chan *imap.Message, 10)
-	s.done = make(chan error, 1)
-	go func() {
-		s.done <- s.c.Fetch(seqset, items, s.messages)
-	}()
-	return s
-}
-
 func getMessageCategory(s mimirStruct) (mimirStruct, error) {
 	categoryInAddress := ""
 	recipients := append(s.message.Envelope.To, s.message.Envelope.Cc...)
@@ -196,13 +167,6 @@ 		}
 	}
 	s.dkimStatus = dkimStatus
 	return s, nil
-}
-
-func parseMimeMessage(s mimirStruct) (mimirStruct, error) {
-	r := bytes.NewReader(s.messageBytes)
-	m, err := message.Read(r)
-	s.mimeMessage = m
-	return s, err
 }
 
 func getNextPart(message *message.Entity, ID string, mimetype string) (io.Reader, error) {
@@ -343,15 +307,6 @@ }
 
 func expunge(s mimirStruct) error {
 	return s.c.Expunge(nil)
-}
-
-func ignoreEmptyBox(s mimirStruct, e error) (mimirStruct, error) {
-	var emptyBoxError EmptyBoxError
-	if errors.As(e, &emptyBoxError) {
-		log.Println("Mailbox is empty")
-		return s, nil
-	}
-	return s, e
 }
 
 func archiveInbox(db *sql.DB, mboxName string, c *client.Client, config Config) error {