asgard.git

commit d051fc674672089c01d3c3207a5e55794c23176b

Author: Adam Evyčędo <git@apiote.xyz>

make separate packages

  | 93 +++-------
  | 6 
  | 10 
  | 108 ++++++------
 go.mod | 2 
  | 84 +++++----
 himinbjorg/address.go | 22 ++
 himinbjorg/knownAddress.go | 11 +
 himinbjorg/lock.go | 29 +++
 himinbjorg/message.go | 28 +++
 idavollr/mailbox.go | 162 ++++++++++++++++++
 idavollr/message.go | 164 +++++++++++++++++++
  | 37 ++-
  | 2 
 imap_mailbox.go | 160 ------------------
 imap_message.go | 164 -------------------
 jotunheim/config.go | 124 ++++++++++++++
 main.go | 159 ++---------------
 mimir.go | 338 ---------------------------------------
 mimir/mimir.go | 343 ++++++++++++++++++++++++++++++++++++++++
  | 144 ++++++----------
  | 0 


diff --git a/db.go b/db.go
deleted file mode 100644
index 86501e2c9342891df44e36c17477dee771c6e5e2..0000000000000000000000000000000000000000
--- a/db.go
+++ /dev/null
@@ -1,328 +0,0 @@
-package main
-
-import (
-	"database/sql"
-	"fmt"
-	"os"
-	"path/filepath"
-	"regexp"
-	"strings"
-	"time"
-
-	"github.com/emersion/go-imap"
-
-	"github.com/mattn/go-sqlite3"
-	_ "github.com/mattn/go-sqlite3"
-)
-
-func makeNameAddress(a *imap.Address, encode bool) string {
-	personalName := ""
-	if encode {
-		fields := a.Format()
-		personalName = fields[0].(string)
-	} else {
-		personalName = a.PersonalName
-	}
-	if personalName != "" {
-		return fmt.Sprintf("%s <%s>", personalName, a.Address())
-	} else {
-		return a.Address()
-	}
-}
-
-type NoMessageError struct {
-	MessageID string
-}
-
-func (e NoMessageError) Error() string {
-	return "no message " + e.MessageID
-}
-
-type ArchiveEntry struct {
-	This     Message
-	Previous Message
-	Next     []Message
-}
-
-type Message struct {
-	ID       string
-	Subject  string
-	Body     string
-	Date     time.Time
-	Dkim     bool
-	Sender   string
-	Category string
-	Thread   string
-}
-
-func (m Message) FormatDate() string {
-	return m.Date.Format(time.RFC822Z)
-}
-
-func (m Message) RESubject() string {
-	if m.Subject[:3] != "Re:" {
-		return "Re: " + m.Subject
-	} else {
-		return m.Subject
-	}
-}
-
-func migrate(dbPath string) (*sql.DB, error) {
-	sql.Register("sqlite3_extended",
-		&sqlite3.SQLiteDriver{
-			ConnectHook: func(conn *sqlite3.SQLiteConn) error {
-				return conn.RegisterFunc("regexp", func(re, s string) (bool, error) {
-					return regexp.MatchString(re, s)
-				}, true)
-			},
-		},
-	)
-
-	home, err := os.UserHomeDir()
-	if err != nil {
-		return nil, fmt.Errorf("while getting user home dir: %w", err)
-	}
-	possibleDbDirs := []string{
-		"/var/lib/asgard",
-		home + "/.local/state/asgard",
-		".",
-	}
-
-	if dbPath != "" {
-		possibleDbDirs = append([]string{filepath.Dir(dbPath)}, possibleDbDirs...)
-	}
-
-	finalDbPath := ""
-	for _, possibleDbDir := range possibleDbDirs {
-		dirInfo, err := os.Stat(possibleDbDir)
-		if err == nil && dirInfo.IsDir() {
-			if filepath.Dir(dbPath) == possibleDbDir && dbPath != "" {
-				finalDbPath = dbPath
-			} else {
-				finalDbPath = possibleDbDir + "/asgard.db"
-			}
-			break
-		}
-	}
-	if finalDbPath == "" {
-		return nil, fmt.Errorf("no suitable db directory found")
-	}
-	db, err := open(finalDbPath)
-	_, err = db.Exec(`create table tyr_knownAddresses(address_from text, address_to text, ban boolean, unique(address, direction))`)
-	if err != nil && err.Error() != "table tyr_knownAddresses already exists" {
-		return nil, err
-	}
-	_, err = db.Exec(`create table tyr_locks(address text unique, token text, date date)`)
-	if err != nil && err.Error() != "table tyr_locks already exists" {
-		return nil, err
-	}
-
-	_, err = db.Exec(`create table mimir_archive(message_id text primary key, subject text, body text, date datetime, in_reply_to text, dkim_status bool, sender text, category text, root_id text, raw_message text, foreign key(in_reply_to) references mimir_archive(message_id))`)
-	if err != nil && err.Error() != "table mimir_archive already exists" {
-		return nil, err
-	}
-	_, err = db.Exec(`create table mimir_recipients(root_message_id text, recipient text, primary key(root_message_id, recipient), foreign key(root_message_id) references mimir_archive(message_id))`)
-	if err != nil && err.Error() != "table mimir_recipients already exists" {
-		return nil, err
-	}
-
-	_, err = db.Exec(`alter table tyr_locks add column recipient text`)
-	if err != nil && err.Error() != "duplicate column name: recipient" {
-		return nil, err
-	}
-
-	return db, nil
-}
-
-func open(dbPath string) (*sql.DB, error) {
-	path, err := filepath.Abs(dbPath)
-	if err != nil {
-		return nil, err
-	}
-	db, err := sql.Open("sqlite3_extended", path)
-	if err != nil {
-		return nil, err
-	}
-	return db, nil
-}
-
-func getAddressLock(db *sql.DB, address string) (Lock, error) {
-	address = strings.ToLower(address)
-	lock := Lock{
-		address: address,
-	}
-	row := db.QueryRow(`select token, date from tyr_locks where address = ?`, address)
-	err := row.Scan(&lock.token, &lock.date)
-	if err == sql.ErrNoRows {
-		return Lock{}, nil
-	} else {
-		return lock, err
-	}
-}
-
-func getLock(db *sql.DB, token string) (Lock, error) {
-	lock := Lock{
-		token: token,
-	}
-	row := db.QueryRow(`select address, date from tyr_locks where token = ?`, token)
-	err := row.Scan(&lock.address, &lock.date)
-	if err == sql.ErrNoRows {
-		return Lock{}, nil
-	} else {
-		return lock, err
-	}
-}
-
-func listLocks(db *sql.DB) ([]Lock, error) {
-	locks := []Lock{}
-	rows, err := db.Query(`select address, token from tyr_locks`)
-	if err != nil {
-		return locks, err
-	}
-	for rows.Next() {
-		lock := Lock{}
-		err := rows.Scan(&lock.address, &lock.token)
-		if err != nil {
-			return locks, err
-		}
-		locks = append(locks, lock)
-	}
-	return locks, nil
-}
-
-func insertLock(db *sql.DB, lock Lock) error {
-	_, err := db.Exec(`insert into tyr_locks values(?, ?, ?, ?) on
-	                  conflict(address) do nothing`,
-		lock.address, lock.token, lock.date, lock.recipient)
-	return err
-}
-
-func deleteLock(db *sql.DB, lock Lock) error {
-	_, err := db.Exec(`delete from tyr_locks where address = ?`, lock.address)
-	return err
-}
-
-func updateLock(db *sql.DB, lock Lock) error {
-	_, err := db.Exec(`update tyr_locks set date = ? where address = ?`, lock.date, lock.address)
-	return err
-}
-
-func getKnownAddress(db *sql.DB, address string) ([]KnownAddress, error) {
-	knownAddresses := []KnownAddress{}
-
-	rows, err := db.Query(`select address_from, address_to, ban from tyr_knownAddresses where ? REGEXP address_from`, address)
-	if err != nil {
-		return []KnownAddress{}, err
-	}
-	for rows.Next() {
-		knownAddress := KnownAddress{}
-		err := rows.Scan(&knownAddress.addressFrom, &knownAddress.addressTo, &knownAddress.ban)
-		if err != nil {
-			return []KnownAddress{}, err
-		}
-		knownAddresses = append(knownAddresses, knownAddress)
-	}
-	return knownAddresses, nil
-}
-
-func insertKnownAddress(db *sql.DB, address KnownAddress) error {
-	_, err := db.Exec(`insert into tyr_knownAddresses values(?, ?, ?) on
-	                  conflict(address_to, address_from) do nothing`,
-		address.addressFrom, address.addressTo, address.ban)
-	return err
-}
-
-func addArchiveEntry(db *sql.DB, messageID, category, subject string, body []byte, date time.Time, inReplyTo string, dkim bool, sender *imap.Address, messageBytes string) error {
-	var rootID string
-	row := db.QueryRow(`select root_id from mimir_archive where message_id = ?`, inReplyTo)
-	err := row.Scan(&rootID)
-	if err != nil {
-		if err == (sql.ErrNoRows) {
-			rootID = messageID
-		} else {
-			return err
-		}
-	}
-	_, err = db.Exec(`insert into mimir_archive values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, messageID, subject, body, date, inReplyTo, dkim, makeNameAddress(sender, false), category, rootID, messageBytes)
-
-	return err
-}
-
-func updateRecipients(db *sql.DB, address *imap.Address, msgID string) error {
-	var (
-		rootID    string
-		recipient sql.NullString
-	)
-	row := db.QueryRow(`select root_id, recipient from mimir_archive left outer join mimir_recipients on(root_id == root_message_id) where message_id = ? and sender = ?`, msgID, makeNameAddress(address, false))
-	err := row.Scan(&rootID, &recipient)
-	if err != nil {
-		return err
-	}
-	if !recipient.Valid {
-		_, err = db.Exec(`insert into mimir_recipients values(?, ?)`, rootID, address.Address())
-	}
-	return err
-}
-
-func getRecipients(db *sql.DB, messageID string, sender *imap.Address) ([]string, error) {
-	recipients := []string{}
-
-	rows, err := db.Query(`select recipient from mimir_archive join mimir_recipients on(root_id == root_message_id) where message_id = ?`, messageID)
-	if err != nil {
-		return recipients, err
-	}
-	for rows.Next() {
-		var recipient string
-		err := rows.Scan(&recipient)
-		if err != nil {
-			return recipients, err
-		}
-		if recipient != sender.Address() {
-			recipients = append(recipients, recipient)
-		}
-	}
-	return recipients, nil
-}
-
-func getArchivedThread(db *sql.DB, msgID string) ([]Message, error) {
-	messages := []Message{}
-	rows, err := db.Query(`select subject, body, date, dkim_status, sender, category, message_id from mimir_archive where root_id = ? order by date asc`, msgID)
-	if err != nil {
-		return messages, fmt.Errorf("while selecting thread: %w", err)
-	}
-	for rows.Next() {
-		message := Message{}
-		err := rows.Scan(&message.Subject, &message.Body, &message.Date, &message.Dkim, &message.Sender, &message.Category, &message.ID)
-		if err != nil {
-			return messages, fmt.Errorf("while scanning message in thread: %w", err)
-		}
-		messages = append(messages, message)
-	}
-	return messages, err
-}
-
-func getArchivedThreads(db *sql.DB, page int64) ([]Message, int, error) {
-	messages := []Message{}
-	var numThreads int
-	row := db.QueryRow(`select count(*) from mimir_archive where root_id == message_id`)
-	err := row.Scan(&numThreads)
-	if err != nil {
-		return messages, 0, fmt.Errorf("while selecting count: %w", err)
-	}
-	if numThreads == 0 {
-		return messages, numThreads, nil
-	}
-	rows, err := db.Query(`select subject, date, sender, category, message_id, root_id, CASE WHEN LENGTH(body) > 256 THEN substr(body,1,256) || '…' ELSE body END from mimir_archive where root_id = message_id order by date desc limit 12 offset ?`, (page-1)*12)
-	if err != nil {
-		return messages, 0, fmt.Errorf("while selecting threads: %w", err)
-	}
-	for rows.Next() {
-		msg := Message{}
-		err := rows.Scan(&msg.Subject, &msg.Date, &msg.Sender, &msg.Category, &msg.ID, &msg.Thread, &msg.Body)
-		if err != nil {
-			return messages, 0, fmt.Errorf("while scanning message: %w", err)
-		}
-		messages = append(messages, msg)
-	}
-	return messages, numThreads, nil
-}




diff --git a/eostre/eostre.go b/eostre/eostre.go
new file mode 100644
index 0000000000000000000000000000000000000000..8a922db590ed75f4cd884e29188a64d153593cad
--- /dev/null
+++ b/eostre/eostre.go
@@ -0,0 +1,166 @@
+package eostre
+
+// todo make gott
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"strings"
+
+	"apiote.xyz/p/asgard/jotunheim"
+
+	"github.com/ProtonMail/gopenpgp/v2/helper"
+	"github.com/bytesparadise/libasciidoc"
+	"github.com/bytesparadise/libasciidoc/pkg/configuration"
+	"github.com/emersion/go-imap"
+	"github.com/emersion/go-imap/client"
+	"github.com/emersion/go-message"
+	_ "github.com/emersion/go-message/charset"
+)
+
+func Eostre(config jotunheim.Config) (int, error) {
+	c, err := client.DialTLS(config.Eostre.ImapAddress, nil)
+	if err != nil {
+		log.Fatalln(err)
+	}
+	log.Println("Connected")
+	defer c.Logout()
+	if err := c.Login(config.Eostre.ImapUsername, config.Eostre.ImapPassword); err != nil {
+		log.Fatalln(err)
+	}
+	log.Println("Logged in")
+	mbox, err := c.Select("INBOX", false)
+	if err != nil {
+		log.Fatalln(err)
+	}
+	from := uint32(1)
+	to := mbox.Messages
+	seqset := new(imap.SeqSet)
+	seqset.AddRange(from, to)
+	messages := make(chan *imap.Message, 10)
+	done := make(chan error, 1)
+	section := &imap.BodySectionName{}
+	go func() {
+		done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, section.FetchItem(), imap.FetchFlags, imap.FetchUid}, messages)
+	}()
+	delSeqset := new(imap.SeqSet)
+	doneMessages := 0
+	for msg := range messages {
+		log.Println("doing", msg.Uid)
+		subject := msg.Envelope.Subject
+		sender := msg.Envelope.From[0]
+		if sender.Address() != config.Eostre.AuthorisedSender {
+			// todo remove message
+			log.Printf("ignoring from %s as not authorised\n", sender)
+			continue
+		}
+		bodyReader := msg.GetBody(section)
+		if bodyReader == nil {
+			log.Printf("body for %d is nil\n", msg.Uid)
+		}
+		m, err := message.Read(bodyReader)
+		if err != nil {
+			log.Fatalln(err)
+		}
+		t, params, err := m.Header.ContentType()
+		if err != nil {
+			log.Fatalln(err)
+		}
+		var part *message.Entity
+		if t == "text/plain" {
+			part = m
+		} else if t == "multipart/encrypted" && params["protocol"] == "application/pgp-encrypted" {
+			mr := m.MultipartReader()
+			for {
+				p, err := mr.NextPart()
+				if err == io.EOF {
+					break
+				} else if err != nil {
+					return 0, fmt.Errorf("while reading next part: %w", err)
+				}
+				t, _, err := p.Header.ContentType()
+				if err != nil {
+					log.Fatalln(err)
+				}
+				if t == "application/octet-stream" {
+					bodyReader := p.Body
+					body, err := io.ReadAll(bodyReader)
+					if err != nil {
+						log.Fatalln(err)
+					}
+					decrypted, err := helper.DecryptVerifyMessageArmored(config.Eostre.PublicKey, config.Eostre.PrivateKey, []byte(config.Eostre.PrivateKeyPass), string(body))
+					if err != nil {
+						log.Fatalln(err)
+					}
+					part, err = message.Read(strings.NewReader(decrypted))
+					if err != nil {
+						log.Fatalln(err)
+					}
+				}
+			}
+		} else {
+			log.Printf("%d is not PGP encrypted and is not plain-text\n", msg.Uid)
+			continue
+		}
+		encryptedSubject := part.Header.Get("Subject")
+		if encryptedSubject != "" {
+			subject = encryptedSubject
+		}
+		partBodyReader := part.Body
+		body, err := io.ReadAll(partBodyReader)
+		if err != nil {
+			log.Fatalln(err)
+		}
+		asciidoc, _, _ := strings.Cut(string(body), "\n-- ")
+
+		filename := msg.Envelope.Date.Format("20060102.html")
+		_, err = os.Stat(filename)
+		if err == nil {
+			asciidoc = "=== " + msg.Envelope.Date.Format("03:04 -0700") + "\n\n_" + subject + "_\n\n" + asciidoc
+		} else {
+			if errors.Is(err, os.ErrNotExist) {
+				asciidoc = "== " + msg.Envelope.Date.Format("Jan 2") + "\n\n_" + subject + "_\n\n" + asciidoc
+			} else {
+				log.Fatalln(err)
+			}
+		}
+
+		reader := strings.NewReader(asciidoc)
+		writer := bytes.NewBuffer([]byte{})
+		config := configuration.NewConfiguration()
+		libasciidoc.Convert(reader, writer, config)
+
+		html := string(writer.Bytes())
+		html = strings.ReplaceAll(html, "<div class=\"sect2\">\n", "")
+		html = strings.ReplaceAll(html, "<div class=\"sect1\">\n", "")
+		html = strings.ReplaceAll(html, "<div class=\"sectionbody\">\n", "")
+		html = strings.ReplaceAll(html, "<div class=\"paragraph\">\n", "")
+		html = strings.ReplaceAll(html, "</div>\n", "")
+		f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
+		if err != nil {
+			log.Fatalln(err)
+		}
+		defer f.Close()
+		f.WriteString(html)
+		delSeqset.AddNum(msg.Uid)
+		doneMessages++
+	}
+	if !delSeqset.Empty() {
+		item := imap.FormatFlagsOp(imap.AddFlags, true)
+		flags := []interface{}{imap.DeletedFlag}
+		_, err = c.Select("INBOX", false)
+		if err != nil {
+			log.Fatalln(err)
+		}
+		err = c.UidStore(delSeqset, item, flags, nil)
+		if err != nil {
+			log.Fatalln(err)
+		}
+		return doneMessages, c.Expunge(nil)
+	}
+	return doneMessages, nil
+}




diff --git a/eostre/eostre_2.go b/eostre/eostre_2.go
new file mode 100644
index 0000000000000000000000000000000000000000..b6a30cb5f4ccb9a09baaf8c14065bad3521830e8
--- /dev/null
+++ b/eostre/eostre_2.go
@@ -0,0 +1,171 @@
+package eostre
+
+import (
+	"bytes"
+	"encoding/base64"
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"os/exec"
+	"path"
+	"strings"
+	"time"
+
+	"apiote.xyz/p/asgard/jotunheim"
+
+	"filippo.io/age"
+	"github.com/emersion/go-imap"
+	"github.com/emersion/go-imap/client"
+	"github.com/emersion/go-message"
+	_ "github.com/emersion/go-message/charset"
+	"github.com/emersion/go-sasl"
+	"github.com/emersion/go-smtp"
+)
+
+func DownloadDiary(config jotunheim.Config) error {
+	c, err := client.DialTLS(config.Eostre.DiaryImapAddress, nil)
+	if err != nil {
+		log.Fatalln(err)
+	}
+	defer c.Close()
+	log.Println("Connected")
+	defer c.Logout()
+	if err := c.Login(config.Eostre.DiaryImapUsername, config.Eostre.DiaryImapPassword); err != nil {
+		log.Fatalln(err)
+	}
+	log.Println("Logged in")
+	mbox, err := c.Select("INBOX", false)
+	if err != nil {
+		log.Fatalln(err)
+	}
+	from := uint32(1)
+	to := mbox.Messages
+	seqset := new(imap.SeqSet)
+	seqset.AddRange(from, to)
+	messages := make(chan *imap.Message, 10)
+	done := make(chan error, 1)
+	section := &imap.BodySectionName{}
+	go func() {
+		done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, section.FetchItem(), imap.FetchFlags, imap.FetchUid, imap.FetchInternalDate}, messages)
+	}()
+	var latestMessage message.Entity
+	var latestDate time.Time
+	for msg := range messages {
+		subject := msg.Envelope.Subject
+		if subject != config.Eostre.DiarySubject {
+			log.Printf("ignoring subject %s\n", subject)
+			continue
+		}
+		sender := msg.Envelope.From[0]
+		if sender.Address() != config.Eostre.DiarySender {
+			log.Printf("ignoring from %s as not authorised\n", sender)
+			continue
+		}
+		bodyReader := msg.GetBody(section)
+		if bodyReader == nil {
+			log.Printf("body for %d is nil\n", msg.Uid)
+			continue
+		}
+		m, err := message.Read(bodyReader)
+		if err != nil {
+			log.Println(err)
+			continue
+		}
+		t, _, err := m.Header.ContentType()
+		if err != nil {
+			log.Println(err)
+			continue
+		}
+		if t != "application/age" {
+			log.Printf("%d is not age\n", msg.Uid)
+			continue
+		}
+		log.Printf("msg %v is after %v?\n", msg.InternalDate, latestDate)
+		if msg.InternalDate.After(latestDate) {
+			log.Printf("yes\n")
+			latestDate = msg.InternalDate
+			latestMessage = *m
+		}
+	}
+	if latestMessage.Header.Len() == 0 {
+		return fmt.Errorf("No messages with diary")
+	}
+	identity, err := age.ParseX25519Identity(config.Eostre.DiaryPrivateKey)
+	if err != nil {
+		log.Fatalf("Failed to parse private key: %v", err)
+	}
+	r, err := age.Decrypt(latestMessage.Body, identity)
+	if err != nil {
+		log.Fatalf("Failed to open encrypted file: %v", err)
+	}
+	out, err := os.Create("diary.epub")
+	if err != nil {
+		log.Fatalf("Failed to create diary file: %v", err)
+	}
+	if _, err := io.Copy(out, r); err != nil {
+		log.Fatalf("Failed to read encrypted file: %v", err)
+	}
+	out.Close()
+	return nil
+}
+
+func UpdateDiary(config jotunheim.Config) error {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return err
+	}
+	_, err = exec.Command(path.Join(home, ".local/share/asgard/eostre.sh")).Output()
+	return err
+}
+
+func SendDiary(config jotunheim.Config) error {
+	recipient, err := age.ParseX25519Recipient(config.Eostre.DiaryPublicKey)
+	if err != nil {
+		log.Fatalf("Failed to parse public key: %v", err)
+	}
+	b := &bytes.Buffer{}
+	b64 := base64.NewEncoder(base64.StdEncoding, b)
+	in, err := os.Open("diary.epub")
+	if err != nil {
+		log.Fatalf("Failed to open decrypted file: %v", err)
+	}
+	w, err := age.Encrypt(b64, recipient)
+	if err != nil {
+		log.Fatalf("Failed to encrypt file: %v", err)
+	}
+	if _, err := io.Copy(w, in); err != nil {
+		log.Fatalf("Failed to read decrypted file: %v", err)
+	}
+	in.Close()
+	w.Close()
+	b64.Close()
+	os.Remove("diary.epub")
+
+	now := time.Now().Format("20060102T150405Z0700")
+	msg := strings.NewReader("To: " + config.Eostre.DiaryRecipient + "\r\n" +
+		"From: " + config.Eostre.DiarySender + "\r\n" +
+		"Date: " + now + "\r\n" +
+		"Message-ID: " + now + "_eostre@apiote.xyz\r\n" +
+		"MIME-Version: 1.0\r\n" +
+		"Subject: Diary\r\n" +
+		"Content-Type: application/age; name=diary.epub.age\r\n" +
+		"Content-Transfer-Encoding: base64\r\n" +
+		"\r\n" +
+		string(b.Bytes()),
+	)
+	c, err := smtp.DialTLS(config.Eostre.DiarySmtpAddress, nil)
+	if err != nil {
+		log.Fatalf("Failed smtp dial: %v", err)
+	}
+	auth := sasl.NewPlainClient("", config.Eostre.DiarySmtpUsername, config.Eostre.DiarySmtpPassword)
+	err = c.Auth(auth)
+	if err != nil {
+		log.Fatalf("Failed smtp auth: %v", err)
+	}
+	err = c.SendMail(config.Eostre.DiarySender, []string{config.Eostre.DiaryRecipient}, msg)
+	if err != nil {
+		return err
+	}
+	return nil
+}




diff --git a/eostre.go b/eostre.go
deleted file mode 100644
index 810f016df087f50769b20d2f3081bc3d99f7d05b..0000000000000000000000000000000000000000
--- a/eostre.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package main
-
-// todo make gott
-
-import (
-	"bytes"
-	"errors"
-	"fmt"
-	"io"
-	"log"
-	"os"
-	"strings"
-
-	"github.com/ProtonMail/gopenpgp/v2/helper"
-	"github.com/bytesparadise/libasciidoc"
-	"github.com/bytesparadise/libasciidoc/pkg/configuration"
-	"github.com/emersion/go-imap"
-	"github.com/emersion/go-imap/client"
-	"github.com/emersion/go-message"
-	_ "github.com/emersion/go-message/charset"
-)
-
-func eostre(config Config) (int, error) {
-	c, err := client.DialTLS(config.Eostre.ImapAddress, nil)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	log.Println("Connected")
-	defer c.Logout()
-	if err := c.Login(config.Eostre.ImapUsername, config.Eostre.ImapPassword); err != nil {
-		log.Fatalln(err)
-	}
-	log.Println("Logged in")
-	mbox, err := c.Select("INBOX", false)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	from := uint32(1)
-	to := mbox.Messages
-	seqset := new(imap.SeqSet)
-	seqset.AddRange(from, to)
-	messages := make(chan *imap.Message, 10)
-	done := make(chan error, 1)
-	section := &imap.BodySectionName{}
-	go func() {
-		done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, section.FetchItem(), imap.FetchFlags, imap.FetchUid}, messages)
-	}()
-	delSeqset := new(imap.SeqSet)
-	doneMessages := 0
-	for msg := range messages {
-		log.Println("doing", msg.Uid)
-		subject := msg.Envelope.Subject
-		sender := msg.Envelope.From[0]
-		if sender.Address() != config.Eostre.AuthorisedSender {
-			// todo remove message
-			log.Printf("ignoring from %s as not authorised\n", sender)
-			continue
-		}
-		bodyReader := msg.GetBody(section)
-		if bodyReader == nil {
-			log.Printf("body for %d is nil\n", msg.Uid)
-		}
-		m, err := message.Read(bodyReader)
-		if err != nil {
-			log.Fatalln(err)
-		}
-		t, params, err := m.Header.ContentType()
-		if err != nil {
-			log.Fatalln(err)
-		}
-		var part *message.Entity
-		if t == "text/plain" {
-			part = m
-		} else if t == "multipart/encrypted" && params["protocol"] == "application/pgp-encrypted" {
-			mr := m.MultipartReader()
-			for {
-				p, err := mr.NextPart()
-				if err == io.EOF {
-					break
-				} else if err != nil {
-					return 0, fmt.Errorf("while reading next part: %w", err)
-				}
-				t, _, err := p.Header.ContentType()
-				if err != nil {
-					log.Fatalln(err)
-				}
-				if t == "application/octet-stream" {
-					bodyReader := p.Body
-					body, err := io.ReadAll(bodyReader)
-					if err != nil {
-						log.Fatalln(err)
-					}
-					decrypted, err := helper.DecryptVerifyMessageArmored(config.Eostre.PublicKey, config.Eostre.PrivateKey, []byte(config.Eostre.PrivateKeyPass), string(body))
-					if err != nil {
-						log.Fatalln(err)
-					}
-					part, err = message.Read(strings.NewReader(decrypted))
-					if err != nil {
-						log.Fatalln(err)
-					}
-				}
-			}
-		} else {
-			log.Printf("%d is not PGP encrypted and is not plain-text\n", msg.Uid)
-			continue
-		}
-		encryptedSubject := part.Header.Get("Subject")
-		if encryptedSubject != "" {
-			subject = encryptedSubject
-		}
-		partBodyReader := part.Body
-		body, err := io.ReadAll(partBodyReader)
-		if err != nil {
-			log.Fatalln(err)
-		}
-		asciidoc, _, _ := strings.Cut(string(body), "\n-- ")
-
-		filename := msg.Envelope.Date.Format("20060102.html")
-		_, err = os.Stat(filename)
-		if err == nil {
-			asciidoc = "=== " + msg.Envelope.Date.Format("03:04 -0700") + "\n\n_" + subject + "_\n\n" + asciidoc
-		} else {
-			if errors.Is(err, os.ErrNotExist) {
-				asciidoc = "== " + msg.Envelope.Date.Format("Jan 2") + "\n\n_" + subject + "_\n\n" + asciidoc
-			} else {
-				log.Fatalln(err)
-			}
-		}
-
-		reader := strings.NewReader(asciidoc)
-		writer := bytes.NewBuffer([]byte{})
-		config := configuration.NewConfiguration()
-		libasciidoc.Convert(reader, writer, config)
-
-		html := string(writer.Bytes())
-		html = strings.ReplaceAll(html, "<div class=\"sect2\">\n", "")
-		html = strings.ReplaceAll(html, "<div class=\"sect1\">\n", "")
-		html = strings.ReplaceAll(html, "<div class=\"sectionbody\">\n", "")
-		html = strings.ReplaceAll(html, "<div class=\"paragraph\">\n", "")
-		html = strings.ReplaceAll(html, "</div>\n", "")
-		f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
-		if err != nil {
-			log.Fatalln(err)
-		}
-		defer f.Close()
-		f.WriteString(html)
-		delSeqset.AddNum(msg.Uid)
-		doneMessages++
-	}
-	if !delSeqset.Empty() {
-		item := imap.FormatFlagsOp(imap.AddFlags, true)
-		flags := []interface{}{imap.DeletedFlag}
-		_, err = c.Select("INBOX", false)
-		if err != nil {
-			log.Fatalln(err)
-		}
-		err = c.UidStore(delSeqset, item, flags, nil)
-		if err != nil {
-			log.Fatalln(err)
-		}
-		return doneMessages, c.Expunge(nil)
-	}
-	return doneMessages, nil
-}




diff --git a/eostre_2.go b/eostre_2.go
deleted file mode 100644
index 92de049ff5e7a0b9f55de6785268e04303e6444c..0000000000000000000000000000000000000000
--- a/eostre_2.go
+++ /dev/null
@@ -1,169 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"encoding/base64"
-	"fmt"
-	"io"
-	"log"
-	"os"
-	"os/exec"
-	"path"
-	"strings"
-	"time"
-
-	"filippo.io/age"
-	"github.com/emersion/go-imap"
-	"github.com/emersion/go-imap/client"
-	"github.com/emersion/go-message"
-	_ "github.com/emersion/go-message/charset"
-	"github.com/emersion/go-sasl"
-	"github.com/emersion/go-smtp"
-)
-
-func downloadDiary(config Config) error {
-	c, err := client.DialTLS(config.Eostre.DiaryImapAddress, nil)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	defer c.Close()
-	log.Println("Connected")
-	defer c.Logout()
-	if err := c.Login(config.Eostre.DiaryImapUsername, config.Eostre.DiaryImapPassword); err != nil {
-		log.Fatalln(err)
-	}
-	log.Println("Logged in")
-	mbox, err := c.Select("INBOX", false)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	from := uint32(1)
-	to := mbox.Messages
-	seqset := new(imap.SeqSet)
-	seqset.AddRange(from, to)
-	messages := make(chan *imap.Message, 10)
-	done := make(chan error, 1)
-	section := &imap.BodySectionName{}
-	go func() {
-		done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, section.FetchItem(), imap.FetchFlags, imap.FetchUid, imap.FetchInternalDate}, messages)
-	}()
-	var latestMessage message.Entity
-	var latestDate time.Time
-	for msg := range messages {
-		subject := msg.Envelope.Subject
-		if subject != config.Eostre.DiarySubject {
-			log.Printf("ignoring subject %s\n", subject)
-			continue
-		}
-		sender := msg.Envelope.From[0]
-		if sender.Address() != config.Eostre.DiarySender {
-			log.Printf("ignoring from %s as not authorised\n", sender)
-			continue
-		}
-		bodyReader := msg.GetBody(section)
-		if bodyReader == nil {
-			log.Printf("body for %d is nil\n", msg.Uid)
-			continue
-		}
-		m, err := message.Read(bodyReader)
-		if err != nil {
-			log.Println(err)
-			continue
-		}
-		t, _, err := m.Header.ContentType()
-		if err != nil {
-			log.Println(err)
-			continue
-		}
-		if t != "application/age" {
-			log.Printf("%d is not age\n", msg.Uid)
-			continue
-		}
-		log.Printf("msg %v is after %v?\n", msg.InternalDate, latestDate)
-		if msg.InternalDate.After(latestDate) {
-			log.Printf("yes\n")
-			latestDate = msg.InternalDate
-			latestMessage = *m
-		}
-	}
-	if latestMessage.Header.Len() == 0 {
-		return fmt.Errorf("No messages with diary")
-	}
-	identity, err := age.ParseX25519Identity(config.Eostre.DiaryPrivateKey)
-	if err != nil {
-		log.Fatalf("Failed to parse private key: %v", err)
-	}
-	r, err := age.Decrypt(latestMessage.Body, identity)
-	if err != nil {
-		log.Fatalf("Failed to open encrypted file: %v", err)
-	}
-	out, err := os.Create("diary.epub")
-	if err != nil {
-		log.Fatalf("Failed to create diary file: %v", err)
-	}
-	if _, err := io.Copy(out, r); err != nil {
-		log.Fatalf("Failed to read encrypted file: %v", err)
-	}
-	out.Close()
-	return nil
-}
-
-func updateDiary(config Config) error {
-	home, err := os.UserHomeDir()
-	if err != nil {
-		return err
-	}
-	_, err = exec.Command(path.Join(home, ".local/share/asgard/eostre.sh")).Output()
-	return err
-}
-
-func sendDiary(config Config) error {
-	recipient, err := age.ParseX25519Recipient(config.Eostre.DiaryPublicKey)
-	if err != nil {
-		log.Fatalf("Failed to parse public key: %v", err)
-	}
-	b := &bytes.Buffer{}
-	b64 := base64.NewEncoder(base64.StdEncoding, b)
-	in, err := os.Open("diary.epub")
-	if err != nil {
-		log.Fatalf("Failed to open decrypted file: %v", err)
-	}
-	w, err := age.Encrypt(b64, recipient)
-	if err != nil {
-		log.Fatalf("Failed to encrypt file: %v", err)
-	}
-	if _, err := io.Copy(w, in); err != nil {
-		log.Fatalf("Failed to read decrypted file: %v", err)
-	}
-	in.Close()
-	w.Close()
-	b64.Close()
-	os.Remove("diary.epub")
-
-	now := time.Now().Format("20060102T150405Z0700")
-	msg := strings.NewReader("To: " + config.Eostre.DiaryRecipient + "\r\n" +
-		"From: " + config.Eostre.DiarySender + "\r\n" +
-		"Date: " + now + "\r\n" +
-		"Message-ID: " + now + "_eostre@apiote.xyz\r\n" +
-		"MIME-Version: 1.0\r\n" +
-		"Subject: Diary\r\n" +
-		"Content-Type: application/age; name=diary.epub.age\r\n" +
-		"Content-Transfer-Encoding: base64\r\n" +
-		"\r\n" +
-		string(b.Bytes()),
-	)
-	c, err := smtp.DialTLS(config.Eostre.DiarySmtpAddress, nil)
-	if err != nil {
-		log.Fatalf("Failed smtp dial: %v", err)
-	}
-	auth := sasl.NewPlainClient("", config.Eostre.DiarySmtpUsername, config.Eostre.DiarySmtpPassword)
-	err = c.Auth(auth)
-	if err != nil {
-		log.Fatalf("Failed smtp auth: %v", err)
-	}
-	err = c.SendMail(config.Eostre.DiarySender, []string{config.Eostre.DiaryRecipient}, msg)
-	if err != nil {
-		return err
-	}
-	return nil
-}




diff --git a/gersemi/gersemi.go b/gersemi/gersemi.go
new file mode 100644
index 0000000000000000000000000000000000000000..c41bf6e3f965ad765b06016fc615c6d3ae3b637e
--- /dev/null
+++ b/gersemi/gersemi.go
@@ -0,0 +1,313 @@
+package gersemi
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log"
+	"net/http"
+	"regexp"
+	"strings"
+	"time"
+
+	"apiote.xyz/p/asgard/idavollr"
+	"apiote.xyz/p/asgard/jotunheim"
+
+	"apiote.xyz/p/gott/v2"
+	_ "github.com/emersion/go-message/charset"
+)
+
+type InvalidMessageError struct{}
+
+func (InvalidMessageError) Error() string {
+	return "message does not match withdrawal or deposit regex"
+}
+
+type Transaction interface {
+	IsTransaction()
+}
+
+type TransactionData struct {
+	Type        string `json:"type"`
+	Date        string `json:"date"` //yyyy-mm-ddT00:00:00+00:00
+	Amount      string `json:"amount"`
+	Description string `json:"description"`
+}
+
+type Withdrawal struct {
+	TransactionData
+	SourceID        string `json:"source_id"`
+	DestinationName string `json:"destination_name"`
+}
+
+func (w Withdrawal) IsTransaction() {}
+
+type Deposit struct {
+	TransactionData
+	SourceName    string `json:"source_name"`
+	DestinationID string `json:"destination_id"`
+}
+
+func (w Deposit) IsTransaction() {}
+
+type GersemiRequestBody struct {
+	Transactions []Transaction `json:"transactions"`
+}
+
+type GersemiMailbox struct {
+	idavollr.Mailbox
+	hc                *http.Client
+	withdrawalRegexes []*regexp.Regexp
+	depositRegexes    []*regexp.Regexp
+}
+
+type GersemiImapMessage struct {
+	idavollr.ImapMessage
+	hc                *http.Client
+	withdrawalRegexes []*regexp.Regexp
+	depositRegexes    []*regexp.Regexp
+	config            jotunheim.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 jotunheim.Config) error {
+	timeout, _ := time.ParseDuration("60s")
+	mailbox := &GersemiMailbox{
+		Mailbox: idavollr.Mailbox{
+			MboxName: config.Gersemi.ImapInbox,
+			ImapAdr:  config.Gersemi.ImapAddress,
+			ImapUser: config.Gersemi.ImapUsername,
+			ImapPass: config.Gersemi.ImapPassword,
+			Conf:     config,
+		},
+		hc: &http.Client{
+			Timeout: timeout,
+		},
+	}
+	mailbox.SetupChannels()
+
+	r := gott.R[idavollr.AbstractMailbox]{
+		S: mailbox,
+	}.
+		Bind(prepareRegexes).
+		Bind(idavollr.Connect).
+		Tee(idavollr.Login).
+		Bind(idavollr.SelectInbox).
+		Tee(idavollr.CheckEmptyBox).
+		Map(idavollr.FetchMessages).
+		Tee(createTransactions).
+		Tee(idavollr.CheckFetchError).
+		Recover(idavollr.IgnoreEmptyBox).
+		Recover(idavollr.Disconnect)
+
+	return r.E
+}
+
+func prepareRegexes(am idavollr.AbstractMailbox) (idavollr.AbstractMailbox, error) {
+	m := am.(*GersemiMailbox)
+	for i, wr := range m.Config().Gersemi.WithdrawalRegexes {
+		re, err := regexp.Compile(wr)
+		if err != nil {
+			return m, fmt.Errorf("while compiling withdrawal regex %d: %w", i, err)
+		}
+		m.withdrawalRegexes = append(m.withdrawalRegexes, re)
+	}
+
+	for i, dr := range m.Config().Gersemi.DepositRegexes {
+		re, err := regexp.Compile(dr)
+		if err != nil {
+			return m, fmt.Errorf("while compiling deposit regex %d: %w", i, err)
+		}
+		m.depositRegexes = append(m.depositRegexes, re)
+	}
+
+	return m, nil
+}
+
+func createTransactions(am idavollr.AbstractMailbox) error {
+	m := am.(*GersemiMailbox)
+	for msg := range m.Messages() {
+		imapMessage := idavollr.ImapMessage{
+			Msg:      msg,
+			Sect:     m.Section(),
+			Mimetype: m.Config().Gersemi.MessageMime,
+		}
+		imapMessage.SetClient(m.Client())
+		r := gott.R[idavollr.AbstractImapMessage]{
+			S: &GersemiImapMessage{
+				ImapMessage:       imapMessage,
+				config:            m.Config(),
+				withdrawalRegexes: m.withdrawalRegexes,
+				depositRegexes:    m.depositRegexes,
+				hc:                m.hc,
+			},
+		}.
+			Bind(idavollr.ReadMessageBody).
+			Bind(idavollr.ParseMimeMessage).
+			Bind(idavollr.GetBody).
+			Bind(idavollr.ReadBody).
+			Bind(createTransaction).
+			Bind(marshalBody).
+			Bind(createRequest).
+			Bind(doRequest).
+			Bind(handleHttpError).
+			Tee(moveMessage).
+			Recover(ignoreInvalidMessage).
+			Recover(idavollr.RecoverMalformedMessage).
+			Recover(idavollr.RecoverErroredMessages)
+		if r.E != nil {
+			return r.E
+		}
+	}
+	return nil
+}
+
+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":
+				m.title = regexp.MustCompile(" +").ReplaceAllString(group, " ")
+			case "SRC":
+				m.src = group
+			case "DST":
+				m.dst = regexp.MustCompile(" +").ReplaceAllString(group, " ")
+			case "AMOUNTC":
+				m.amount = group
+				m.amount = strings.Replace(m.amount, ",", ".", -1)
+			case "AMOUNT":
+				m.amount = group
+			case "DAY":
+				m.day = group
+			case "MONTH":
+				m.month = group
+			case "YEAR":
+				m.year = group
+			}
+		}
+	}
+	return m
+}
+
+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]
+		m = matchRegex(m, match, groupNames)
+		transaction := Withdrawal{
+			TransactionData: TransactionData{
+				Type:        "withdrawal",
+				Date:        m.year + "-" + m.month + "-" + m.day + "T06:00:00+00:00",
+				Amount:      m.amount,
+				Description: m.title,
+			},
+			SourceID:        m.config.Gersemi.Accounts[m.src],
+			DestinationName: m.dst,
+		}
+		body := GersemiRequestBody{
+			Transactions: []Transaction{transaction},
+		}
+		m.requestBody = body
+		return m
+	}
+	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]
+		m = matchRegex(m, match, groupNames)
+		transaction := Deposit{
+			TransactionData: TransactionData{
+				Type:        "deposit",
+				Date:        m.year + "-" + m.month + "-" + m.day + "T06:00:00+00:00",
+				Amount:      m.amount,
+				Description: m.title,
+			},
+			SourceName:    m.src,
+			DestinationID: m.config.Gersemi.Accounts[m.dst],
+		}
+		body := GersemiRequestBody{
+			Transactions: []Transaction{transaction},
+		}
+		m.requestBody = body
+		return m
+	}
+	return nil
+}
+
+func createTransaction(am idavollr.AbstractImapMessage) (idavollr.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(am idavollr.AbstractImapMessage) (_ idavollr.AbstractImapMessage, err error) {
+	m := am.(*GersemiImapMessage)
+	m.requestBodyBytes, err = json.Marshal(m.requestBody)
+	return m, err
+}
+
+func createRequest(am idavollr.AbstractImapMessage) (_ idavollr.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(am idavollr.AbstractImapMessage) (_ idavollr.AbstractImapMessage, err error) {
+	m := am.(*GersemiImapMessage)
+	m.response, err = m.hc.Do(m.request)
+	return m, err
+}
+
+func handleHttpError(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) {
+	m := am.(*GersemiImapMessage)
+	if m.response.StatusCode != 200 {
+		return m, fmt.Errorf(m.response.Status)
+	}
+	return m, nil
+}
+
+func moveMessage(am idavollr.AbstractImapMessage) error { // TODO collect messages out of loop and move all
+	m := am.(*GersemiImapMessage)
+	return idavollr.MoveMsg(m.Client(), m.Msg, m.config.Gersemi.DoneFolder)
+}
+
+func ignoreInvalidMessage(s idavollr.AbstractImapMessage, e error) (idavollr.AbstractImapMessage, error) {
+	var invalidMessageErr InvalidMessageError
+	if errors.As(e, &invalidMessageErr) {
+		log.Println(e.Error())
+		return s, nil
+	}
+	return s, e
+}




diff --git a/gersemi.go b/gersemi.go
deleted file mode 100644
index bf6b12dc858aa474b54e78f24b150fed5a796358..0000000000000000000000000000000000000000
--- a/gersemi.go
+++ /dev/null
@@ -1,309 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"log"
-	"net/http"
-	"regexp"
-	"strings"
-	"time"
-
-	"apiote.xyz/p/gott/v2"
-	_ "github.com/emersion/go-message/charset"
-)
-
-type InvalidMessageError struct{}
-
-func (InvalidMessageError) Error() string {
-	return "message does not match withdrawal or deposit regex"
-}
-
-type Transaction interface {
-	IsTransaction()
-}
-
-type TransactionData struct {
-	Type        string `json:"type"`
-	Date        string `json:"date"` //yyyy-mm-ddT00:00:00+00:00
-	Amount      string `json:"amount"`
-	Description string `json:"description"`
-}
-
-type Withdrawal struct {
-	TransactionData
-	SourceID        string `json:"source_id"`
-	DestinationName string `json:"destination_name"`
-}
-
-func (w Withdrawal) IsTransaction() {}
-
-type Deposit struct {
-	TransactionData
-	SourceName    string `json:"source_name"`
-	DestinationID string `json:"destination_id"`
-}
-
-func (w Deposit) IsTransaction() {}
-
-type GersemiRequestBody struct {
-	Transactions []Transaction `json:"transactions"`
-}
-
-type GersemiMailbox struct {
-	Mailbox
-	hc                *http.Client
-	withdrawalRegexes []*regexp.Regexp
-	depositRegexes    []*regexp.Regexp
-}
-
-type GersemiImapMessage struct {
-	ImapMessage
-	hc                *http.Client
-	withdrawalRegexes []*regexp.Regexp
-	depositRegexes    []*regexp.Regexp
-	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 {
-	timeout, _ := time.ParseDuration("60s")
-	mailbox := &GersemiMailbox{
-		Mailbox: Mailbox{
-			mboxName: config.Gersemi.ImapInbox,
-			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(connect).
-		Tee(login).
-		Bind(selectInbox).
-		Tee(checkEmptyBox).
-		Map(fetchMessages).
-		Tee(createTransactions).
-		Tee(checkFetchError).
-		Recover(ignoreEmptyBox).
-		Recover(disconnect)
-
-	return r.E
-}
-
-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 m, fmt.Errorf("while compiling withdrawal regex %d: %w", i, err)
-		}
-		m.withdrawalRegexes = append(m.withdrawalRegexes, re)
-	}
-
-	for i, dr := range m.config().Gersemi.DepositRegexes {
-		re, err := regexp.Compile(dr)
-		if err != nil {
-			return m, fmt.Errorf("while compiling deposit regex %d: %w", i, err)
-		}
-		m.depositRegexes = append(m.depositRegexes, re)
-	}
-
-	return m, nil
-}
-
-func createTransactions(am 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,
-					cli:      am.client(),
-				},
-				config:            m.config(),
-				withdrawalRegexes: m.withdrawalRegexes,
-				depositRegexes:    m.depositRegexes,
-				hc:                m.hc,
-			},
-		}.
-			Bind(readMessageBody).
-			Bind(parseMimeMessage).
-			Bind(getBody).
-			Bind(readBody).
-			Bind(createTransaction).
-			Bind(marshalBody).
-			Bind(createRequest).
-			Bind(doRequest).
-			Bind(handleHttpError).
-			Tee(moveGersemiMessage).
-			Recover(ignoreGersemiInvalidMessage).
-			Recover(recoverMalformedMessage).
-			Recover(recoverErroredMessages)
-		if r.E != nil {
-			return r.E
-		}
-	}
-	return nil
-}
-
-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":
-				m.title = regexp.MustCompile(" +").ReplaceAllString(group, " ")
-			case "SRC":
-				m.src = group
-			case "DST":
-				m.dst = regexp.MustCompile(" +").ReplaceAllString(group, " ")
-			case "AMOUNTC":
-				m.amount = group
-				m.amount = strings.Replace(m.amount, ",", ".", -1)
-			case "AMOUNT":
-				m.amount = group
-			case "DAY":
-				m.day = group
-			case "MONTH":
-				m.month = group
-			case "YEAR":
-				m.year = group
-			}
-		}
-	}
-	return m
-}
-
-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]
-		m = matchRegex(m, match, groupNames)
-		transaction := Withdrawal{
-			TransactionData: TransactionData{
-				Type:        "withdrawal",
-				Date:        m.year + "-" + m.month + "-" + m.day + "T06:00:00+00:00",
-				Amount:      m.amount,
-				Description: m.title,
-			},
-			SourceID:        m.config.Gersemi.Accounts[m.src],
-			DestinationName: m.dst,
-		}
-		body := GersemiRequestBody{
-			Transactions: []Transaction{transaction},
-		}
-		m.requestBody = body
-		return m
-	}
-	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]
-		m = matchRegex(m, match, groupNames)
-		transaction := Deposit{
-			TransactionData: TransactionData{
-				Type:        "deposit",
-				Date:        m.year + "-" + m.month + "-" + m.day + "T06:00:00+00:00",
-				Amount:      m.amount,
-				Description: m.title,
-			},
-			SourceName:    m.src,
-			DestinationID: m.config.Gersemi.Accounts[m.dst],
-		}
-		body := GersemiRequestBody{
-			Transactions: []Transaction{transaction},
-		}
-		m.requestBody = body
-		return m
-	}
-	return nil
-}
-
-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(am AbstractImapMessage) (_ AbstractImapMessage, err error) {
-	m := am.(*GersemiImapMessage)
-	m.requestBodyBytes, err = json.Marshal(m.requestBody)
-	return m, 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(am AbstractImapMessage) (_ AbstractImapMessage, err error) {
-	m := am.(*GersemiImapMessage)
-	m.response, err = m.hc.Do(m.request)
-	return m, err
-}
-
-func handleHttpError(am AbstractImapMessage) (AbstractImapMessage, error) {
-	m := am.(*GersemiImapMessage)
-	if m.response.StatusCode != 200 {
-		return m, fmt.Errorf(m.response.Status)
-	}
-	return m, nil
-}
-
-func moveGersemiMessage(am AbstractImapMessage) error { // TODO collect messages out of loop and move all
-	m := am.(*GersemiImapMessage)
-	return moveMsg(m.client(), m.msg, m.config.Gersemi.DoneFolder)
-}
-
-func ignoreGersemiInvalidMessage(s AbstractImapMessage, e error) (AbstractImapMessage, error) {
-	var invalidMessageErr InvalidMessageError
-	if errors.As(e, &invalidMessageErr) {
-		log.Println(e.Error())
-		return s, nil
-	}
-	return s, e
-}




diff --git a/go.mod b/go.mod
index 4578277033bb4a1610856ba5e0a6e7b2c285b254..78cdc780b4b427e2f99d3a30ac9c35349d605e4f 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module git.apiote.xyz/me/asgard
+module apiote.xyz/p/asgard
 
 go 1.18
 




diff --git a/hermodr/hermodr.go b/hermodr/hermodr.go
new file mode 100644
index 0000000000000000000000000000000000000000..bc191aa24b153ae53c09cbca6fa905c2186860de
--- /dev/null
+++ b/hermodr/hermodr.go
@@ -0,0 +1,184 @@
+package hermodr
+
+import (
+	"io"
+	"net/mail"
+	"strings"
+
+	"apiote.xyz/p/asgard/idavollr"
+	"apiote.xyz/p/asgard/jotunheim"
+
+	"apiote.xyz/p/gott/v2"
+	"github.com/ProtonMail/gopenpgp/v2/helper"
+	"github.com/emersion/go-imap"
+	"github.com/emersion/go-smtp"
+)
+
+type EmptyMessageError struct{}
+
+func (EmptyMessageError) Error() string {
+	return "Server didn't return message body"
+}
+
+type HermodrMailbox struct {
+	idavollr.Mailbox
+}
+
+type HermodrImapMessage struct {
+	idavollr.ImapMessage
+	config  jotunheim.Config
+	literal imap.Literal
+	mailMsg *mail.Message
+	body    string
+	plain   string
+	armour  string
+}
+
+func redirectMessages(am idavollr.AbstractMailbox) error {
+	m := am.(*HermodrMailbox)
+	for msg := range m.Messages() {
+		imapMessage := idavollr.ImapMessage{
+			Msg:  msg,
+			Sect: m.Section(),
+		}
+		imapMessage.SetClient(am.Client())
+		r := gott.R[idavollr.AbstractImapMessage]{
+			S: &HermodrImapMessage{
+				ImapMessage: imapMessage,
+				config:      m.Conf,
+			},
+		}.
+			Bind(getBodySection).
+			Bind(readLiteralMessage).
+			Bind(readLiteralBody).
+			Map(composePlaintextBody).
+			Bind(encrypt).
+			Tee(send).
+			Tee(markRead).
+			Tee(moveMessage)
+
+		if r.E != nil {
+			return r.E
+		}
+	}
+	return nil
+}
+
+func getBodySection(m idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) {
+	hm := m.(*HermodrImapMessage)
+	r := hm.Message().GetBody(m.Section())
+	if r == nil {
+		return hm, EmptyMessageError{}
+	}
+	hm.literal = r
+	return hm, nil
+}
+
+func readLiteralMessage(m idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) {
+	hm := m.(*HermodrImapMessage)
+	msg, err := mail.ReadMessage(hm.literal)
+	hm.mailMsg = msg
+	return hm, err
+}
+
+func readLiteralBody(m idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) {
+	hm := m.(*HermodrImapMessage)
+	body, err := io.ReadAll(hm.mailMsg.Body)
+	hm.body = string(body)
+	return hm, err
+}
+
+func composePlaintextBody(m idavollr.AbstractImapMessage) idavollr.AbstractImapMessage {
+	hm := m.(*HermodrImapMessage)
+	header := hm.mailMsg.Header
+	plainText := "Content-Type: " + header.Get("Content-Type") + "; protected-headers=\"v1\"\r\n"
+	plainText += "From: " + header.Get("From") + "\r\n"
+	plainText += "Message-ID: " + header.Get("Message-ID") + "\r\n"
+	plainText += "Subject: " + header.Get("Subject") + "\r\n"
+	plainText += "\r\n"
+	plainText += string(hm.body)
+	hm.plain = plainText
+	return hm
+}
+
+func encrypt(m idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) {
+	hm := m.(*HermodrImapMessage)
+	armour, err := helper.EncryptMessageArmored(hm.config.Hermodr.PublicKey, hm.plain)
+	hm.armour = armour
+	return hm, err
+}
+
+func send(m idavollr.AbstractImapMessage) error {
+	hm := m.(*HermodrImapMessage)
+	from := hm.mailMsg.Header.Get("From")
+	date := hm.mailMsg.Header.Get("Date")
+	messageID := hm.mailMsg.Header.Get("Message-ID")
+	to := []string{hm.config.Hermodr.Recipient}
+	msg := strings.NewReader("To: " + hm.config.Hermodr.Recipient + "\r\n" +
+		"From: " + from + "\r\n" +
+		"Date: " + date + "\r\n" +
+		"Message-ID: " + messageID + "\r\n" +
+		"MIME-Version: 1.0\r\n" +
+		"Subject: ...\r\n" +
+		"Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"---------------------997d365ae018229dc62ea2ff6b617cac\"; charset=utf-8\r\n" +
+		"\r\n" +
+		"This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)\r\n" +
+		"-----------------------997d365ae018229dc62ea2ff6b617cac\r\n" +
+		"Content-Type: application/pgp-encrypted\r\n" +
+		"Content-Description: PGP/MIME version identification\r\n" +
+		"\r\n" +
+		"Version: 1\r\n" +
+		"\r\n" +
+		"-----------------------997d365ae018229dc62ea2ff6b617cac\r\n" +
+		"Content-Type: application/octet-stream; name=\"encrypted.asc\"\r\n" +
+		"Content-Description: OpenPGP encrypted message\r\n" +
+		"Content-Disposition: inline; filename=\"encrypted.asc\"\r\n" +
+		"\r\n" +
+		hm.armour +
+		"\r\n" +
+		"\r\n" +
+		"-----------------------997d365ae018229dc62ea2ff6b617cac--\r\n")
+	err := smtp.SendMail(hm.config.Hermodr.SmtpServer, nil, hm.config.Hermodr.SmtpUsername, to, msg)
+	return err
+}
+
+func markRead(m idavollr.AbstractImapMessage) error {
+	seqset := new(imap.SeqSet)
+	seqset.AddNum(1) // TODO collect seqset
+	item := imap.FormatFlagsOp(imap.AddFlags, true)
+	flags := []interface{}{imap.SeenFlag}
+	err := m.Client().Store(seqset, item, flags, nil) // TODO store outside of loop
+	return err
+}
+
+func moveMessage(m idavollr.AbstractImapMessage) error { // TODO collect messages out of loop and move all
+	hm := m.(*HermodrImapMessage)
+	return idavollr.MoveMsg(m.Client(), hm.Msg, hm.config.Hermodr.ImapFolderRedirected)
+}
+
+func Hermodr(config jotunheim.Config) error {
+	mailbox := &HermodrMailbox{
+		Mailbox: idavollr.Mailbox{
+			MboxName: config.Hermodr.ImapFolderInbox,
+			ImapAdr:  config.Hermodr.ImapAddress,
+			ImapUser: config.Hermodr.ImapUsername,
+			ImapPass: config.Hermodr.ImapPassword,
+			Conf:     config,
+		},
+	}
+
+	mailbox.SetupChannels()
+	r := gott.R[idavollr.AbstractMailbox]{
+		S: mailbox,
+	}.
+		Bind(idavollr.Connect).
+		Tee(idavollr.Login).
+		Bind(idavollr.SelectInbox).
+		Tee(idavollr.CheckEmptyBox).
+		Map(idavollr.FetchMessages).
+		Tee(redirectMessages).
+		Recover(idavollr.IgnoreEmptyBox).
+		Recover(idavollr.Disconnect)
+
+	return r.E
+}




diff --git a/hermodr.go b/hermodr.go
deleted file mode 100644
index 7f73a9c7b9ca4b4f2527e9d16fe241c62be315cb..0000000000000000000000000000000000000000
--- a/hermodr.go
+++ /dev/null
@@ -1,180 +0,0 @@
-package main
-
-import (
-	"io"
-	"net/mail"
-	"strings"
-
-	"apiote.xyz/p/gott/v2"
-	"github.com/ProtonMail/gopenpgp/v2/helper"
-	"github.com/emersion/go-imap"
-	"github.com/emersion/go-smtp"
-)
-
-type EmptyMessageError struct{}
-
-func (EmptyMessageError) Error() string {
-	return "Server didn't return message body"
-}
-
-type HermodrMailbox struct {
-	Mailbox
-}
-
-type HermodrImapMessage struct {
-	ImapMessage
-	config  Config
-	literal imap.Literal
-	mailMsg *mail.Message
-	body    string
-	plain   string
-	armour  string
-}
-
-func redirectMessages(am AbstractMailbox) error {
-	m := am.(*HermodrMailbox)
-	for msg := range m.messages() {
-		r := gott.R[AbstractImapMessage]{
-			S: &HermodrImapMessage{
-				ImapMessage: ImapMessage{
-					msg:  msg,
-					sect: m.section(),
-					cli:  am.client(),
-				},
-				config: m.conf,
-			},
-		}.
-			Bind(getBodySection).
-			Bind(readLiteralMessage).
-			Bind(readLiteralBody).
-			Map(composePlaintextBody).
-			Bind(encrypt).
-			Tee(send).
-			Tee(markRead).
-			Tee(moveMessage)
-
-		if r.E != nil {
-			return r.E
-		}
-	}
-	return nil
-}
-
-func getBodySection(m AbstractImapMessage) (AbstractImapMessage, error) {
-	hm := m.(*HermodrImapMessage)
-	r := hm.message().GetBody(m.section())
-	if r == nil {
-		return hm, EmptyMessageError{}
-	}
-	hm.literal = r
-	return hm, nil
-}
-
-func readLiteralMessage(m AbstractImapMessage) (AbstractImapMessage, error) {
-	hm := m.(*HermodrImapMessage)
-	msg, err := mail.ReadMessage(hm.literal)
-	hm.mailMsg = msg
-	return hm, err
-}
-
-func readLiteralBody(m AbstractImapMessage) (AbstractImapMessage, error) {
-	hm := m.(*HermodrImapMessage)
-	body, err := io.ReadAll(hm.mailMsg.Body)
-	hm.body = string(body)
-	return hm, err
-}
-
-func composePlaintextBody(m AbstractImapMessage) AbstractImapMessage {
-	hm := m.(*HermodrImapMessage)
-	header := hm.mailMsg.Header
-	plainText := "Content-Type: " + header.Get("Content-Type") + "; protected-headers=\"v1\"\r\n"
-	plainText += "From: " + header.Get("From") + "\r\n"
-	plainText += "Message-ID: " + header.Get("Message-ID") + "\r\n"
-	plainText += "Subject: " + header.Get("Subject") + "\r\n"
-	plainText += "\r\n"
-	plainText += string(hm.body)
-	hm.plain = plainText
-	return hm
-}
-
-func encrypt(m AbstractImapMessage) (AbstractImapMessage, error) {
-	hm := m.(*HermodrImapMessage)
-	armour, err := helper.EncryptMessageArmored(hm.config.Hermodr.PublicKey, hm.plain)
-	hm.armour = armour
-	return hm, err
-}
-
-func send(m AbstractImapMessage) error {
-	hm := m.(*HermodrImapMessage)
-	from := hm.mailMsg.Header.Get("From")
-	date := hm.mailMsg.Header.Get("Date")
-	messageID := hm.mailMsg.Header.Get("Message-ID")
-	to := []string{hm.config.Hermodr.Recipient}
-	msg := strings.NewReader("To: " + hm.config.Hermodr.Recipient + "\r\n" +
-		"From: " + from + "\r\n" +
-		"Date: " + date + "\r\n" +
-		"Message-ID: " + messageID + "\r\n" +
-		"MIME-Version: 1.0\r\n" +
-		"Subject: ...\r\n" +
-		"Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"---------------------997d365ae018229dc62ea2ff6b617cac\"; charset=utf-8\r\n" +
-		"\r\n" +
-		"This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)\r\n" +
-		"-----------------------997d365ae018229dc62ea2ff6b617cac\r\n" +
-		"Content-Type: application/pgp-encrypted\r\n" +
-		"Content-Description: PGP/MIME version identification\r\n" +
-		"\r\n" +
-		"Version: 1\r\n" +
-		"\r\n" +
-		"-----------------------997d365ae018229dc62ea2ff6b617cac\r\n" +
-		"Content-Type: application/octet-stream; name=\"encrypted.asc\"\r\n" +
-		"Content-Description: OpenPGP encrypted message\r\n" +
-		"Content-Disposition: inline; filename=\"encrypted.asc\"\r\n" +
-		"\r\n" +
-		hm.armour +
-		"\r\n" +
-		"\r\n" +
-		"-----------------------997d365ae018229dc62ea2ff6b617cac--\r\n")
-	err := smtp.SendMail(hm.config.Hermodr.SmtpServer, nil, hm.config.Hermodr.SmtpUsername, to, msg)
-	return err
-}
-
-func markRead(m AbstractImapMessage) error {
-	seqset := new(imap.SeqSet)
-	seqset.AddNum(1) // TODO collect seqset
-	item := imap.FormatFlagsOp(imap.AddFlags, true)
-	flags := []interface{}{imap.SeenFlag}
-	err := m.client().Store(seqset, item, flags, nil) // TODO store outside of loop
-	return err
-}
-
-func moveMessage(m AbstractImapMessage) error { // TODO collect messages out of loop and move all
-	hm := m.(*HermodrImapMessage)
-	return moveMsg(m.client(), hm.msg, hm.config.Hermodr.ImapFolderRedirected)
-}
-
-func hermodr(config Config) error {
-	mailbox := &HermodrMailbox{
-		Mailbox: Mailbox{
-			mboxName: config.Hermodr.ImapFolderInbox,
-			imapAdr:  config.Hermodr.ImapAddress,
-			imapUser: config.Hermodr.ImapUsername,
-			imapPass: config.Hermodr.ImapPassword,
-			conf:     config,
-		},
-	}
-
-	mailbox.setupChannels()
-	r := gott.R[AbstractMailbox]{
-		S: mailbox,
-	}.
-		Bind(connect).
-		Tee(login).
-		Bind(selectInbox).
-		Tee(checkEmptyBox).
-		Map(fetchMessages).
-		Tee(redirectMessages).
-		Recover(ignoreEmptyBox).
-		Recover(disconnect)
-
-	return r.E
-}




diff --git a/himinbjorg/address.go b/himinbjorg/address.go
new file mode 100644
index 0000000000000000000000000000000000000000..1564103ededf745e4cd3a33038aec6edef67ed85
--- /dev/null
+++ b/himinbjorg/address.go
@@ -0,0 +1,22 @@
+package himinbjorg
+
+import (
+	"fmt"
+
+	"github.com/emersion/go-imap"
+)
+
+func MakeNameAddress(a *imap.Address, encode bool) string {
+	personalName := ""
+	if encode {
+		fields := a.Format()
+		personalName = fields[0].(string)
+	} else {
+		personalName = a.PersonalName
+	}
+	if personalName != "" {
+		return fmt.Sprintf("%s <%s>", personalName, a.Address())
+	} else {
+		return a.Address()
+	}
+}




diff --git a/himinbjorg/db.go b/himinbjorg/db.go
new file mode 100644
index 0000000000000000000000000000000000000000..e1b05da04c649971f79b7edf2a61878469c42780
--- /dev/null
+++ b/himinbjorg/db.go
@@ -0,0 +1,291 @@
+package himinbjorg
+
+import (
+	"database/sql"
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/emersion/go-imap"
+
+	"github.com/mattn/go-sqlite3"
+	_ "github.com/mattn/go-sqlite3"
+)
+
+type NoMessageError struct {
+	MessageID string
+}
+
+func (e NoMessageError) Error() string {
+	return "no message " + e.MessageID
+}
+
+type ArchiveEntry struct {
+	This     Message
+	Previous Message
+	Next     []Message
+}
+
+func Migrate(dbPath string) (*sql.DB, error) {
+	sql.Register("sqlite3_extended",
+		&sqlite3.SQLiteDriver{
+			ConnectHook: func(conn *sqlite3.SQLiteConn) error {
+				return conn.RegisterFunc("regexp", func(re, s string) (bool, error) {
+					return regexp.MatchString(re, s)
+				}, true)
+			},
+		},
+	)
+
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return nil, fmt.Errorf("while getting user home dir: %w", err)
+	}
+	possibleDbDirs := []string{
+		"/var/lib/asgard",
+		home + "/.local/state/asgard",
+		".",
+	}
+
+	if dbPath != "" {
+		possibleDbDirs = append([]string{filepath.Dir(dbPath)}, possibleDbDirs...)
+	}
+
+	finalDbPath := ""
+	for _, possibleDbDir := range possibleDbDirs {
+		dirInfo, err := os.Stat(possibleDbDir)
+		if err == nil && dirInfo.IsDir() {
+			if filepath.Dir(dbPath) == possibleDbDir && dbPath != "" {
+				finalDbPath = dbPath
+			} else {
+				finalDbPath = possibleDbDir + "/asgard.db"
+			}
+			break
+		}
+	}
+	if finalDbPath == "" {
+		return nil, fmt.Errorf("no suitable db directory found")
+	}
+	db, err := open(finalDbPath)
+	_, err = db.Exec(`create table tyr_knownAddresses(address_from text, address_to text, ban boolean, unique(address, direction))`)
+	if err != nil && err.Error() != "table tyr_knownAddresses already exists" {
+		return nil, err
+	}
+	_, err = db.Exec(`create table tyr_locks(address text unique, token text, date date)`)
+	if err != nil && err.Error() != "table tyr_locks already exists" {
+		return nil, err
+	}
+
+	_, err = db.Exec(`create table mimir_archive(message_id text primary key, subject text, body text, date datetime, in_reply_to text, dkim_status bool, sender text, category text, root_id text, raw_message text, foreign key(in_reply_to) references mimir_archive(message_id))`)
+	if err != nil && err.Error() != "table mimir_archive already exists" {
+		return nil, err
+	}
+	_, err = db.Exec(`create table mimir_recipients(root_message_id text, recipient text, primary key(root_message_id, recipient), foreign key(root_message_id) references mimir_archive(message_id))`)
+	if err != nil && err.Error() != "table mimir_recipients already exists" {
+		return nil, err
+	}
+
+	_, err = db.Exec(`alter table tyr_locks add column recipient text`)
+	if err != nil && err.Error() != "duplicate column name: recipient" {
+		return nil, err
+	}
+
+	return db, nil
+}
+
+func open(dbPath string) (*sql.DB, error) {
+	path, err := filepath.Abs(dbPath)
+	if err != nil {
+		return nil, err
+	}
+	db, err := sql.Open("sqlite3_extended", path)
+	if err != nil {
+		return nil, err
+	}
+	return db, nil
+}
+
+func GetAddressLock(db *sql.DB, address string) (Lock, error) {
+	address = strings.ToLower(address)
+	lock := Lock{
+		Address: address,
+	}
+	row := db.QueryRow(`select token, date from tyr_locks where address = ?`, address)
+	err := row.Scan(&lock.Token, &lock.Date)
+	if err == sql.ErrNoRows {
+		return Lock{}, nil
+	} else {
+		return lock, err
+	}
+}
+
+func GetLock(db *sql.DB, token string) (Lock, error) {
+	lock := Lock{
+		Token: token,
+	}
+	row := db.QueryRow(`select address, date from tyr_locks where token = ?`, token)
+	err := row.Scan(&lock.Address, &lock.Date)
+	if err == sql.ErrNoRows {
+		return Lock{}, nil
+	} else {
+		return lock, err
+	}
+}
+
+func ListLocks(db *sql.DB) ([]Lock, error) {
+	locks := []Lock{}
+	rows, err := db.Query(`select address, token from tyr_locks`)
+	if err != nil {
+		return locks, err
+	}
+	for rows.Next() {
+		lock := Lock{}
+		err := rows.Scan(&lock.Address, &lock.Token)
+		if err != nil {
+			return locks, err
+		}
+		locks = append(locks, lock)
+	}
+	return locks, nil
+}
+
+func InsertLock(db *sql.DB, lock Lock) error {
+	_, err := db.Exec(`insert into tyr_locks values(?, ?, ?, ?) on
+	                  conflict(address) do nothing`,
+		lock.Address, lock.Token, lock.Date, lock.Recipient)
+	return err
+}
+
+func DeleteLock(db *sql.DB, lock Lock) error {
+	_, err := db.Exec(`delete from tyr_locks where address = ?`, lock.Address)
+	return err
+}
+
+func UpdateLock(db *sql.DB, lock Lock) error {
+	_, err := db.Exec(`update tyr_locks set date = ? where address = ?`, lock.Date, lock.Address)
+	return err
+}
+
+func GetKnownAddress(db *sql.DB, address string) ([]KnownAddress, error) {
+	knownAddresses := []KnownAddress{}
+
+	rows, err := db.Query(`select address_from, address_to, ban from tyr_knownAddresses where ? REGEXP address_from`, address)
+	if err != nil {
+		return []KnownAddress{}, err
+	}
+	for rows.Next() {
+		knownAddress := KnownAddress{}
+		err := rows.Scan(&knownAddress.AddressFrom, &knownAddress.AddressTo, &knownAddress.Ban)
+		if err != nil {
+			return []KnownAddress{}, err
+		}
+		knownAddresses = append(knownAddresses, knownAddress)
+	}
+	return knownAddresses, nil
+}
+
+func InsertKnownAddress(db *sql.DB, address KnownAddress) error {
+	_, err := db.Exec(`insert into tyr_knownAddresses values(?, ?, ?) on
+	                  conflict(address_to, address_from) do nothing`,
+		address.AddressFrom, address.AddressTo, address.Ban)
+	return err
+}
+
+func AddArchiveEntry(db *sql.DB, messageID, category, subject string, body []byte, date time.Time, inReplyTo string, dkim bool, sender *imap.Address, messageBytes string) error {
+	var rootID string
+	row := db.QueryRow(`select root_id from mimir_archive where message_id = ?`, inReplyTo)
+	err := row.Scan(&rootID)
+	if err != nil {
+		if err == (sql.ErrNoRows) {
+			rootID = messageID
+		} else {
+			return err
+		}
+	}
+
+	_, err = db.Exec(`insert into mimir_archive values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, messageID, subject, body, date, inReplyTo, dkim, MakeNameAddress(sender, false), category, rootID, messageBytes)
+
+	return err
+}
+
+func UpdateRecipients(db *sql.DB, address *imap.Address, msgID string) error {
+	var (
+		rootID    string
+		recipient sql.NullString
+	)
+	row := db.QueryRow(`select root_id, recipient from mimir_archive left outer join mimir_recipients on(root_id == root_message_id) where message_id = ? and sender = ?`, msgID, MakeNameAddress(address, false))
+	err := row.Scan(&rootID, &recipient)
+	if err != nil {
+		return err
+	}
+	if !recipient.Valid {
+		_, err = db.Exec(`insert into mimir_recipients values(?, ?)`, rootID, address.Address())
+	}
+	return err
+}
+
+func GetRecipients(db *sql.DB, messageID string, sender *imap.Address) ([]string, error) {
+	recipients := []string{}
+
+	rows, err := db.Query(`select recipient from mimir_archive join mimir_recipients on(root_id == root_message_id) where message_id = ?`, messageID)
+	if err != nil {
+		return recipients, err
+	}
+	for rows.Next() {
+		var recipient string
+		err := rows.Scan(&recipient)
+		if err != nil {
+			return recipients, err
+		}
+		if recipient != sender.Address() {
+			recipients = append(recipients, recipient)
+		}
+	}
+	return recipients, nil
+}
+
+func GetArchivedThread(db *sql.DB, msgID string) ([]Message, error) {
+	messages := []Message{}
+	rows, err := db.Query(`select subject, body, date, dkim_status, sender, category, message_id from mimir_archive where root_id = ? order by date asc`, msgID)
+	if err != nil {
+		return messages, fmt.Errorf("while selecting thread: %w", err)
+	}
+	for rows.Next() {
+		message := Message{}
+		err := rows.Scan(&message.Subject, &message.Body, &message.Date, &message.Dkim, &message.Sender, &message.Category, &message.ID)
+		if err != nil {
+			return messages, fmt.Errorf("while scanning message in thread: %w", err)
+		}
+		messages = append(messages, message)
+	}
+	return messages, err
+}
+
+func GetArchivedThreads(db *sql.DB, page int64) ([]Message, int, error) {
+	messages := []Message{}
+	var numThreads int
+	row := db.QueryRow(`select count(*) from mimir_archive where root_id == message_id`)
+	err := row.Scan(&numThreads)
+	if err != nil {
+		return messages, 0, fmt.Errorf("while selecting count: %w", err)
+	}
+	if numThreads == 0 {
+		return messages, numThreads, nil
+	}
+	rows, err := db.Query(`select subject, date, sender, category, message_id, root_id, CASE WHEN LENGTH(body) > 256 THEN substr(body,1,256) || '…' ELSE body END from mimir_archive where root_id = message_id order by date desc limit 12 offset ?`, (page-1)*12)
+	if err != nil {
+		return messages, 0, fmt.Errorf("while selecting threads: %w", err)
+	}
+	for rows.Next() {
+		msg := Message{}
+		err := rows.Scan(&msg.Subject, &msg.Date, &msg.Sender, &msg.Category, &msg.ID, &msg.Thread, &msg.Body)
+		if err != nil {
+			return messages, 0, fmt.Errorf("while scanning message: %w", err)
+		}
+		messages = append(messages, msg)
+	}
+	return messages, numThreads, nil
+}




diff --git a/himinbjorg/knownAddress.go b/himinbjorg/knownAddress.go
new file mode 100644
index 0000000000000000000000000000000000000000..f78e52d3e0dbfae60ee8cd20d9cbeb5fff7616ec
--- /dev/null
+++ b/himinbjorg/knownAddress.go
@@ -0,0 +1,11 @@
+package himinbjorg
+
+type KnownAddress struct {
+	AddressFrom string
+	AddressTo   string
+	Ban         bool
+}
+
+func (a KnownAddress) empty() bool {
+	return a.AddressFrom == "" && a.AddressTo == ""
+}




diff --git a/himinbjorg/lock.go b/himinbjorg/lock.go
new file mode 100644
index 0000000000000000000000000000000000000000..229c57bf5b5e1288ad8a17ca1bda6f54b49ec902
--- /dev/null
+++ b/himinbjorg/lock.go
@@ -0,0 +1,29 @@
+package himinbjorg
+
+import (
+	"math/rand"
+	"strconv"
+	"time"
+)
+
+type Lock struct {
+	Address   string
+	Token     string
+	Date      time.Time
+	Recipient string
+}
+
+func NewLock(address, recipient string) Lock {
+	token := strconv.FormatUint(rand.Uint64(), 16)
+	lock := Lock{
+		Address:   address,
+		Token:     token,
+		Date:      time.Now(),
+		Recipient: recipient,
+	}
+	return lock
+}
+
+func (l Lock) Empty() bool {
+	return l.Address == "" && l.Token == "" && l.Date.IsZero()
+}




diff --git a/himinbjorg/message.go b/himinbjorg/message.go
new file mode 100644
index 0000000000000000000000000000000000000000..35c876a87ac7a1412b93467675f5d1ee53d3ade8
--- /dev/null
+++ b/himinbjorg/message.go
@@ -0,0 +1,28 @@
+package himinbjorg
+
+import (
+	"time"
+)
+
+type Message struct {
+	ID       string
+	Subject  string
+	Body     string
+	Date     time.Time
+	Dkim     bool
+	Sender   string
+	Category string
+	Thread   string
+}
+
+func (m Message) FormatDate() string {
+	return m.Date.Format(time.RFC822Z)
+}
+
+func (m Message) RESubject() string {
+	if m.Subject[:3] != "Re:" {
+		return "Re: " + m.Subject
+	} else {
+		return m.Subject
+	}
+}




diff --git a/idavollr/errors.go b/idavollr/errors.go
new file mode 100644
index 0000000000000000000000000000000000000000..fa1c43b8cf0607b67adef28b48f79a6d054dc4b0
--- /dev/null
+++ b/idavollr/errors.go
@@ -0,0 +1,20 @@
+package idavollr
+
+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/idavollr/imap.go b/idavollr/imap.go
new file mode 100644
index 0000000000000000000000000000000000000000..652c883456da1ed11ba23de7afa5cc6a5e5ae287
--- /dev/null
+++ b/idavollr/imap.go
@@ -0,0 +1,148 @@
+package idavollr
+
+import (
+	"io"
+	"log"
+	"mime"
+	"mime/quotedprintable"
+	"strings"
+
+	"apiote.xyz/p/asgard/himinbjorg"
+	"apiote.xyz/p/asgard/jotunheim"
+
+	"github.com/emersion/go-imap"
+	"github.com/emersion/go-imap-move"
+	"github.com/emersion/go-imap/client"
+	"github.com/emersion/go-sasl"
+	"github.com/emersion/go-smtp"
+)
+
+func SendRepeatedQuarantine(address *imap.Address, lock himinbjorg.Lock) {
+	// todo
+	log.Printf("sending repeated quarantine to %s from %+v\n", address.Address(), lock)
+}
+
+func SendQuarantine(address *imap.Address) {
+	// todo
+	log.Printf("sending quarantine to %s\n", address.Address())
+}
+
+func MoveMsg(c *client.Client, msg *imap.Message, dest string) error {
+	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)
+	seqSet := new(imap.SeqSet)
+	seqSet.AddNum(msg.Uid)
+	return moveClient.UidMoveWithFallback(seqSet, dest)
+}
+
+func MoveMultiple(c *client.Client, seqSet *imap.SeqSet, dest string) error {
+	moveClient := move.NewClient(c)
+	if !seqSet.Empty() {
+		log.Println("moving multiple to " + dest)
+		err := moveClient.UidMoveWithFallback(seqSet, dest)
+		return err
+	}
+	return nil
+}
+
+func MoveFromQuarantine(c *client.Client, mbox *imap.MailboxStatus, address, dest string) error {
+	log.Printf("moving %s from quarantine to %s\n", address, dest)
+	moveClient := move.NewClient(c)
+	from := uint32(1)
+	to := mbox.Messages
+	allMessagesSet := new(imap.SeqSet)
+	allMessagesSet.AddRange(from, to)
+	var err error = nil
+
+	messages := make(chan *imap.Message, 10)
+	done := make(chan error, 1)
+	go func() {
+		done <- c.Fetch(allMessagesSet, []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}, messages)
+	}()
+
+	moveSet := new(imap.SeqSet)
+	for msg := range messages {
+		sender := msg.Envelope.From[0]
+		if sender.Address() == address {
+			moveSet.AddNum(msg.Uid)
+		}
+	}
+	if !moveSet.Empty() {
+		err = moveClient.UidMoveWithFallback(moveSet, dest)
+	}
+	if err := <-done; err != nil {
+		return err
+	}
+	return err
+}
+
+func ForwardMessage(config jotunheim.Config, category, messageID, inReplyTo, subject string, body []byte, recipients []string, sender *imap.Address) error {
+	// todo reformat, errors, &c.
+
+	msg := "To: " + strings.Join(recipients, ", ") + "\r\n" +
+		"From: " + himinbjorg.MakeNameAddress(sender, true) + "\r\n" +
+		"Message-ID: " + messageID + "\r\n" +
+		"Subject: " + mime.QEncoding.Encode("utf-8", subject) + "\r\n"
+	if inReplyTo != "" {
+		msg = msg + "In-Reply-To: " + inReplyTo + "\r\n"
+	}
+	msg += "Reply-To: " + mime.QEncoding.Encode("utf-8", strings.Replace(config.Mimir.RecipientTemplate, "[:]", category, 1)) + "\r\n" +
+		"Content-Type: text/plain; charset=utf-8\r\n" +
+		"Content-Transfer-Encoding: quoted-printable\r\n" +
+		"\r\n"
+	msgReader := strings.NewReader(msg)
+	auth := sasl.NewPlainClient("", config.Mimir.ImapUsername, config.Mimir.ImapPassword)
+	c, err := smtp.DialTLS(config.Mimir.SmtpAddress, nil)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	if err = c.Auth(auth); err != nil {
+		log.Fatal(err)
+	}
+
+	if err := c.Mail(config.Mimir.SmtpSender, nil); err != nil {
+		log.Fatal(err)
+	}
+	for _, recipient := range recipients {
+		if err := c.Rcpt(recipient, nil); err != nil {
+			log.Fatal(err)
+		}
+	}
+
+	wc, err := c.Data()
+	if err != nil {
+		log.Fatal(err)
+	}
+	_, err = io.Copy(wc, msgReader)
+	qpWriter := quotedprintable.NewWriter(wc)
+	_, err = qpWriter.Write(body)
+	_, err = qpWriter.Write([]byte("\r\n"))
+	if err != nil {
+		log.Fatal(err)
+	}
+	err = wc.Close()
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	err = c.Quit()
+	if err != nil {
+		log.Fatal(err)
+	}
+	return nil
+}
+
+func RemoveMessage(c *client.Client, msgUid uint32, mailbox string) error {
+	_, err := c.Select(mailbox, false)
+	if err != nil {
+		return err
+	}
+
+	seqset := new(imap.SeqSet)
+	seqset.AddNum(msgUid)
+
+	item := imap.FormatFlagsOp(imap.AddFlags, true)
+	flags := []interface{}{imap.DeletedFlag}
+	return c.UidStore(seqset, item, flags, nil)
+}




diff --git a/idavollr/mailbox.go b/idavollr/mailbox.go
new file mode 100644
index 0000000000000000000000000000000000000000..adade67e4dfba65deeadc0444e208ca3ef479d5c
--- /dev/null
+++ b/idavollr/mailbox.go
@@ -0,0 +1,162 @@
+package idavollr
+
+import (
+	"errors"
+	"log"
+
+	"apiote.xyz/p/asgard/jotunheim"
+
+	"github.com/emersion/go-imap"
+	"github.com/emersion/go-imap/client"
+)
+
+type AbstractMailbox interface {
+	MailboxName() string
+	ImapAddress() string
+	ImapUsername() string
+	ImapPassword() string
+	SetClient(*client.Client)
+	Client() *client.Client
+	Config() jotunheim.Config
+	SetMailbox(*imap.MailboxStatus)
+	GetMailbox() *imap.MailboxStatus
+	SetSection(*imap.BodySectionName)
+	Section() *imap.BodySectionName
+	Messages() chan *imap.Message
+	Done() chan error
+
+	SetupChannels()
+}
+
+type Mailbox struct {
+	MboxName string
+	ImapAdr  string
+	ImapUser string
+	ImapPass string
+	Conf     jotunheim.Config
+
+	cli  *client.Client
+	mbox *imap.MailboxStatus
+	sect *imap.BodySectionName
+	msgs chan *imap.Message
+	dn   chan error
+}
+
+func (m Mailbox) MailboxName() string {
+	return m.MboxName
+}
+
+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() jotunheim.Config {
+	return m.Conf
+}
+
+func (m *Mailbox) SetMailbox(mbox *imap.MailboxStatus) {
+	m.mbox = mbox
+}
+
+func (m Mailbox) GetMailbox() *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(m.MailboxName(), false)
+	m.SetMailbox(mbox)
+	return m, err
+}
+
+func CheckEmptyBox(m AbstractMailbox) error {
+	if m.GetMailbox().Messages == 0 {
+		return EmptyBoxError{}
+	}
+	return nil
+}
+
+func FetchMessages(m AbstractMailbox) AbstractMailbox {
+	from := uint32(1)
+	to := m.GetMailbox().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 CheckFetchError(m AbstractMailbox) error {
+	return <-m.Done()
+}
+
+func Expunge(m AbstractMailbox) error {
+	return m.Client().Expunge(nil)
+}
+
+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/idavollr/message.go b/idavollr/message.go
new file mode 100644
index 0000000000000000000000000000000000000000..cfdd81b36afc1e8e7f5eb673f469d69f998dc64c
--- /dev/null
+++ b/idavollr/message.go
@@ -0,0 +1,164 @@
+package idavollr
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+
+	"github.com/emersion/go-imap"
+	"github.com/emersion/go-imap/client"
+	"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)
+	SetClient(*client.Client)
+	Client() *client.Client
+}
+
+type ImapMessage struct {
+	Sect     *imap.BodySectionName
+	Msg      *imap.Message
+	Mimetype string
+
+	cli      *client.Client
+	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 (m ImapMessage) SetClient(c *client.Client) {
+	m.cli = c
+}
+
+func (m ImapMessage) Client() *client.Client {
+	return m.cli
+}
+
+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 getNextPart(message *message.Entity, ID string, mimetype string) (io.Reader, error) {
+	if mr := message.MultipartReader(); mr != nil {
+		for {
+			p, err := mr.NextPart()
+			if err == io.EOF {
+				break
+			} else if err != nil {
+				return nil, fmt.Errorf("while reading next part: %w", err)
+			}
+			return getNextPart(p, ID, mimetype)
+		}
+	} else {
+		t, _, err := message.Header.ContentType()
+		if err != nil {
+			return nil, fmt.Errorf("while getting content type: %w", err)
+		}
+		if t == mimetype {
+			return message.Body, nil
+		}
+	}
+	return nil, MalformedMessageError{
+		Cause:     errors.New(mimetype + " not found"),
+		MessageID: ID,
+	}
+}
+
+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 RecoverMalformedMessage(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 RecoverErroredMessages(m AbstractImapMessage, err error) (AbstractImapMessage, error) {
+	log.Printf("message %s errored: %v\n", m.Message().Envelope.MessageId, err)
+	return m, nil
+}




diff --git a/imap.go b/imap.go
deleted file mode 100644
index a27fcfe46eaafb38697aff7bfe98076611311463..0000000000000000000000000000000000000000
--- a/imap.go
+++ /dev/null
@@ -1,145 +0,0 @@
-package main
-
-import (
-	"io"
-	"log"
-	"mime"
-	"mime/quotedprintable"
-	"strings"
-
-	"github.com/emersion/go-imap"
-	"github.com/emersion/go-imap-move"
-	"github.com/emersion/go-imap/client"
-	"github.com/emersion/go-sasl"
-	"github.com/emersion/go-smtp"
-)
-
-func moveMsg(c *client.Client, msg *imap.Message, dest string) error {
-	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)
-	seqSet := new(imap.SeqSet)
-	seqSet.AddNum(msg.Uid)
-	return moveClient.UidMoveWithFallback(seqSet, dest)
-}
-
-func moveMultiple(c *client.Client, seqSet *imap.SeqSet, dest string) error {
-	moveClient := move.NewClient(c)
-	if !seqSet.Empty() {
-		log.Println("moving multiple to " + dest)
-		err := moveClient.UidMoveWithFallback(seqSet, dest)
-		return err
-	}
-	return nil
-}
-
-func moveFromQuarantine(c *client.Client, mbox *imap.MailboxStatus, address, dest string) error {
-	log.Printf("moving %s from quarantine to %s\n", address, dest)
-	moveClient := move.NewClient(c)
-	from := uint32(1)
-	to := mbox.Messages
-	allMessagesSet := new(imap.SeqSet)
-	allMessagesSet.AddRange(from, to)
-	var err error = nil
-
-	messages := make(chan *imap.Message, 10)
-	done := make(chan error, 1)
-	go func() {
-		done <- c.Fetch(allMessagesSet, []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}, messages)
-	}()
-
-	moveSet := new(imap.SeqSet)
-	for msg := range messages {
-		sender := msg.Envelope.From[0]
-		if sender.Address() == address {
-			moveSet.AddNum(msg.Uid)
-		}
-	}
-	if !moveSet.Empty() {
-		err = moveClient.UidMoveWithFallback(moveSet, dest)
-	}
-	if err := <-done; err != nil {
-		return err
-	}
-	return err
-}
-
-func sendRepeatedQuarantine(address *imap.Address, lock Lock) {
-	// todo
-	log.Printf("sending repeated quarantine to %s from %+v\n", address.Address(), lock)
-}
-
-func sendQuarantine(address *imap.Address) {
-	// todo
-	log.Printf("sending quarantine to %s\n", address.Address())
-}
-
-func forwardMessage(config Config, category, messageID, inReplyTo, subject string, body []byte, recipients []string, sender *imap.Address) error {
-	// todo reformat, errors, &c.
-
-	msg := "To: " + strings.Join(recipients, ", ") + "\r\n" +
-		"From: " + makeNameAddress(sender, true) + "\r\n" +
-		"Message-ID: " + messageID + "\r\n" +
-		"Subject: " + mime.QEncoding.Encode("utf-8", subject) + "\r\n"
-	if inReplyTo != "" {
-		msg = msg + "In-Reply-To: " + inReplyTo + "\r\n"
-	}
-	msg += "Reply-To: " + mime.QEncoding.Encode("utf-8", strings.Replace(config.Mimir.RecipientTemplate, "[:]", category, 1)) + "\r\n" +
-		"Content-Type: text/plain; charset=utf-8\r\n" +
-		"Content-Transfer-Encoding: quoted-printable\r\n" +
-		"\r\n"
-	msgReader := strings.NewReader(msg)
-	auth := sasl.NewPlainClient("", config.Mimir.ImapUsername, config.Mimir.ImapPassword)
-	c, err := smtp.DialTLS(config.Mimir.SmtpAddress, nil)
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	if err = c.Auth(auth); err != nil {
-		log.Fatal(err)
-	}
-
-	if err := c.Mail(config.Mimir.SmtpSender, nil); err != nil {
-		log.Fatal(err)
-	}
-	for _, recipient := range recipients {
-		if err := c.Rcpt(recipient, nil); err != nil {
-			log.Fatal(err)
-		}
-	}
-
-	wc, err := c.Data()
-	if err != nil {
-		log.Fatal(err)
-	}
-	_, err = io.Copy(wc, msgReader)
-	qpWriter := quotedprintable.NewWriter(wc)
-	_, err = qpWriter.Write(body)
-	_, err = qpWriter.Write([]byte("\r\n"))
-	if err != nil {
-		log.Fatal(err)
-	}
-	err = wc.Close()
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	err = c.Quit()
-	if err != nil {
-		log.Fatal(err)
-	}
-	return nil
-}
-
-func removeMessage(c *client.Client, msgUid uint32, mailbox string) error {
-	_, err := c.Select(mailbox, false)
-	if err != nil {
-		return err
-	}
-
-	seqset := new(imap.SeqSet)
-	seqset.AddNum(msgUid)
-
-	item := imap.FormatFlagsOp(imap.AddFlags, true)
-	flags := []interface{}{imap.DeletedFlag}
-	return c.UidStore(seqset, item, flags, nil)
-}




diff --git a/imap_errors.go b/imap_errors.go
deleted file mode 100644
index 628900e0d3e9e16033beea0152d3104775d0a2ad..0000000000000000000000000000000000000000
--- a/imap_errors.go
+++ /dev/null
@@ -1,20 +0,0 @@
-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
deleted file mode 100644
index 8fb216b411057b598e906bc5bfecf4c4622d1f50..0000000000000000000000000000000000000000
--- a/imap_mailbox.go
+++ /dev/null
@@ -1,160 +0,0 @@
-package main
-
-import (
-	"errors"
-	"log"
-
-	"github.com/emersion/go-imap"
-	"github.com/emersion/go-imap/client"
-)
-
-type AbstractMailbox interface {
-	mailboxName() string
-	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 {
-	mboxName string
-	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) mailboxName() string {
-	return m.mboxName
-}
-
-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(m.mailboxName(), false)
-	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 checkFetchError(m AbstractMailbox) error {
-	return <-m.done()
-}
-
-func expunge(m AbstractMailbox) error {
-	return m.client().Expunge(nil)
-}
-
-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
deleted file mode 100644
index e8422ed2bb4cdae9b1e4da1910a28db7d332319e..0000000000000000000000000000000000000000
--- a/imap_message.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"errors"
-	"fmt"
-	"io"
-	"log"
-
-	"github.com/emersion/go-imap"
-	"github.com/emersion/go-imap/client"
-	"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)
-	setClient(*client.Client)
-	client() *client.Client
-}
-
-type ImapMessage struct {
-	sect     *imap.BodySectionName
-	msg      *imap.Message
-	mimetype string
-	cli      *client.Client
-
-	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 (m ImapMessage) setClient(c *client.Client) {
-	m.cli = c
-}
-
-func (m ImapMessage) client() *client.Client {
-	return m.cli
-}
-
-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 getNextPart(message *message.Entity, ID string, mimetype string) (io.Reader, error) {
-	if mr := message.MultipartReader(); mr != nil {
-		for {
-			p, err := mr.NextPart()
-			if err == io.EOF {
-				break
-			} else if err != nil {
-				return nil, fmt.Errorf("while reading next part: %w", err)
-			}
-			return getNextPart(p, ID, mimetype)
-		}
-	} else {
-		t, _, err := message.Header.ContentType()
-		if err != nil {
-			return nil, fmt.Errorf("while getting content type: %w", err)
-		}
-		if t == mimetype {
-			return message.Body, nil
-		}
-	}
-	return nil, MalformedMessageError{
-		Cause:     errors.New(mimetype + " not found"),
-		MessageID: ID,
-	}
-}
-
-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 recoverMalformedMessage(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 recoverErroredMessages(m AbstractImapMessage, err error) (AbstractImapMessage, error) {
-	log.Printf("message %s errored: %v\n", m.message().Envelope.MessageId, err)
-	return m, nil
-}




diff --git a/jotunheim/config.go b/jotunheim/config.go
new file mode 100644
index 0000000000000000000000000000000000000000..47a6ad55c3a6f2370ca61c13c84ad588ad7b065e
--- /dev/null
+++ b/jotunheim/config.go
@@ -0,0 +1,124 @@
+package jotunheim
+
+import (
+	"fmt"
+	"os"
+
+	"apiote.xyz/p/go-dirty"
+)
+
+type TyrConfig struct {
+	ImapAddress          string
+	ImapUsername         string
+	ImapPassword         string
+	ImapFolderInbox      string
+	ImapFolderJunk       string
+	ImapFolderArchive    string
+	ImapFolderTrash      string
+	ImapFolderDrafts     string
+	ImapFolderQuarantine string
+	ImapFolderSent       string
+	RecipientDomain      string
+	MainEmailAddress     string
+}
+
+type HermodrConfig struct {
+	ImapAddress          string
+	ImapUsername         string
+	ImapPassword         string
+	ImapFolderInbox      string
+	ImapFolderRedirected string
+	Recipient            string
+	SmtpServer           string
+	SmtpUsername         string
+	PublicKey            string
+}
+type MimirConfig struct {
+	ImapAddress       string
+	ImapUsername      string
+	ImapPassword      string
+	ImapInbox         string
+	RecipientTemplate string
+	Categories        []string
+	ForwardAddress    string
+	PersonalAddress   string
+	SmtpAddress       string
+	SmtpSender        string
+	Companion         string
+}
+
+type EostreConfig struct {
+	ImapAddress       string
+	ImapUsername      string
+	ImapPassword      string
+	DiaryImapAddress  string
+	DiaryImapUsername string
+	DiaryImapPassword string
+	DiarySmtpAddress  string
+	DiarySmtpUsername string
+	DiarySmtpPassword string
+	DiarySubject      string
+	DiarySender       string
+	DiaryRecipient    string
+	AuthorisedSender  string
+	PrivateKeyPass    string
+	PrivateKey        string
+	PublicKey         string
+	DiaryPrivateKey   string
+	DiaryPublicKey    string
+}
+
+type GersemiConfig struct {
+	FireflyToken      string
+	Firefly           string
+	ImapAddress       string
+	ImapUsername      string
+	ImapPassword      string
+	ImapInbox         string
+	MessageMime       string
+	DoneFolder        string
+	DefaultSource     string
+	WithdrawalRegexes []string
+	DepositRegexes    []string
+	Accounts          map[string]string
+}
+
+type Config struct {
+	Tyr     TyrConfig
+	Hermodr HermodrConfig
+	Mimir   MimirConfig
+	Eostre  EostreConfig
+	Gersemi GersemiConfig
+}
+
+func Read(configPath string) (Config, error) {
+	config := Config{}
+	userConfigDir, err := os.UserConfigDir()
+	if err != nil {
+		return config, fmt.Errorf("while getting user config dir: %w", err)
+	}
+	possibleConfigs := []string{
+		configPath,
+		"/etc/asgard.dirty",
+		userConfigDir + "/asgard.dirty",
+		"asgard.dirty",
+	}
+	finalConfigPath := ""
+	for _, possibleConfig := range possibleConfigs {
+		_, err := os.Stat(possibleConfig)
+		if err == nil {
+			finalConfigPath = possibleConfig
+			break
+		}
+	}
+	if finalConfigPath == "" {
+		return config, fmt.Errorf("no config found")
+	}
+	file, err := os.Open(finalConfigPath)
+	if err != nil {
+		return config, fmt.Errorf("while opening config: %w", err)
+	}
+	defer file.Close()
+	err = dirty.LoadStruct(file, &config)
+	return config, err
+}




diff --git a/main.go b/main.go
index 91dac23725739f9d64ac7be79ebe97f25e1dd8cb..7eb9b6a0e60fd5104f5931b0865ca290d9ab9613 100644
--- a/main.go
+++ b/main.go
@@ -3,135 +3,24 @@
 import (
 	"embed"
 	"errors"
-	"fmt"
 	"log"
 	"net/http"
 	"os"
 
-	"apiote.xyz/p/go-dirty"
+	"apiote.xyz/p/asgard/eostre"
+	"apiote.xyz/p/asgard/gersemi"
+	"apiote.xyz/p/asgard/hermodr"
+	"apiote.xyz/p/asgard/himinbjorg"
+	"apiote.xyz/p/asgard/jotunheim"
+	"apiote.xyz/p/asgard/mimir"
+	"apiote.xyz/p/asgard/tyr"
+
 	"git.sr.ht/~sircmpwn/getopt"
 )
 
 //go:embed templates
 var templatesFS embed.FS
 
-type TyrConfig struct {
-	ImapAddress          string
-	ImapUsername         string
-	ImapPassword         string
-	ImapFolderInbox      string
-	ImapFolderJunk       string
-	ImapFolderArchive    string
-	ImapFolderTrash      string
-	ImapFolderDrafts     string
-	ImapFolderQuarantine string
-	ImapFolderSent       string
-	RecipientDomain      string
-	MainEmailAddress     string
-}
-
-type HermodrConfig struct {
-	ImapAddress          string
-	ImapUsername         string
-	ImapPassword         string
-	ImapFolderInbox      string
-	ImapFolderRedirected string
-	Recipient            string
-	SmtpServer           string
-	SmtpUsername         string
-	PublicKey            string
-}
-type MimirConfig struct {
-	ImapAddress       string
-	ImapUsername      string
-	ImapPassword      string
-	ImapInbox         string
-	RecipientTemplate string
-	Categories        []string
-	ForwardAddress    string
-	PersonalAddress   string
-	SmtpAddress       string
-	SmtpSender        string
-	Companion         string
-}
-
-type EostreConfig struct {
-	ImapAddress       string
-	ImapUsername      string
-	ImapPassword      string
-	DiaryImapAddress  string
-	DiaryImapUsername string
-	DiaryImapPassword string
-	DiarySmtpAddress  string
-	DiarySmtpUsername string
-	DiarySmtpPassword string
-	DiarySubject      string
-	DiarySender       string
-	DiaryRecipient    string
-	AuthorisedSender  string
-	PrivateKeyPass    string
-	PrivateKey        string
-	PublicKey         string
-	DiaryPrivateKey   string
-	DiaryPublicKey    string
-}
-
-type GersemiConfig struct {
-	FireflyToken      string
-	Firefly           string
-	ImapAddress       string
-	ImapUsername      string
-	ImapPassword      string
-	ImapInbox         string
-	MessageMime       string
-	DoneFolder        string
-	DefaultSource     string
-	WithdrawalRegexes []string
-	DepositRegexes    []string
-	Accounts          map[string]string
-}
-
-type Config struct {
-	Tyr     TyrConfig
-	Hermodr HermodrConfig
-	Mimir   MimirConfig
-	Eostre  EostreConfig
-	Gersemi GersemiConfig
-}
-
-func readConfig(configPath string) (Config, error) {
-	config := Config{}
-	userConfigDir, err := os.UserConfigDir()
-	if err != nil {
-		return config, fmt.Errorf("while getting user config dir: %w", err)
-	}
-	possibleConfigs := []string{
-		configPath,
-		"/etc/asgard.dirty",
-		userConfigDir + "/asgard.dirty",
-		"asgard.dirty",
-	}
-	finalConfigPath := ""
-	for _, possibleConfig := range possibleConfigs {
-		_, err := os.Stat(possibleConfig)
-		if err == nil {
-			finalConfigPath = possibleConfig
-			break
-		}
-	}
-	if finalConfigPath == "" {
-		return config, fmt.Errorf("no config found")
-	}
-	file, err := os.Open(finalConfigPath)
-	if err != nil {
-		log.Printf("error opening configuration %v\n", err)
-		return config, err
-	}
-	defer file.Close()
-	err = dirty.LoadStruct(file, &config)
-	return config, err
-}
-
 func main() {
 	var (
 		configPath string
@@ -141,12 +30,12 @@ 	getopt.StringVar(&configPath, "c", "", "path to config file")
 	getopt.StringVar(&dbPath, "b", "", "path to database file")
 	getopt.Parse()
 
-	config, err := readConfig(configPath)
+	config, err := jotunheim.Read(configPath)
 	if err != nil {
 		log.Fatalln(err)
 	}
 
-	db, err := migrate(dbPath)
+	db, err := himinbjorg.Migrate(dbPath)
 	if err != nil {
 		log.Fatalln(err)
 	}
@@ -158,22 +47,22 @@ 	switch args[0] {
 	case "hermodr":
 		fallthrough
 	case "hermóðr":
-		hermodr(config)
+		hermodr.Hermodr(config)
 
 	case "tyr":
 		fallthrough
 	case "týr":
 		if len(args) == 1 {
-			tyr(db, config)
+			tyr.Tyr(db, config)
 		} else {
 			switch args[1] {
 			case "list":
-				tyr_lists_locks(db)
+				tyr.ListLocks(db)
 			case "offend":
 				if len(args) == 2 {
 					log.Fatalln("missing token")
 				}
-				tyr_release(db, config, args[2], "*", config.Tyr.ImapFolderJunk)
+				tyr.Release(db, config, args[2], "*", config.Tyr.ImapFolderJunk)
 			case "release":
 				if len(args) == 2 {
 					log.Fatalln("missing token (and recipient)")
@@ -182,19 +71,19 @@ 				addressTo := ""
 				if len(args) != 3 {
 					addressTo = args[3]
 				}
-				tyr_release(db, config, args[2], addressTo, config.Tyr.ImapFolderInbox)
+				tyr.Release(db, config, args[2], addressTo, config.Tyr.ImapFolderInbox)
 			}
 		}
 
 	case "mimir":
 		fallthrough
 	case "mímir":
-		mimir(db, config)
+		mimir.Mimir(db, config)
 
 	case "ēostre":
 		fallthrough
 	case "eostre":
-		n, err := eostre(config)
+		n, err := eostre.Eostre(config)
 		if err != nil {
 			log.Println(err)
 			return
@@ -204,32 +93,32 @@ 		if err != nil && errors.Is(err, os.ErrNotExist) {
 			if n == 0 {
 				return
 			}
-			err = downloadDiary(config)
+			err = eostre.DownloadDiary(config)
 			if err != nil {
 				log.Println(err)
 				return
 			}
 		}
 		if n > 0 {
-			err = updateDiary(config)
+			err = eostre.UpdateDiary(config)
 			if err != nil {
 				log.Println(err)
 				return
 			}
 		}
-		err = sendDiary(config)
+		err = eostre.SendDiary(config)
 		if err != nil {
 			log.Println(err)
 			return
 		}
 
 	case "gersemi":
-		log.Println(gersemi(config))
+		log.Println(gersemi.Gersemi(config))
 
 	case "serve":
-		http.HandleFunc("/tyr", tyr_serve(db, config, templatesFS))
-		http.HandleFunc("/mimir", mimir_serve(db, templatesFS))
-		http.HandleFunc("/mimir/", mimir_serve(db, templatesFS))
+		http.HandleFunc("/tyr", tyr.Serve(db, config, templatesFS))
+		http.HandleFunc("/mimir", mimir.Serve(db, templatesFS))
+		http.HandleFunc("/mimir/", mimir.Serve(db, templatesFS))
 		e := http.ListenAndServe(":8081", nil)
 		if e != nil {
 			log.Println(e)




diff --git a/mimir/mimir.go b/mimir/mimir.go
new file mode 100644
index 0000000000000000000000000000000000000000..94cf421633f2d9670c18ca11c31b20bd988613a9
--- /dev/null
+++ b/mimir/mimir.go
@@ -0,0 +1,343 @@
+package mimir
+
+// todo test utf-8 in subject, sender (mímir), body
+
+// ---- release v1
+
+/* todo views:
+default: threads click-> thread
+thread: linear messages by datetime oldest on top, with #message_id, and is_reply_to: <a href=#message_id>
+
+search ordered by datetime, newest on top; thread – most recent message
+
+search by time: messages [in thread] click-> thread#message_id
+search by from: messages [in thread] click-> thread#message_id
+filter by category: inherit
+|search by subject: threads click-> thread
+|search by subject+: messages [in thread] click-> thread#message_id
+|search by full text: messages [in thread] click-> thread#message_id
+*/
+
+/* todo moderation:
+ban address, right to forget, unsubscribe (from one topic, from all topics)
+*/
+
+// ---- release v2
+
+// todo in thread card: add number of messages and interested people
+// todo highlight patches
+// todo check pgp/mime signatures
+
+import (
+	"bytes"
+	"database/sql"
+	"embed"
+	"errors"
+	"fmt"
+	"html/template"
+	"log"
+	"net/http"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"apiote.xyz/p/asgard/himinbjorg"
+	"apiote.xyz/p/asgard/idavollr"
+	"apiote.xyz/p/asgard/jotunheim"
+
+	"apiote.xyz/p/gott/v2"
+	"github.com/emersion/go-imap"
+	_ "github.com/emersion/go-message/charset"
+	"github.com/emersion/go-msgauth/dkim"
+)
+
+type UnknownCategoryError struct {
+	MessageID string
+	Category  string
+}
+
+func (e UnknownCategoryError) Error() string {
+	return fmt.Sprintf("Unknown category ‘%s’ in message %s", e.Category, e.MessageID)
+}
+
+type ListingPage struct {
+	Messages []himinbjorg.Message
+	Page     int
+	NumPages int
+}
+
+func (l ListingPage) PrevPage() int {
+	return l.Page - 1
+}
+func (l ListingPage) NextPage() int {
+	return l.Page + 1
+}
+
+type MimirMailbox struct {
+	idavollr.Mailbox
+	categories     []string
+	categoryRegexp *regexp.Regexp
+	db             *sql.DB
+}
+
+type MimirImapMessage struct {
+	idavollr.ImapMessage
+	categoryRegexp *regexp.Regexp
+	categories     []string
+	category       string
+	dkimStatus     bool
+	db             *sql.DB
+	config         jotunheim.Config
+	recipients     []string
+	mboxName       string
+}
+
+func Mimir(db *sql.DB, config jotunheim.Config) error {
+	r := gott.R[idavollr.AbstractMailbox]{
+		S: &MimirMailbox{
+			Mailbox: idavollr.Mailbox{
+				MboxName: config.Mimir.ImapInbox,
+				ImapAdr:  config.Mimir.ImapAddress,
+				ImapUser: config.Mimir.ImapUsername,
+				ImapPass: config.Mimir.ImapPassword,
+				Conf:     config,
+			},
+			db: db,
+		},
+	}.
+		Bind(getCategories).
+		Bind(prepareCategoryRegexp).
+		Bind(idavollr.Connect).
+		Tee(idavollr.Login).
+		Bind(idavollr.SelectInbox).
+		Tee(idavollr.CheckEmptyBox).
+		Map(idavollr.FetchMessages).
+		Tee(archiveMessages).
+		Tee(idavollr.CheckFetchError).
+		Tee(idavollr.Expunge).
+		Recover(idavollr.IgnoreEmptyBox).
+		Recover(idavollr.Disconnect)
+
+	return r.E
+}
+
+func getCategories(am idavollr.AbstractMailbox) (idavollr.AbstractMailbox, error) {
+	m := am.(*MimirMailbox)
+	m.categories = m.Config().Mimir.Categories
+	if len(m.categories) == 0 {
+		return m, errors.New("no categories defined")
+	}
+	return m, nil
+}
+
+func prepareCategoryRegexp(am idavollr.AbstractMailbox) (idavollr.AbstractMailbox, error) {
+	m := am.(*MimirMailbox)
+	if !strings.Contains(m.Config().Mimir.RecipientTemplate, "[:]") {
+		return m, errors.New("recipient template does not contain ‘[:]’")
+	}
+	recipientRegexp := strings.Replace(m.Config().Mimir.RecipientTemplate, "[:]", "(.*)", 1)
+	r, err := regexp.Compile(recipientRegexp)
+	m.categoryRegexp = r
+	return m, err
+}
+
+func archiveMessages(am idavollr.AbstractMailbox) error {
+	m := am.(*MimirMailbox)
+	for msg := range m.Messages() {
+		imapMessage := idavollr.ImapMessage{
+			Msg:      msg,
+			Sect:     m.Section(),
+			Mimetype: "text/plain",
+		}
+		imapMessage.SetClient(m.Client())
+		r := gott.R[idavollr.AbstractImapMessage]{
+			S: &MimirImapMessage{
+				ImapMessage:    imapMessage,
+				categoryRegexp: m.categoryRegexp,
+				categories:     m.categories,
+				db:             m.db,
+				config:         m.Config(),
+				mboxName:       m.MboxName,
+			},
+		}.
+			Bind(getMessageCategory).
+			Bind(idavollr.ReadBody).
+			Bind(verifyDkim).
+			Bind(idavollr.ParseMimeMessage).
+			Bind(idavollr.GetBody).
+			Bind(idavollr.ReadBody).
+			Tee(archiveMessage).
+			Tee(updateTopicRecipients).
+			Bind(getMessageRecipients).
+			Tee(forwardMimirMessage).
+			Recover(idavollr.RecoverMalformedMessage).
+			Recover(recoverUnknownCategory).
+			Tee(removeMessage).
+			Recover(idavollr.RecoverErroredMessages)
+		if r.E != nil {
+			return r.E
+		}
+	}
+	return nil
+}
+
+func getMessageCategory(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) {
+	m := am.(*MimirImapMessage)
+	categoryInAddress := ""
+	recipients := append(m.Message().Envelope.To, m.Message().Envelope.Cc...)
+	for _, recipient := range recipients {
+		matches := m.categoryRegexp.FindStringSubmatch(recipient.Address())
+		if len(matches) != 2 {
+			continue
+		}
+		categoryInAddress = matches[1]
+		for _, category := range m.categories {
+			if matches[1] == category {
+				m.category = category
+				return m, nil
+			}
+		}
+	}
+	return m, UnknownCategoryError{
+		MessageID: m.Message().Envelope.MessageId,
+		Category:  categoryInAddress,
+	}
+}
+
+func verifyDkim(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) {
+	m := am.(*MimirImapMessage)
+	dkimStatus := false
+	r := bytes.NewReader(m.MessageBytes())
+	verifications, err := dkim.Verify(r)
+	if err != nil {
+		return m, err
+	}
+	for _, v := range verifications {
+		if v.Err == nil && m.Message().Envelope.From[0].HostName == v.Domain {
+			dkimStatus = true
+		}
+	}
+	m.dkimStatus = dkimStatus
+	return m, nil
+}
+
+func archiveMessage(am idavollr.AbstractImapMessage) error {
+	m := am.(*MimirImapMessage)
+	messageID := m.Message().Envelope.MessageId
+	subject := m.Message().Envelope.Subject
+	date := m.Message().Envelope.Date.UTC()
+	inReplyTo := m.Message().Envelope.InReplyTo
+	sender := m.Message().Envelope.From[0]
+	log.Printf("archiving %s\n", messageID)
+	return himinbjorg.AddArchiveEntry(m.db, messageID, m.category, subject, m.MessageBody(), date, inReplyTo, m.dkimStatus, sender, string(m.MessageBytes()))
+}
+
+func updateTopicRecipients(am idavollr.AbstractImapMessage) error {
+	m := am.(*MimirImapMessage)
+	var sender *imap.Address
+	if len(m.Message().Envelope.ReplyTo) > 0 {
+		sender = m.Message().Envelope.ReplyTo[0]
+	} else {
+		sender = m.Message().Envelope.From[0]
+	}
+	messageID := m.Message().Envelope.MessageId
+	return himinbjorg.UpdateRecipients(m.db, sender, messageID)
+}
+
+func getMessageRecipients(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) {
+	m := am.(*MimirImapMessage)
+	sender := m.Message().Envelope.From[0]
+	messageID := m.Message().Envelope.MessageId
+	recipients, err := himinbjorg.GetRecipients(m.db, messageID, sender)
+	if sender.Address() != m.config.Mimir.PersonalAddress {
+		recipients = append(recipients, strings.Replace(m.config.Mimir.ForwardAddress, "[:]", m.category, 1))
+	}
+	m.recipients = recipients
+	return m, err
+}
+
+func forwardMimirMessage(am idavollr.AbstractImapMessage) error {
+	m := am.(*MimirImapMessage)
+	messageID := m.Message().Envelope.MessageId
+	inReplyTo := m.Message().Envelope.InReplyTo
+	subject := m.Message().Envelope.Subject
+	sender := m.Message().Envelope.From[0]
+	log.Printf("forwarding %s to %v\n", messageID, m.recipients)
+	return idavollr.ForwardMessage(m.config, m.category, messageID, inReplyTo, subject, m.MessageBody(), m.recipients, sender)
+}
+
+func recoverUnknownCategory(m idavollr.AbstractImapMessage, err error) (idavollr.AbstractImapMessage, error) {
+	var unknownCategoryError UnknownCategoryError
+	if errors.As(err, &unknownCategoryError) {
+		err = nil
+		log.Println(unknownCategoryError.Error())
+	}
+	return m, err
+}
+
+func removeMessage(am idavollr.AbstractImapMessage) error {
+	m := am.(*MimirImapMessage)
+	return idavollr.RemoveMessage(m.Client(), m.Message().Uid, m.mboxName)
+}
+
+func Serve(db *sql.DB, templatesFs embed.FS) func(w http.ResponseWriter, r *http.Request) {
+	// TODO on back check with cache
+	return func(w http.ResponseWriter, r *http.Request) {
+		path := strings.Split(r.URL.Path[1:], "/")
+		if len(path) == 1 {
+			r.ParseForm()
+			pageParam := r.Form.Get("page")
+			var (
+				page int64 = 1
+				err  error
+			)
+			if pageParam != "" {
+				page, err = strconv.ParseInt(pageParam, 10, 0)
+				if err != nil {
+					w.WriteHeader(http.StatusInternalServerError)
+					log.Println(err)
+				}
+			}
+			messages, numThreads, err := himinbjorg.GetArchivedThreads(db, page)
+			if err != nil {
+				w.WriteHeader(http.StatusInternalServerError)
+				log.Println(err)
+			}
+			t, err := template.ParseFS(templatesFs, "templates/mimir_threads.html")
+			if err != nil {
+				w.WriteHeader(http.StatusInternalServerError)
+				log.Println(err)
+			}
+			b := bytes.NewBuffer([]byte{})
+			err = t.Execute(b, ListingPage{
+				Messages: messages,
+				Page:     int(page),
+				NumPages: numThreads / 12,
+			})
+			w.Write(b.Bytes())
+		} else if len(path) == 3 && path[1] == "m" {
+			thread, err := himinbjorg.GetArchivedThread(db, path[2])
+			if err != nil {
+				var noMsgErr himinbjorg.NoMessageError
+				if errors.As(err, &noMsgErr) {
+					w.WriteHeader(http.StatusNotFound)
+					w.Write([]byte(noMsgErr.Error()))
+				} else {
+					w.WriteHeader(http.StatusInternalServerError)
+					log.Println(err)
+				}
+				return
+			}
+			t, err := template.ParseFS(templatesFs, "templates/mimir_message.html")
+			if err != nil {
+				w.WriteHeader(http.StatusInternalServerError)
+				log.Println(err)
+			}
+			b := bytes.NewBuffer([]byte{})
+			err = t.Execute(b, thread)
+			w.Write(b.Bytes())
+		} else {
+			w.WriteHeader(http.StatusNotFound)
+		}
+	}
+}




diff --git a/mimir.go b/mimir.go
deleted file mode 100644
index 61b7ad93687c58c4caa87cfabe9795b591a2fbd7..0000000000000000000000000000000000000000
--- a/mimir.go
+++ /dev/null
@@ -1,338 +0,0 @@
-package main
-
-// todo test utf-8 in subject, sender (mímir), body
-
-// ---- release v1
-
-/* todo views:
-default: threads click-> thread
-thread: linear messages by datetime oldest on top, with #message_id, and is_reply_to: <a href=#message_id>
-
-search ordered by datetime, newest on top; thread – most recent message
-
-search by time: messages [in thread] click-> thread#message_id
-search by from: messages [in thread] click-> thread#message_id
-filter by category: inherit
-|search by subject: threads click-> thread
-|search by subject+: messages [in thread] click-> thread#message_id
-|search by full text: messages [in thread] click-> thread#message_id
-*/
-
-/* todo moderation:
-ban address, right to forget, unsubscribe (from one topic, from all topics)
-*/
-
-// ---- release v2
-
-// todo in thread card: add number of messages and interested people
-// todo highlight patches
-// todo check pgp/mime signatures
-
-import (
-	"bytes"
-	"database/sql"
-	"embed"
-	"errors"
-	"fmt"
-	"html/template"
-	"log"
-	"net/http"
-	"regexp"
-	"strconv"
-	"strings"
-
-	"apiote.xyz/p/gott/v2"
-	"github.com/emersion/go-imap"
-	_ "github.com/emersion/go-message/charset"
-	"github.com/emersion/go-msgauth/dkim"
-)
-
-type UnknownCategoryError struct {
-	MessageID string
-	Category  string
-}
-
-func (e UnknownCategoryError) Error() string {
-	return fmt.Sprintf("Unknown category ‘%s’ in message %s", e.Category, e.MessageID)
-}
-
-type ListingPage struct {
-	Messages []Message
-	Page     int
-	NumPages int
-}
-
-func (l ListingPage) PrevPage() int {
-	return l.Page - 1
-}
-func (l ListingPage) NextPage() int {
-	return l.Page + 1
-}
-
-type MimirMailbox struct {
-	Mailbox
-	categories     []string
-	categoryRegexp *regexp.Regexp
-	db             *sql.DB
-}
-
-type MimirImapMessage struct {
-	ImapMessage
-	categoryRegexp *regexp.Regexp
-	categories     []string
-	category       string
-	dkimStatus     bool
-	db             *sql.DB
-	config         Config
-	recipients     []string
-	mboxName       string
-}
-
-func mimir(db *sql.DB, config Config) error {
-	r := gott.R[AbstractMailbox]{
-		S: &MimirMailbox{
-			Mailbox: Mailbox{
-				mboxName: config.Mimir.ImapInbox,
-				imapAdr:  config.Mimir.ImapAddress,
-				imapUser: config.Mimir.ImapUsername,
-				imapPass: config.Mimir.ImapPassword,
-				conf:     config,
-			},
-			db: db,
-		},
-	}.
-		Bind(getCategories).
-		Bind(prepareCategoryRegexp).
-		Bind(connect).
-		Tee(login).
-		Bind(selectInbox).
-		Tee(checkEmptyBox).
-		Map(fetchMessages).
-		Tee(archiveMessages).
-		Tee(checkFetchError).
-		Tee(expunge).
-		Recover(ignoreEmptyBox).
-		Recover(disconnect)
-
-	return r.E
-}
-
-func getCategories(am AbstractMailbox) (AbstractMailbox, error) {
-	m := am.(*MimirMailbox)
-	m.categories = m.config().Mimir.Categories
-	if len(m.categories) == 0 {
-		return m, errors.New("no categories defined")
-	}
-	return m, nil
-}
-
-func prepareCategoryRegexp(am AbstractMailbox) (AbstractMailbox, error) {
-	m := am.(*MimirMailbox)
-	if !strings.Contains(m.config().Mimir.RecipientTemplate, "[:]") {
-		return m, errors.New("recipient template does not contain ‘[:]’")
-	}
-	recipientRegexp := strings.Replace(m.config().Mimir.RecipientTemplate, "[:]", "(.*)", 1)
-	r, err := regexp.Compile(recipientRegexp)
-	m.categoryRegexp = r
-	return m, err
-}
-
-func archiveMessages(am AbstractMailbox) error {
-	m := am.(*MimirMailbox)
-	for msg := range m.messages() {
-		r := gott.R[AbstractImapMessage]{
-			S: &MimirImapMessage{
-				ImapMessage: ImapMessage{
-					msg:      msg,
-					sect:     m.section(),
-					mimetype: "text/plain",
-					cli:      m.cli,
-				},
-				categoryRegexp: m.categoryRegexp,
-				categories:     m.categories,
-				db:             m.db,
-				config:         m.config(),
-				mboxName:       m.mboxName,
-			},
-		}.
-			Bind(getMessageCategory).
-			Bind(readBody).
-			Bind(verifyDkim).
-			Bind(parseMimeMessage).
-			Bind(getBody).
-			Bind(readBody).
-			Tee(archiveMessage).
-			Tee(updateTopicRecipients).
-			Bind(getMessageRecipients).
-			Tee(forwardMimirMessage).
-			Recover(recoverMalformedMessage).
-			Recover(recoverMimirUnknownCategory).
-			Tee(removeMimirMessage).
-			Recover(recoverErroredMessages)
-		if r.E != nil {
-			return r.E
-		}
-	}
-	return nil
-}
-
-func getMessageCategory(am AbstractImapMessage) (AbstractImapMessage, error) {
-	m := am.(*MimirImapMessage)
-	categoryInAddress := ""
-	recipients := append(m.message().Envelope.To, m.message().Envelope.Cc...)
-	for _, recipient := range recipients {
-		matches := m.categoryRegexp.FindStringSubmatch(recipient.Address())
-		if len(matches) != 2 {
-			continue
-		}
-		categoryInAddress = matches[1]
-		for _, category := range m.categories {
-			if matches[1] == category {
-				m.category = category
-				return m, nil
-			}
-		}
-	}
-	return m, UnknownCategoryError{
-		MessageID: m.message().Envelope.MessageId,
-		Category:  categoryInAddress,
-	}
-}
-
-func verifyDkim(am AbstractImapMessage) (AbstractImapMessage, error) {
-	m := am.(*MimirImapMessage)
-	dkimStatus := false
-	r := bytes.NewReader(m.messageBytes())
-	verifications, err := dkim.Verify(r)
-	if err != nil {
-		return m, err
-	}
-	for _, v := range verifications {
-		if v.Err == nil && m.message().Envelope.From[0].HostName == v.Domain {
-			dkimStatus = true
-		}
-	}
-	m.dkimStatus = dkimStatus
-	return m, nil
-}
-
-func archiveMessage(am AbstractImapMessage) error {
-	m := am.(*MimirImapMessage)
-	messageID := m.message().Envelope.MessageId
-	subject := m.message().Envelope.Subject
-	date := m.message().Envelope.Date.UTC()
-	inReplyTo := m.message().Envelope.InReplyTo
-	sender := m.message().Envelope.From[0]
-	log.Printf("archiving %s\n", messageID)
-	return addArchiveEntry(m.db, messageID, m.category, subject, m.messageBody(), date, inReplyTo, m.dkimStatus, sender, string(m.messageBytes()))
-}
-
-func updateTopicRecipients(am AbstractImapMessage) error {
-	m := am.(*MimirImapMessage)
-	var sender *imap.Address
-	if len(m.message().Envelope.ReplyTo) > 0 {
-		sender = m.message().Envelope.ReplyTo[0]
-	} else {
-		sender = m.message().Envelope.From[0]
-	}
-	messageID := m.message().Envelope.MessageId
-	return updateRecipients(m.db, sender, messageID)
-}
-
-func getMessageRecipients(am AbstractImapMessage) (AbstractImapMessage, error) {
-	m := am.(*MimirImapMessage)
-	sender := m.message().Envelope.From[0]
-	messageID := m.message().Envelope.MessageId
-	recipients, err := getRecipients(m.db, messageID, sender)
-	if sender.Address() != m.config.Mimir.PersonalAddress {
-		recipients = append(recipients, strings.Replace(m.config.Mimir.ForwardAddress, "[:]", m.category, 1))
-	}
-	m.recipients = recipients
-	return m, err
-}
-
-func forwardMimirMessage(am AbstractImapMessage) error {
-	m := am.(*MimirImapMessage)
-	messageID := m.message().Envelope.MessageId
-	inReplyTo := m.message().Envelope.InReplyTo
-	subject := m.message().Envelope.Subject
-	sender := m.message().Envelope.From[0]
-	log.Printf("forwarding %s to %v\n", messageID, m.recipients)
-	return forwardMessage(m.config, m.category, messageID, inReplyTo, subject, m.messageBody(), m.recipients, sender)
-}
-
-func recoverMimirUnknownCategory(m AbstractImapMessage, err error) (AbstractImapMessage, error) {
-	var unknownCategoryError UnknownCategoryError
-	if errors.As(err, &unknownCategoryError) {
-		err = nil
-		log.Println(unknownCategoryError.Error())
-	}
-	return m, err
-}
-
-func removeMimirMessage(am AbstractImapMessage) error {
-	m := am.(*MimirImapMessage)
-	return removeMessage(m.client(), m.message().Uid, m.mboxName)
-}
-
-func mimir_serve(db *sql.DB, templatesFs embed.FS) func(w http.ResponseWriter, r *http.Request) {
-	// todo on back check with cache
-	return func(w http.ResponseWriter, r *http.Request) {
-		path := strings.Split(r.URL.Path[1:], "/")
-		if len(path) == 1 {
-			r.ParseForm()
-			pageParam := r.Form.Get("page")
-			var (
-				page int64 = 1
-				err  error
-			)
-			if pageParam != "" {
-				page, err = strconv.ParseInt(pageParam, 10, 0)
-				if err != nil {
-					w.WriteHeader(http.StatusInternalServerError)
-					log.Println(err)
-				}
-			}
-			messages, numThreads, err := getArchivedThreads(db, page)
-			if err != nil {
-				w.WriteHeader(http.StatusInternalServerError)
-				log.Println(err)
-			}
-			t, err := template.ParseFS(templatesFs, "templates/mimir_threads.html")
-			if err != nil {
-				w.WriteHeader(http.StatusInternalServerError)
-				log.Println(err)
-			}
-			b := bytes.NewBuffer([]byte{})
-			err = t.Execute(b, ListingPage{
-				Messages: messages,
-				Page:     int(page),
-				NumPages: numThreads / 12,
-			})
-			w.Write(b.Bytes())
-		} else if len(path) == 3 && path[1] == "m" {
-			thread, err := getArchivedThread(db, path[2])
-			if err != nil {
-				var noMsgErr NoMessageError
-				if errors.As(err, &noMsgErr) {
-					w.WriteHeader(http.StatusNotFound)
-					w.Write([]byte(noMsgErr.Error()))
-				} else {
-					w.WriteHeader(http.StatusInternalServerError)
-					log.Println(err)
-				}
-				return
-			}
-			t, err := template.ParseFS(templatesFs, "templates/mimir_message.html")
-			if err != nil {
-				w.WriteHeader(http.StatusInternalServerError)
-				log.Println(err)
-			}
-			b := bytes.NewBuffer([]byte{})
-			err = t.Execute(b, thread)
-			w.Write(b.Bytes())
-		} else {
-			w.WriteHeader(http.StatusNotFound)
-		}
-	}
-}




diff --git a/tyr/tyr.go b/tyr/tyr.go
new file mode 100644
index 0000000000000000000000000000000000000000..4ba18414d621365629c4d4d45e6c8d7369ead6e8
--- /dev/null
+++ b/tyr/tyr.go
@@ -0,0 +1,365 @@
+package tyr
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"database/sql"
+	"embed"
+	"fmt"
+	"html/template"
+	"io"
+	"log"
+	"net/http"
+	"strings"
+	"time"
+
+	"apiote.xyz/p/asgard/himinbjorg"
+	"apiote.xyz/p/asgard/idavollr"
+	"apiote.xyz/p/asgard/jotunheim"
+
+	"github.com/emersion/go-imap"
+	"github.com/emersion/go-imap/client"
+)
+
+func addSentTo(db *sql.DB, c *client.Client, mbox *imap.MailboxStatus, config jotunheim.Config) error {
+	from := uint32(1)
+	to := mbox.Messages
+	seqset := new(imap.SeqSet)
+	seqset.AddRange(from, to)
+
+	messages := make(chan *imap.Message, 10)
+	done := make(chan error, 1)
+	go func() {
+		done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope}, messages)
+	}()
+
+	for msg := range messages {
+		recipients := append(msg.Envelope.To, msg.Envelope.Cc...)
+		sender := msg.Envelope.From[0].Address()
+		for _, recipient := range recipients {
+			knownAddress := himinbjorg.KnownAddress{
+				AddressFrom: recipient.Address(),
+				AddressTo:   sender,
+				Ban:         false,
+			}
+			err := himinbjorg.InsertKnownAddress(db, knownAddress)
+			if err != nil {
+				log.Println(err)
+				continue
+			}
+		}
+	}
+
+	clearLocks(db, config)
+
+	if err := <-done; err != nil {
+		return err
+	}
+	return nil
+}
+
+func clearLocks(db *sql.DB, config jotunheim.Config) {
+	locks, _ := himinbjorg.ListLocks(db)
+	for _, lock := range locks {
+		knownAddresses, _ := himinbjorg.GetKnownAddress(db, lock.Address)
+		for _, knownAddress := range knownAddresses {
+			if knownAddress.AddressTo == lock.Recipient {
+				releaseQuarantine(db, config, lock, config.Tyr.ImapFolderInbox)
+			}
+		}
+	}
+}
+
+func listInboxes(c *client.Client, config jotunheim.Config) ([]*imap.MailboxInfo, error) {
+	mailboxes := make(chan *imap.MailboxInfo, 10)
+	inboxes := []*imap.MailboxInfo{}
+	done := make(chan error, 1)
+	go func() {
+		done <- c.List("", "*", mailboxes)
+	}()
+
+	for m := range mailboxes {
+		if m.Name != config.Tyr.ImapFolderArchive && m.Name != config.Tyr.ImapFolderDrafts && m.Name != config.Tyr.ImapFolderJunk &&
+			m.Name != config.Tyr.ImapFolderQuarantine && m.Name != config.Tyr.ImapFolderSent && m.Name != config.Tyr.ImapFolderTrash {
+			inboxes = append(inboxes, m)
+		}
+	}
+
+	if err := <-done; err != nil {
+		return inboxes, err
+	}
+	return inboxes, nil
+}
+
+func checkInbox(db *sql.DB, config jotunheim.Config, c *client.Client, mbox *imap.MailboxStatus) error {
+	from := uint32(1)
+	to := mbox.Messages
+	seqset := new(imap.SeqSet)
+	moveSet := new(imap.SeqSet)
+	seqset.AddRange(from, to)
+
+	messages := make(chan *imap.Message, 10)
+	done := make(chan error, 1)
+	go func() {
+		done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid}, messages)
+	}()
+
+messagesLoop:
+	for msg := range messages {
+		for _, flag := range msg.Flags {
+			if flag == imap.FlaggedFlag {
+				log.Printf("Ignoring %s as flagged\n", msg.Envelope.Subject)
+				continue messagesLoop
+			}
+		}
+		recipients := append(msg.Envelope.To, msg.Envelope.Cc...)
+		recipients = append(recipients, msg.Envelope.Bcc...)
+		domainRecipient := findDomainRecipient(recipients, config)
+		recipients_ := map[string]struct{}{}
+		for _, recipient := range recipients {
+			recipients_[recipient.Address()] = struct{}{}
+		}
+		sender := msg.Envelope.From[0]
+
+		senderRows, err := himinbjorg.GetKnownAddress(db, sender.Address())
+		if err != nil {
+			log.Println(err)
+			continue
+		}
+		lock, err := himinbjorg.GetAddressLock(db, sender.Address())
+		if err != nil {
+			log.Println(err)
+			continue
+		}
+
+		for _, senderRow := range senderRows {
+			if _, present := recipients_[senderRow.AddressTo]; present || senderRow.AddressTo == "*" {
+				if senderRow.Ban {
+					idavollr.MoveMsg(c, msg, config.Tyr.ImapFolderJunk)
+					log.Printf("%s -> %s is a known offender\n", senderRow.AddressFrom, senderRow.AddressTo)
+				} else {
+					log.Printf("%s -> %s is a known friend\n", senderRow.AddressFrom, senderRow.AddressTo)
+				}
+				continue messagesLoop
+			}
+		}
+
+		if !lock.Empty() {
+			now := time.Now()
+			weekBefore := now.AddDate(0, 0, -7)
+			if lock.Date.Before(weekBefore) {
+				if domainRecipient == config.Tyr.MainEmailAddress {
+					idavollr.SendRepeatedQuarantine(sender, lock)
+				}
+				lock.Date = time.Now()
+				himinbjorg.UpdateLock(db, lock)
+			} else {
+				log.Printf("lock %+v is still valid\n", lock)
+			}
+			log.Printf("moving repeated %v : %s from %s to quarantine\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address())
+			moveSet.AddNum(msg.Uid)
+			continue
+		}
+
+		if domainRecipient == config.Tyr.MainEmailAddress {
+			idavollr.SendQuarantine(sender)
+		}
+		lock = himinbjorg.NewLock(sender.Address(), domainRecipient)
+		himinbjorg.InsertLock(db, lock)
+		log.Printf("moving %v : %s from %s to %s to quarantine\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address(), domainRecipient)
+		moveSet.AddNum(msg.Uid)
+	}
+
+	if err := <-done; err != nil {
+		return err
+	}
+	return idavollr.MoveMultiple(c, moveSet, config.Tyr.ImapFolderQuarantine)
+}
+
+func findDomainRecipient(recipients []*imap.Address, config jotunheim.Config) string {
+	for _, recipient := range recipients {
+		if recipient.HostName == config.Tyr.RecipientDomain {
+			return recipient.Address()
+		}
+	}
+	return config.Tyr.MainEmailAddress
+}
+
+func Tyr(db *sql.DB, config jotunheim.Config) {
+	c, err := client.DialTLS(config.Tyr.ImapAddress, nil)
+	if err != nil {
+		log.Fatalln(err)
+	}
+	log.Println("Connected")
+	defer c.Logout()
+	if err := c.Login(config.Tyr.ImapUsername, config.Tyr.ImapPassword); err != nil {
+		log.Fatalln(err)
+	}
+	log.Println("Logged in")
+
+	mbox, err := c.Select(config.Tyr.ImapFolderSent, false)
+	if err != nil {
+		log.Fatalln(err)
+	}
+	err = addSentTo(db, c, mbox, config)
+	if err != nil {
+		log.Fatalln(err)
+	}
+
+	inboxes, err := listInboxes(c, config)
+	if err != nil {
+		log.Fatalln(err)
+	}
+inboxLoop:
+	for _, inbox := range inboxes {
+		for _, attribute := range inbox.Attributes {
+			if attribute == "\\Noselect" {
+				continue inboxLoop
+			}
+		}
+		mbox, err := c.Select(inbox.Name, false)
+		if err != nil {
+			log.Fatalln(err)
+		}
+		checkInbox(db, config, c, mbox)
+	}
+}
+
+func ListLocks(db *sql.DB) {
+	locks, err := himinbjorg.ListLocks(db)
+	if err != nil {
+		log.Fatalln(err)
+	}
+	if len(locks) == 0 {
+		fmt.Println("no locks")
+	}
+	for _, lock := range locks {
+		fmt.Printf("%s: %s\n", lock.Token, lock.Address)
+	}
+}
+
+func Release(db *sql.DB, config jotunheim.Config, token, addressTo, dest string) {
+	lock, err := himinbjorg.GetLock(db, token)
+	if err != nil {
+		log.Fatalln(err)
+	}
+	if addressTo != "" {
+		lock.Recipient = addressTo
+	}
+	err = releaseQuarantine(db, config, lock, dest)
+	if err != nil {
+		log.Fatalln(err)
+	}
+}
+
+type TyrData struct {
+	Address string
+	Token   string
+	Captcha string
+	Error   string
+}
+
+func releaseQuarantine(db *sql.DB, config jotunheim.Config, lock himinbjorg.Lock, dest string) error {
+	himinbjorg.DeleteLock(db, lock)
+	knownAddress := himinbjorg.KnownAddress{
+		AddressFrom: lock.Address,
+		AddressTo:   lock.Recipient,
+		Ban:         dest == config.Tyr.ImapFolderJunk,
+	}
+	err := himinbjorg.InsertKnownAddress(db, knownAddress)
+	if err != nil {
+		return err
+	}
+	c, err := client.DialTLS(config.Tyr.ImapAddress, nil)
+	if err != nil {
+		return err
+	}
+	defer c.Logout()
+	if err := c.Login(config.Tyr.ImapUsername, config.Tyr.ImapPassword); err != nil {
+		log.Fatalln(err)
+	}
+	mbox, err := c.Select(config.Tyr.ImapFolderQuarantine, false)
+	if err != nil {
+		log.Fatalln(err)
+	}
+	idavollr.MoveFromQuarantine(c, mbox, lock.Address, dest)
+	return nil
+}
+
+func Serve(db *sql.DB, config jotunheim.Config, templatesFs embed.FS) func(w http.ResponseWriter, r *http.Request) {
+	return func(w http.ResponseWriter, r *http.Request) {
+		r.ParseForm()
+		formAddress := r.Form.Get("address")
+		formToken := r.Form.Get("token")
+		formError := r.Form.Get("error")
+		if r.Method == "GET" {
+			tyrData := TyrData{
+				Address: formAddress,
+				Token:   formToken,
+				Captcha: "$696a04444feea781aeca9c546e220e0981aff4a8db0b2998decdf13265a95c31", // todo with salt and randomised time
+				Error:   formError,
+			}
+			t, _ := template.ParseFS(templatesFs, "templates/tyr.html")
+			b := bytes.NewBuffer([]byte{})
+			_ = t.Execute(b, tyrData)
+			io.Copy(w, b)
+		} else if r.Method == "POST" {
+			formCaptcha := r.Form.Get("captcha")
+			captchaResult := strings.Split(r.Form.Get("captcha_result"), "$")
+			shaCaptcha := sha256.Sum256([]byte(formCaptcha + captchaResult[0]))
+			hexCaptcha := fmt.Sprintf("%x", shaCaptcha)
+			if hexCaptcha != captchaResult[1] {
+				w.Header().Add("Location", "?error=captcha")
+				w.WriteHeader(303)
+				return
+			}
+
+			lock, err := himinbjorg.GetAddressLock(db, "*")
+			if err != nil {
+				w.WriteHeader(500)
+				w.Write([]byte(err.Error()))
+				return
+			}
+			if lock.Token != "" && lock.Token == formToken {
+				lock.Address = formAddress
+				err = releaseQuarantine(db, config, lock, config.Tyr.ImapFolderInbox)
+				if err != nil {
+					w.WriteHeader(500)
+					w.Write([]byte(err.Error()))
+				} else {
+					w.Header().Add("Location", "?error=success")
+					w.WriteHeader(303)
+				}
+				return
+			}
+
+			lock, err = himinbjorg.GetAddressLock(db, formAddress)
+			if err != nil {
+				w.WriteHeader(500)
+				w.Write([]byte(err.Error()))
+				return
+			}
+			if lock.Token == "" {
+				w.Header().Add("Location", "?error=address&address="+formAddress)
+				w.WriteHeader(303)
+				return
+			}
+			if lock.Token != formToken {
+				w.Header().Add("Location", "?error=token&address="+formAddress)
+				w.WriteHeader(303)
+				return
+			}
+			err = releaseQuarantine(db, config, lock, config.Tyr.ImapFolderInbox)
+			if err != nil {
+				w.WriteHeader(500)
+				w.Write([]byte(err.Error()))
+			} else {
+				w.Header().Add("Location", "?error=success")
+				w.WriteHeader(303)
+			}
+			return
+		} else {
+			w.WriteHeader(405)
+		}
+	}
+}




diff --git a/tyr.go b/tyr.go
deleted file mode 100644
index 1bcc79b7b9e1e50ba77cdd947370278dd483259e..0000000000000000000000000000000000000000
--- a/tyr.go
+++ /dev/null
@@ -1,397 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"crypto/sha256"
-	"database/sql"
-	"embed"
-	"fmt"
-	"html/template"
-	"io"
-	"log"
-	"math/rand"
-	"net/http"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/emersion/go-imap"
-	"github.com/emersion/go-imap/client"
-)
-
-type KnownAddress struct {
-	addressFrom string
-	addressTo   string
-	ban         bool
-}
-
-func (a KnownAddress) empty() bool {
-	return a.addressFrom == "" && a.addressTo == ""
-}
-
-type Lock struct {
-	address   string
-	token     string
-	date      time.Time
-	recipient string
-}
-
-func NewLock(address, recipient string) Lock {
-	token := strconv.FormatUint(rand.Uint64(), 16)
-	lock := Lock{
-		address:   address,
-		token:     token,
-		date:      time.Now(),
-		recipient: recipient,
-	}
-	return lock
-}
-
-func (l Lock) empty() bool {
-	return l.address == "" && l.token == "" && l.date.IsZero()
-}
-
-/* ASGARD */
-
-func addSentTo(db *sql.DB, c *client.Client, mbox *imap.MailboxStatus, config Config) error {
-	from := uint32(1)
-	to := mbox.Messages
-	seqset := new(imap.SeqSet)
-	seqset.AddRange(from, to)
-
-	messages := make(chan *imap.Message, 10)
-	done := make(chan error, 1)
-	go func() {
-		done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope}, messages)
-	}()
-
-	for msg := range messages {
-		recipients := append(msg.Envelope.To, msg.Envelope.Cc...)
-		sender := msg.Envelope.From[0].Address()
-		for _, recipient := range recipients {
-			knownAddress := KnownAddress{
-				addressFrom: recipient.Address(),
-				addressTo:   sender,
-				ban:         false,
-			}
-			err := insertKnownAddress(db, knownAddress)
-			if err != nil {
-				log.Println(err)
-				continue
-			}
-		}
-	}
-
-	clearLocks(db, config)
-
-	if err := <-done; err != nil {
-		return err
-	}
-	return nil
-}
-
-func clearLocks(db *sql.DB, config Config) {
-	locks, _ := listLocks(db)
-	for _, lock := range locks {
-		knownAddresses, _ := getKnownAddress(db, lock.address)
-		for _, knownAddress := range knownAddresses {
-			if knownAddress.addressTo == lock.recipient {
-				releaseQuarantine(db, config, lock, config.Tyr.ImapFolderInbox)
-			}
-		}
-	}
-}
-
-func listInboxes(c *client.Client, config Config) ([]*imap.MailboxInfo, error) {
-	mailboxes := make(chan *imap.MailboxInfo, 10)
-	inboxes := []*imap.MailboxInfo{}
-	done := make(chan error, 1)
-	go func() {
-		done <- c.List("", "*", mailboxes)
-	}()
-
-	for m := range mailboxes {
-		if m.Name != config.Tyr.ImapFolderArchive && m.Name != config.Tyr.ImapFolderDrafts && m.Name != config.Tyr.ImapFolderJunk &&
-			m.Name != config.Tyr.ImapFolderQuarantine && m.Name != config.Tyr.ImapFolderSent && m.Name != config.Tyr.ImapFolderTrash {
-			inboxes = append(inboxes, m)
-		}
-	}
-
-	if err := <-done; err != nil {
-		return inboxes, err
-	}
-	return inboxes, nil
-}
-
-func checkInbox(db *sql.DB, config Config, c *client.Client, mbox *imap.MailboxStatus) error {
-	from := uint32(1)
-	to := mbox.Messages
-	seqset := new(imap.SeqSet)
-	moveSet := new(imap.SeqSet)
-	seqset.AddRange(from, to)
-
-	messages := make(chan *imap.Message, 10)
-	done := make(chan error, 1)
-	go func() {
-		done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid}, messages)
-	}()
-
-messagesLoop:
-	for msg := range messages {
-		for _, flag := range msg.Flags {
-			if flag == imap.FlaggedFlag {
-				log.Printf("Ignoring %s as flagged\n", msg.Envelope.Subject)
-				continue messagesLoop
-			}
-		}
-		recipients := append(msg.Envelope.To, msg.Envelope.Cc...)
-		recipients = append(recipients, msg.Envelope.Bcc...)
-		domainRecipient := findDomainRecipient(recipients, config)
-		recipients_ := map[string]struct{}{}
-		for _, recipient := range recipients {
-			recipients_[recipient.Address()] = struct{}{}
-		}
-		sender := msg.Envelope.From[0]
-
-		senderRows, err := getKnownAddress(db, sender.Address())
-		if err != nil {
-			log.Println(err)
-			continue
-		}
-		lock, err := getAddressLock(db, sender.Address())
-		if err != nil {
-			log.Println(err)
-			continue
-		}
-
-		for _, senderRow := range senderRows {
-			if _, present := recipients_[senderRow.addressTo]; present || senderRow.addressTo == "*" {
-				if senderRow.ban {
-					moveMsg(c, msg, config.Tyr.ImapFolderJunk)
-					log.Printf("%s -> %s is a known offender\n", senderRow.addressFrom, senderRow.addressTo)
-				} else {
-					log.Printf("%s -> %s is a known friend\n", senderRow.addressFrom, senderRow.addressTo)
-				}
-				continue messagesLoop
-			}
-		}
-
-		if !lock.empty() {
-			now := time.Now()
-			weekBefore := now.AddDate(0, 0, -7)
-			if lock.date.Before(weekBefore) {
-				if domainRecipient == config.Tyr.MainEmailAddress {
-					sendRepeatedQuarantine(sender, lock)
-				}
-				lock.date = time.Now()
-				updateLock(db, lock)
-			} else {
-				log.Printf("lock %+v is still valid\n", lock)
-			}
-			log.Printf("moving repeated %v : %s from %s to quarantine\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address())
-			moveSet.AddNum(msg.Uid)
-			continue
-		}
-
-		if domainRecipient == config.Tyr.MainEmailAddress {
-			sendQuarantine(sender)
-		}
-		lock = NewLock(sender.Address(), domainRecipient)
-		insertLock(db, lock)
-		log.Printf("moving %v : %s from %s to %s to quarantine\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address(), domainRecipient)
-		moveSet.AddNum(msg.Uid)
-	}
-
-	if err := <-done; err != nil {
-		return err
-	}
-	return moveMultiple(c, moveSet, config.Tyr.ImapFolderQuarantine)
-}
-
-func findDomainRecipient(recipients []*imap.Address, config Config) string {
-	for _, recipient := range recipients {
-		if recipient.HostName == config.Tyr.RecipientDomain {
-			return recipient.Address()
-		}
-	}
-	return config.Tyr.MainEmailAddress
-}
-
-func tyr(db *sql.DB, config Config) {
-	c, err := client.DialTLS(config.Tyr.ImapAddress, nil)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	log.Println("Connected")
-	defer c.Logout()
-	if err := c.Login(config.Tyr.ImapUsername, config.Tyr.ImapPassword); err != nil {
-		log.Fatalln(err)
-	}
-	log.Println("Logged in")
-
-	mbox, err := c.Select(config.Tyr.ImapFolderSent, false)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	err = addSentTo(db, c, mbox, config)
-	if err != nil {
-		log.Fatalln(err)
-	}
-
-	inboxes, err := listInboxes(c, config)
-	if err != nil {
-		log.Fatalln(err)
-	}
-inboxLoop:
-	for _, inbox := range inboxes {
-		for _, attribute := range inbox.Attributes {
-			if attribute == "\\Noselect" {
-				continue inboxLoop
-			}
-		}
-		mbox, err := c.Select(inbox.Name, false)
-		if err != nil {
-			log.Fatalln(err)
-		}
-		checkInbox(db, config, c, mbox)
-	}
-}
-
-func tyr_lists_locks(db *sql.DB) {
-	locks, err := listLocks(db)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	if len(locks) == 0 {
-		fmt.Println("no locks")
-	}
-	for _, lock := range locks {
-		fmt.Printf("%s: %s\n", lock.token, lock.address)
-	}
-}
-
-func tyr_release(db *sql.DB, config Config, token, addressTo, dest string) {
-	lock, err := getLock(db, token)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	if addressTo != "" {
-		lock.recipient = addressTo
-	}
-	err = releaseQuarantine(db, config, lock, dest)
-	if err != nil {
-		log.Fatalln(err)
-	}
-}
-
-type TyrData struct {
-	Address string
-	Token   string
-	Captcha string
-	Error   string
-}
-
-func releaseQuarantine(db *sql.DB, config Config, lock Lock, dest string) error {
-	deleteLock(db, lock)
-	knownAddress := KnownAddress{
-		addressFrom: lock.address,
-		addressTo:   lock.recipient,
-		ban:         dest == config.Tyr.ImapFolderJunk,
-	}
-	err := insertKnownAddress(db, knownAddress)
-	if err != nil {
-		return err
-	}
-	c, err := client.DialTLS(config.Tyr.ImapAddress, nil)
-	if err != nil {
-		return err
-	}
-	defer c.Logout()
-	if err := c.Login(config.Tyr.ImapUsername, config.Tyr.ImapPassword); err != nil {
-		log.Fatalln(err)
-	}
-	mbox, err := c.Select(config.Tyr.ImapFolderQuarantine, false)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	moveFromQuarantine(c, mbox, lock.address, dest)
-	return nil
-}
-
-func tyr_serve(db *sql.DB, config Config, templatesFs embed.FS) func(w http.ResponseWriter, r *http.Request) {
-	return func(w http.ResponseWriter, r *http.Request) {
-		r.ParseForm()
-		formAddress := r.Form.Get("address")
-		formToken := r.Form.Get("token")
-		formError := r.Form.Get("error")
-		if r.Method == "GET" {
-			tyrData := TyrData{
-				Address: formAddress,
-				Token:   formToken,
-				Captcha: "$696a04444feea781aeca9c546e220e0981aff4a8db0b2998decdf13265a95c31", // todo with salt and randomised time
-				Error:   formError,
-			}
-			t, _ := template.ParseFS(templatesFs, "templates/tyr.html")
-			b := bytes.NewBuffer([]byte{})
-			_ = t.Execute(b, tyrData)
-			io.Copy(w, b)
-		} else if r.Method == "POST" {
-			formCaptcha := r.Form.Get("captcha")
-			captchaResult := strings.Split(r.Form.Get("captcha_result"), "$")
-			shaCaptcha := sha256.Sum256([]byte(formCaptcha + captchaResult[0]))
-			hexCaptcha := fmt.Sprintf("%x", shaCaptcha)
-			if hexCaptcha != captchaResult[1] {
-				w.Header().Add("Location", "?error=captcha")
-				w.WriteHeader(303)
-				return
-			}
-
-			lock, err := getAddressLock(db, "*")
-			if err != nil {
-				w.WriteHeader(500)
-				w.Write([]byte(err.Error()))
-				return
-			}
-			if lock.token != "" && lock.token == formToken {
-				lock.address = formAddress
-				err = releaseQuarantine(db, config, lock, config.Tyr.ImapFolderInbox)
-				if err != nil {
-					w.WriteHeader(500)
-					w.Write([]byte(err.Error()))
-				} else {
-					w.Header().Add("Location", "?error=success")
-					w.WriteHeader(303)
-				}
-				return
-			}
-
-			lock, err = getAddressLock(db, formAddress)
-			if err != nil {
-				w.WriteHeader(500)
-				w.Write([]byte(err.Error()))
-				return
-			}
-			if lock.token == "" {
-				w.Header().Add("Location", "?error=address&address="+formAddress)
-				w.WriteHeader(303)
-				return
-			}
-			if lock.token != formToken {
-				w.Header().Add("Location", "?error=token&address="+formAddress)
-				w.WriteHeader(303)
-				return
-			}
-			err = releaseQuarantine(db, config, lock, config.Tyr.ImapFolderInbox)
-			if err != nil {
-				w.WriteHeader(500)
-				w.Write([]byte(err.Error()))
-			} else {
-				w.Header().Add("Location", "?error=success")
-				w.WriteHeader(303)
-			}
-			return
-		} else {
-			w.WriteHeader(405)
-		}
-	}
-}




diff --git a/vor/vor.go b/vor/vor.go
new file mode 100644
index 0000000000000000000000000000000000000000..8ccb8f0d76044d30c1a2c4a852d5adc7c1b3250f
--- /dev/null
+++ b/vor/vor.go
@@ -0,0 +1,4 @@
+package main
+
+func vor() {
+}




diff --git a/vor.go b/vor.go
deleted file mode 100644
index 8ccb8f0d76044d30c1a2c4a852d5adc7c1b3250f..0000000000000000000000000000000000000000
--- a/vor.go
+++ /dev/null
@@ -1,4 +0,0 @@
-package main
-
-func vor() {
-}