asgard.git

commit 38f2cbc12ac007dcbe42394dc4310547f75fd929

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

conflict

 config_example.dirty | 6 
  | 140 ++++++-----
  | 6 
 eostre.sh | 18 +
  | 10 
 gersemi.go | 363 -------------------------------
 gersemi/gersemi.go | 313 +++++++++++++++++++++++++++
 go.mod | 2 
 hermodr.go | 211 ------------------
 hermodr/hermodr.go | 184 ++++++++++++++++
 himinbjorg/address.go | 22 +
 himinbjorg/knownAddress.go | 11 
 himinbjorg/lock.go | 29 ++
 himinbjorg/message.go | 28 ++
 idavollr/errors.go | 20 +
 idavollr/mailbox.go | 162 ++++++++++++++
 idavollr/message.go | 164 ++++++++++++++
  | 46 +--
 jotunheim/config.go | 124 ++++++++++
 main.go | 149 ++----------
 mimir.go | 457 ----------------------------------------
 mimir/mimir.go | 343 ++++++++++++++++++++++++++++++
 tyr.go | 366 --------------------------------
 tyr/tyr.go | 365 +++++++++++++++++++++++++++++++
  | 0 


diff --git a/config_example.dirty b/config_example.dirty
index 570f2484cd838af55d78008218c7d814ec8163f9..d10150a89d2d46220a63acc1aeb279a6e4116c16 100644
--- a/config_example.dirty
+++ b/config_example.dirty
@@ -11,6 +11,8 @@ 			('imapFolderDrafts' 'Drafts')
 			('imapFolderQuarantine' 'Quarantine')
 			('imapFolderSent' 'Sent')
 			('imapFolderTrash' 'Trash')
+			('recipientDomain' '')
+			('mainEmailAddress' '')
 		)
 	)
 	('hermodr'
@@ -35,11 +37,12 @@ 			('imapUsername' '')
 			('imapPassword' '')
 			('imapInbox' 'Inbox')
 			('recipientTemplate' '[:]@exmaple.com')
-			('categories' ('cars', 'music'))
+			('categories' ('cars' 'music'))
 			('forwardAddress' 'user+[:]@exmaple.com')
 			('personalAddress' 'user@example.com')
 			('smtpAddress' '')
 			('smtpSender' '')
+			('companion' '')
 		)
 	)
 	('eostre'
@@ -79,6 +82,7 @@ 			('firefly' '')
 			('imapAddress' '')
 			('imapUsername' '')
 			('imapPassword' '')
+			('imapInbox' '')
 			('messageMime' '')
 			('doneFolder' '')
 			('defaultSource' '')




diff --git a/db.go b/db.go
deleted file mode 100644
index b12efb681ea49bc271a60eb3b598c07f919fdca5..0000000000000000000000000000000000000000
--- a/db.go
+++ /dev/null
@@ -1,281 +0,0 @@
-package main
-
-import (
-	"database/sql"
-	"fmt"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/emersion/go-imap"
-
-	_ "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) {
-	db, err := open(dbPath)
-	_, 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
-	}
-
-	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", 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)
-	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 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.sh b/eostre.sh
index 96b7791a2e906b58b1f38c7e8d38280c1a8ad359..02f1910cc343e0d319debe2e0ac1a542fb41e54d 100755
--- a/eostre.sh
+++ b/eostre.sh
@@ -1,6 +1,19 @@
 #!/bin/sh
 # NEEDS sed,grep,zip,unzip
 
+findTemplate() {
+	templateName=$1
+	for d in /usr/share/asgard/templates ~/.local/share/asgard/templates templates .
+	do
+		if stat "$d/$templateName" >/dev/null 2>&1
+		then
+			echo "$d/$templateName"
+			return
+		fi
+	done
+	return 1
+}
+
 set -e
 
 # shellcheck disable=SC2010
@@ -49,12 +62,13 @@ for year in $years
 do
 	manifest="$manifest\n\t\t<item href=\"Text/$year.xhtml\" id=\"$year.xhtml\" media-type=\"application/xhtml+xml\"/>"
 	spine="$spine\n\t\t<itemref idref=\"$year.xhtml\"/>"
-	cp templates/content.opf.template tmp/OEBPS/content.opf
+	
+	cp "$(findTemplate content.opf.template)" tmp/OEBPS/content.opf
 	sed -i "s|{{manifest}}|$manifest|" tmp/OEBPS/content.opf
 	sed -i "s|{{spine}}|$spine|" tmp/OEBPS/content.opf
 
 	toc="$toc\n\t\t<li><a href=\"$year.xhtml\">$year</a></li>"
-	cp templates/nav.xhtml.template tmp/OEBPS/Text/nav.xhtml
+	cp "$(findTemplate nav.xhtml.template)" tmp/OEBPS/Text/nav.xhtml
 	sed -i "s|{{toc}}|$toc|" tmp/OEBPS/Text/nav.xhtml
 done
 




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 3103ca1d0401dbd9da969de7ccb0c6815c86ef77..0000000000000000000000000000000000000000
--- a/gersemi.go
+++ /dev/null
@@ -1,363 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"log"
-	"net/http"
-	"regexp"
-	"strings"
-	"time"
-
-	"apiote.xyz/p/gott/v2"
-	"github.com/emersion/go-imap"
-	"github.com/emersion/go-imap/client"
-	"github.com/emersion/go-message"
-	_ "github.com/emersion/go-message/charset"
-)
-
-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 gersemiStruct struct {
-	c      *client.Client
-	config Config
-	hc     *http.Client
-
-	mbox              *imap.MailboxStatus
-	section           *imap.BodySectionName
-	messages          chan *imap.Message
-	done              chan error
-	withdrawalRegexes []*regexp.Regexp
-	depositRegexes    []*regexp.Regexp
-
-	message             *imap.Message
-	messageBytes        []byte
-	mimeMessage         *message.Entity
-	plainTextBodyReader io.Reader
-	plainTextBody       []byte
-	src, dst            string
-	title               string
-	amount              string
-	day, month, year    string
-	requestBody         GersemiRequestBody
-	requestBodyBytes    []byte
-	request             *http.Request
-	response            *http.Response
-}
-
-func gersemi(config Config) error {
-	c, err := client.DialTLS(config.Gersemi.ImapAddress, nil)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	log.Println("Connected")
-	defer c.Close()
-	defer c.Logout()
-	if err := c.Login(config.Gersemi.ImapUsername, config.Gersemi.ImapPassword); err != nil {
-		log.Fatalln(err)
-	}
-	log.Println("Logged in")
-
-	timeout, _ := time.ParseDuration("60s")
-	r := gott.R[gersemiStruct]{
-		S: gersemiStruct{
-			c:      c,
-			config: config,
-			hc: &http.Client{
-				Timeout: timeout,
-			},
-		},
-	}.
-		Bind(prepareRegexes).
-		Bind(selectGersemiInbox).
-		Tee(checkGersemiEmptyBox).
-		Map(fetchGersemiMessages).
-		Bind(createTransactions).
-		Recover(ignoreGersemiEmptyBox)
-
-	return r.E
-}
-
-func prepareRegexes(s gersemiStruct) (gersemiStruct, error) {
-	for i, wr := range s.config.Gersemi.WithdrawalRegexes {
-		re, err := regexp.Compile(wr)
-		if err != nil {
-			return s, fmt.Errorf("while compiling withdrawal regex %d: %w", i, err)
-		}
-		s.withdrawalRegexes = append(s.withdrawalRegexes, re)
-	}
-
-	for i, dr := range s.config.Gersemi.DepositRegexes {
-		re, err := regexp.Compile(dr)
-		if err != nil {
-			return s, fmt.Errorf("while compiling deposit regex %d: %w", i, err)
-		}
-		s.depositRegexes = append(s.depositRegexes, re)
-	}
-
-	return s, nil
-}
-
-func selectGersemiInbox(s gersemiStruct) (gersemiStruct, error) {
-	mbox, err := s.c.Select("INBOX", false)
-	s.mbox = mbox
-	return s, err
-}
-
-func checkGersemiEmptyBox(s gersemiStruct) error {
-	return checkImapEmptyBox(s.mbox)
-}
-
-func fetchGersemiMessages(s gersemiStruct) gersemiStruct { // fixme same as in mimir
-	from := uint32(1)
-	to := s.mbox.Messages
-	seqset := new(imap.SeqSet)
-	seqset.AddRange(from, to)
-
-	s.section = &imap.BodySectionName{}
-	items := []imap.FetchItem{imap.FetchEnvelope, s.section.FetchItem(), imap.FetchUid}
-	s.messages = make(chan *imap.Message, 10)
-	s.done = make(chan error, 1)
-	go func() {
-		s.done <- s.c.Fetch(seqset, items, s.messages)
-	}()
-	return s
-}
-
-func ignoreGersemiEmptyBox(s gersemiStruct, e error) (gersemiStruct, error) {
-	var emptyBoxError EmptyBoxError
-	if errors.As(e, &emptyBoxError) {
-		log.Println("Mailbox is empty")
-		return s, nil
-	}
-	return s, e
-}
-
-func createTransactions(s gersemiStruct) (gersemiStruct, error) {
-	for msg := range s.messages {
-		s.message = msg
-		r := gott.R[gersemiStruct]{S: s}.
-			Bind(readGersemiMsgBody).
-			Bind(parseGersemiMimeMessage).
-			Bind(getGersemiBody).
-			Bind(readGersemiBody).
-			Bind(createTransaction).
-			Bind(marshalBody).
-			Bind(createRequest).
-			Bind(doRequest).
-			Bind(handleHttpError).
-			SafeTee(moveGersemiMessage).
-			Recover(ignoreGersemiInvalidMessage).
-			Recover(recoverGersemiMalformedMessage).
-			Recover(recoverGersemiErroredMessages)
-		if r.E != nil {
-			return s, r.E
-		}
-	}
-	return s, nil
-}
-
-func readGersemiMsgBody(s gersemiStruct) (gersemiStruct, error) {
-	r := s.message.GetBody(s.section)
-	if r == nil {
-		return s, MalformedMessageError{
-			Cause:     errors.New("no body in message"),
-			MessageID: s.message.Envelope.MessageId,
-		}
-	}
-	messageBytes, err := io.ReadAll(r)
-	s.messageBytes = messageBytes
-	return s, err
-}
-
-func parseGersemiMimeMessage(s gersemiStruct) (gersemiStruct, error) {
-	r := bytes.NewReader(s.messageBytes)
-	m, err := message.Read(r)
-	s.mimeMessage = m
-	return s, err
-}
-
-func getGersemiBody(s gersemiStruct) (gersemiStruct, error) {
-	body, err := getNextPart(s.mimeMessage, s.message.Envelope.MessageId, s.config.Gersemi.MessageMime)
-	s.plainTextBodyReader = body
-	return s, err
-}
-
-func readGersemiBody(s gersemiStruct) (gersemiStruct, error) {
-	body, err := io.ReadAll(s.plainTextBodyReader)
-	s.plainTextBody = body
-	return s, err
-}
-
-func matchRegex(s gersemiStruct, match []string, groupNames []string) gersemiStruct {
-	for groupIdx, group := range match {
-		names := groupNames[groupIdx]
-		for _, name := range strings.Split(names, "_") {
-			switch name {
-			case "TITLE":
-				s.title = regexp.MustCompile(" +").ReplaceAllString(group, " ")
-			case "SRC":
-				s.src = group
-			case "DST":
-				s.dst = regexp.MustCompile(" +").ReplaceAllString(group, " ")
-			case "AMOUNTC":
-				s.amount = group
-				s.amount = strings.Replace(s.amount, ",", ".", -1)
-			case "AMOUNT":
-				s.amount = group
-			case "DAY":
-				s.day = group
-			case "MONTH":
-				s.month = group
-			case "YEAR":
-				s.year = group
-			}
-		}
-	}
-	return s
-}
-
-func createTransaction(s gersemiStruct) (gersemiStruct, error) {
-	s.src = s.config.Gersemi.DefaultSource
-	for _, wr := range s.withdrawalRegexes {
-		groupNames := wr.SubexpNames()
-		matches := wr.FindAllStringSubmatch(string(s.plainTextBody), -1)
-		if matches == nil {
-			continue
-		}
-		match := matches[0]
-		s = matchRegex(s, match, groupNames)
-		transaction := Withdrawal{
-			TransactionData: TransactionData{
-				Type:        "withdrawal",
-				Date:        s.year + "-" + s.month + "-" + s.day + "T06:00:00+00:00",
-				Amount:      s.amount,
-				Description: s.title,
-			},
-			SourceID:        s.config.Gersemi.Accounts[s.src],
-			DestinationName: s.dst,
-		}
-		body := GersemiRequestBody{
-			Transactions: []Transaction{transaction},
-		}
-		s.requestBody = body
-		return s, nil
-	}
-	for _, dr := range s.depositRegexes {
-		groupNames := dr.SubexpNames()
-		matches := dr.FindAllStringSubmatch(string(s.plainTextBody), -1)
-		if matches == nil {
-			continue
-		}
-		match := matches[0]
-		s = matchRegex(s, match, groupNames)
-		transaction := Deposit{
-			TransactionData: TransactionData{
-				Type:        "deposit",
-				Date:        s.year + "-" + s.month + "-" + s.day + "T06:00:00+00:00",
-				Amount:      s.amount,
-				Description: s.title,
-			},
-			SourceName:    s.src,
-			DestinationID: s.config.Gersemi.Accounts[s.dst],
-		}
-		body := GersemiRequestBody{
-			Transactions: []Transaction{transaction},
-		}
-		s.requestBody = body
-		return s, nil
-	}
-
-	return s, InvalidMessageError{}
-}
-
-func marshalBody(s gersemiStruct) (_ gersemiStruct, err error) {
-	s.requestBodyBytes, err = json.Marshal(s.requestBody)
-	return s, err
-}
-
-func createRequest(s gersemiStruct) (_ gersemiStruct, err error) {
-	s.request, err = http.NewRequest("POST", s.config.Gersemi.Firefly+"/api/v1/transactions", bytes.NewReader(s.requestBodyBytes))
-	s.request.Header.Add("Authorization", "Bearer "+s.config.Gersemi.FireflyToken)
-	s.request.Header.Add("Accept", "application/vnd.api+json")
-	s.request.Header.Add("Content-Type", "application/json")
-	return s, err
-}
-
-func doRequest(s gersemiStruct) (_ gersemiStruct, err error) {
-	s.response, err = s.hc.Do(s.request)
-	return s, err
-}
-
-func handleHttpError(s gersemiStruct) (gersemiStruct, error) {
-	if s.response.StatusCode != 200 {
-		return s, fmt.Errorf(s.response.Status)
-	}
-	return s, nil
-}
-
-func moveGersemiMessage(s gersemiStruct) {
-	moveMsg(s.c, s.message, s.config.Gersemi.DoneFolder)
-}
-
-func ignoreGersemiInvalidMessage(s gersemiStruct, e error) (gersemiStruct, error) {
-	var invalidMessageErr InvalidMessageError
-	if errors.As(e, &invalidMessageErr) {
-		log.Println(e.Error())
-		return s, nil
-	}
-	return s, e
-}
-
-func recoverGersemiMalformedMessage(s gersemiStruct, err error) (gersemiStruct, error) {
-	var malformedMessageError MalformedMessageError
-	if errors.As(err, &malformedMessageError) {
-		err = nil
-		log.Println(malformedMessageError.Error())
-		log.Println(string(s.messageBytes))
-	}
-	return s, err
-}
-
-func recoverGersemiErroredMessages(s gersemiStruct, err error) (gersemiStruct, error) {
-	log.Printf("message %s errored: %v\n", s.message.Envelope.MessageId, err)
-	return s, nil
-}




diff --git a/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 2b070345719cf5c7da2e8cd44b6bd8199eab0ec1..0000000000000000000000000000000000000000
--- a/hermodr.go
+++ /dev/null
@@ -1,211 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"io"
-	"net/mail"
-	"os"
-	"strings"
-
-	"github.com/ProtonMail/gopenpgp/v2/helper"
-	"github.com/emersion/go-imap"
-	"github.com/emersion/go-imap/client"
-	"github.com/emersion/go-smtp"
-	"notabug.org/apiote/gott"
-)
-
-type EmptyMessageError struct{}
-
-func (EmptyMessageError) Error() string {
-	return "Server didn't return message body"
-}
-
-type Result struct {
-	Config Config
-
-	Client    *client.Client
-	Mailbox   *imap.MailboxStatus
-	Literal   imap.Literal
-	Message   *mail.Message
-	Body      string
-	PlainText string
-	Armour    string
-}
-
-func connect(args ...interface{}) (interface{}, error) {
-	result := args[0].(Result)
-	c, err := client.Dial(result.Config.Hermodr.ImapAddress)
-	result.Client = c
-	return result, err
-}
-
-func login(args ...interface{}) error {
-	result := args[0].(Result)
-	err := result.Client.Login(result.Config.Hermodr.ImapUsername, result.Config.Hermodr.ImapPassword)
-	return err
-}
-
-func selectInbox(args ...interface{}) (interface{}, error) {
-	result := args[0].(Result)
-	mbox, err := result.Client.Select(result.Config.Hermodr.ImapFolderInbox, false)
-	result.Mailbox = mbox
-	return result, err
-}
-
-func redirectMessages(args ...interface{}) (interface{}, error) {
-	result := args[0].(Result)
-
-	for result.Mailbox.Messages > 0 {
-		r, err := gott.NewResult(result).
-			Bind(getMessage).
-			Bind(readMessage).
-			Bind(readBody).
-			Map(composePlaintextBody).
-			Bind(encrypt).
-			Tee(send).
-			Tee(markRead).
-			Tee(moveMessage).
-			Bind(selectInbox).
-			Finish()
-		if err != nil {
-			return r, err
-		}
-		result = r.(Result)
-	}
-	return result, nil
-}
-func getMessage(args ...interface{}) (interface{}, error) {
-	result := args[0].(Result)
-
-	seqset := new(imap.SeqSet)
-	seqset.AddNum(1)
-
-	section := &imap.BodySectionName{}
-	items := []imap.FetchItem{section.FetchItem()}
-
-	messages := make(chan *imap.Message, 1)
-	done := make(chan error, 1)
-	go func() {
-		done <- result.Client.Fetch(seqset, items, messages)
-	}()
-
-	msg := <-messages
-	r := msg.GetBody(section)
-	if r == nil {
-		return result, EmptyMessageError{}
-	}
-	result.Literal = r
-
-	err := <-done
-	return result, err
-}
-
-func readMessage(args ...interface{}) (interface{}, error) {
-	result := args[0].(Result)
-	m, err := mail.ReadMessage(result.Literal)
-	result.Message = m
-	return result, err
-}
-
-func readBody(args ...interface{}) (interface{}, error) {
-	result := args[0].(Result)
-	body, err := io.ReadAll(result.Message.Body)
-	result.Body = string(body)
-	return result, err
-}
-
-func composePlaintextBody(args ...interface{}) interface{} {
-	result := args[0].(Result)
-
-	header := result.Message.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(result.Body)
-	result.PlainText = plainText
-	return result
-}
-
-func encrypt(args ...interface{}) (interface{}, error) {
-	result := args[0].(Result)
-	armour, err := helper.EncryptMessageArmored(result.Config.Hermodr.PublicKey, result.PlainText)
-	result.Armour = armour
-	return result, err
-}
-
-func send(args ...interface{}) error {
-	result := args[0].(Result)
-
-	from := result.Message.Header.Get("From")
-	date := result.Message.Header.Get("Date")
-	messageID := result.Message.Header.Get("Message-ID")
-	to := []string{result.Config.Hermodr.Recipient}
-	msg := strings.NewReader("To: " + result.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" +
-		result.Armour +
-		"\r\n" +
-		"\r\n" +
-		"-----------------------997d365ae018229dc62ea2ff6b617cac--\r\n")
-	err := smtp.SendMail(result.Config.Hermodr.SmtpServer, nil, result.Config.Hermodr.SmtpUsername, to, msg)
-	return err
-}
-
-func markRead(args ...interface{}) error {
-	result := args[0].(Result)
-
-	seqset := new(imap.SeqSet)
-	seqset.AddNum(1)
-	item := imap.FormatFlagsOp(imap.AddFlags, true)
-	flags := []interface{}{imap.SeenFlag}
-	err := result.Client.Store(seqset, item, flags, nil)
-	return err
-}
-
-func moveMessage(args ...interface{}) error {
-	result := args[0].(Result)
-
-	seqset := new(imap.SeqSet)
-	seqset.AddNum(1)
-	err := result.Client.Copy(seqset, result.Config.Hermodr.ImapFolderRedirected)
-	return err
-}
-
-func hermodr(config Config) {
-	r, err := gott.NewResult(Result{Config: config}).
-		SetLevelLog(gott.Debug).
-		Bind(connect).
-		Tee(login).
-		Bind(selectInbox).
-		Bind(redirectMessages).
-		Finish()
-
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "Error: %v", err)
-		return
-	}
-	// Don't forget to logout
-	if r.(Result).Client != nil {
-		r.(Result).Client.Logout()
-	}
-
-}




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 cb83b5aec054634519d1da8a1c8b81135c1a3bad..0000000000000000000000000000000000000000
--- a/imap.go
+++ /dev/null
@@ -1,158 +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"
-)
-
-type EmptyBoxError struct{}
-
-func (EmptyBoxError) Error() string {
-	return ""
-}
-
-func moveMsg(c *client.Client, msg *imap.Message, dest string) {
-	log.Printf("moving %v : %s from %s to %s\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address(), dest)
-	moveClient := move.NewClient(c)
-	seqSet := new(imap.SeqSet)
-	seqSet.AddNum(msg.Uid)
-	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)
-}
-
-func checkImapEmptyBox(mbox *imap.MailboxStatus) error {
-	if mbox.Messages == 0 {
-		return EmptyBoxError{}
-	}
-	return 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 3b2acfbbdd9dd9369b7a7459b3bf6c5a1eb8be16..7eb9b6a0e60fd5104f5931b0865ca290d9ab9613 100644
--- a/main.go
+++ b/main.go
@@ -1,112 +1,25 @@
 package main
 
 import (
+	"embed"
 	"errors"
 	"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"
 )
 
-type TyrConfig struct {
-	ImapAddress          string
-	ImapUsername         string
-	ImapPassword         string
-	ImapFolderInbox      string
-	ImapFolderJunk       string
-	ImapFolderArchive    string
-	ImapFolderTrash      string
-	ImapFolderDrafts     string
-	ImapFolderQuarantine string
-	ImapFolderSent       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) {
-	if configPath == "" {
-		// TODO try XDG_CONFIG/asgard.dirty, /etc/asgard.dirty, ~/.config/asgard.dirty
-		configPath = "asgard.dirty"
-	}
-	file, err := os.Open(configPath)
-	if err != nil {
-		log.Printf("error opening configuration %v\n", err)
-		return Config{}, err
-	}
-	defer file.Close()
-	config := Config{}
-	err = dirty.LoadStruct(file, &config)
-	return config, err
-}
+//go:embed templates
+var templatesFS embed.FS
 
 func main() {
 	var (
@@ -114,15 +27,15 @@ 		configPath string
 		dbPath     string
 	)
 	getopt.StringVar(&configPath, "c", "", "path to config file")
-	getopt.StringVar(&dbPath, "b", "asgard.db", "path to database 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)
 	}
-	//log.Printf("running with conifig\n%+v\n", config)
-	db, err := migrate(dbPath)
+
+	db, err := himinbjorg.Migrate(dbPath)
 	if err != nil {
 		log.Fatalln(err)
 	}
@@ -134,45 +47,43 @@ 	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)")
 				}
 				addressTo := ""
-				if len(args) == 3 {
-					addressTo = "*"
-				} else {
+				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
@@ -182,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))
-		http.HandleFunc("/mimir", mimir_serve(db))
-		http.HandleFunc("/mimir/", mimir_serve(db))
+		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 652b8e972578fa55b7edac0a8c3696e2ae89b65c..0000000000000000000000000000000000000000
--- a/mimir.go
+++ /dev/null
@@ -1,457 +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"
-	"errors"
-	"fmt"
-	"html/template"
-	"io"
-	"log"
-	"net/http"
-	"regexp"
-	"strconv"
-	"strings"
-
-	"apiote.xyz/p/gott/v2"
-	"github.com/emersion/go-imap"
-	"github.com/emersion/go-imap/client"
-	"github.com/emersion/go-message"
-	_ "github.com/emersion/go-message/charset"
-	"github.com/emersion/go-msgauth/dkim"
-)
-
-type MalformedMessageError struct {
-	MessageID string
-	Cause     error
-}
-
-func (e MalformedMessageError) Error() string {
-	return fmt.Sprintf("Malformed message %s: %s", e.MessageID, e.Cause.Error())
-}
-
-type UnknownCategoryError struct {
-	MessageID string
-	Category  string
-}
-
-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 mimirStruct struct {
-	mboxName string
-	c        *client.Client
-	config   Config
-	db       *sql.DB
-
-	categories     []string
-	categoryRegexp *regexp.Regexp
-	mbox           *imap.MailboxStatus
-	messages       chan *imap.Message
-	section        *imap.BodySectionName
-	done           chan error
-
-	message             *imap.Message
-	category            string
-	messageBytes        []byte
-	dkimStatus          bool
-	mimeMessage         *message.Entity
-	plainTextBodyReader io.Reader
-	plainTextBody       []byte
-	recipients          []string
-}
-
-func getCategories(s mimirStruct) (mimirStruct, error) {
-	s.categories = s.config.Mimir.Categories
-	if len(s.categories) == 0 {
-		return s, errors.New("no categories defined")
-	}
-	return s, nil
-}
-
-func prepareCategoryRegexp(s mimirStruct) (mimirStruct, error) {
-	if !strings.Contains(s.config.Mimir.RecipientTemplate, "[:]") {
-		return s, errors.New("recipient template does not contain ‘[:]’")
-	}
-	recipientRegexp := strings.Replace(s.config.Mimir.RecipientTemplate, "[:]", "(.*)", 1)
-	r, err := regexp.Compile(recipientRegexp)
-	s.categoryRegexp = r
-	return s, err
-}
-
-func checkEmptyBox(s mimirStruct) error {
-	return checkImapEmptyBox(s.mbox)
-}
-
-func selectMimirInbox(s mimirStruct) (mimirStruct, error) {
-	mbox, err := s.c.Select(s.mboxName, true)
-	s.mbox = mbox
-	return s, err
-}
-
-func fetchMessages(s mimirStruct) mimirStruct {
-	from := uint32(1)
-	to := s.mbox.Messages
-	seqset := new(imap.SeqSet)
-	seqset.AddRange(from, to)
-
-	s.section = &imap.BodySectionName{}
-	items := []imap.FetchItem{imap.FetchEnvelope, s.section.FetchItem(), imap.FetchUid}
-	s.messages = make(chan *imap.Message, 10)
-	s.done = make(chan error, 1)
-	go func() {
-		s.done <- s.c.Fetch(seqset, items, s.messages)
-	}()
-	return s
-}
-
-func getMessageCategory(s mimirStruct) (mimirStruct, error) {
-	categoryInAddress := ""
-	recipients := append(s.message.Envelope.To, s.message.Envelope.Cc...)
-	for _, recipient := range recipients {
-		matches := s.categoryRegexp.FindStringSubmatch(recipient.Address())
-		if len(matches) != 2 {
-			continue
-		}
-		categoryInAddress = matches[1]
-		for _, category := range s.categories {
-			if matches[1] == category {
-				s.category = category
-				return s, nil
-			}
-		}
-	}
-	return s, UnknownCategoryError{
-		MessageID: s.message.Envelope.MessageId,
-		Category:  categoryInAddress,
-	}
-}
-
-func readMimirBody(s mimirStruct) (mimirStruct, error) {
-	r := s.message.GetBody(s.section)
-	if r == nil {
-		return s, MalformedMessageError{
-			Cause:     errors.New("no body in message"),
-			MessageID: s.message.Envelope.MessageId,
-		}
-	}
-	messageBytes, err := io.ReadAll(r)
-	s.messageBytes = messageBytes
-	return s, err
-}
-
-func verifyDkim(s mimirStruct) (mimirStruct, error) {
-	dkimStatus := false
-	r := bytes.NewReader(s.messageBytes)
-	verifications, err := dkim.Verify(r)
-	if err != nil {
-		return s, err
-	}
-	for _, v := range verifications {
-		if v.Err == nil && s.message.Envelope.From[0].HostName == v.Domain {
-			dkimStatus = true
-		}
-	}
-	s.dkimStatus = dkimStatus
-	return s, nil
-}
-
-func parseMimeMessage(s mimirStruct) (mimirStruct, error) {
-	r := bytes.NewReader(s.messageBytes)
-	m, err := message.Read(r)
-	s.mimeMessage = m
-	return s, err
-}
-
-func getNextPart(message *message.Entity, ID string, mimetype string) (io.Reader, error) {
-	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 getPlainTextBody(s mimirStruct) (mimirStruct, error) {
-	body, err := getNextPart(s.mimeMessage, s.message.Envelope.MessageId, "text/plain")
-	s.plainTextBodyReader = body
-	return s, err
-}
-
-func readPlainTextBody(s mimirStruct) (mimirStruct, error) {
-	body, err := io.ReadAll(s.plainTextBodyReader)
-	s.plainTextBody = body
-	return s, err
-}
-
-func archiveMessage(s mimirStruct) error {
-	messageID := s.message.Envelope.MessageId
-	subject := s.message.Envelope.Subject
-	date := s.message.Envelope.Date.UTC()
-	inReplyTo := s.message.Envelope.InReplyTo
-	sender := s.message.Envelope.From[0]
-	log.Printf("archiving %s\n", messageID)
-	return addArchiveEntry(s.db, messageID, s.category, subject, s.plainTextBody, date, inReplyTo, s.dkimStatus, sender, string(s.messageBytes))
-}
-
-func updateTopicRecipients(s mimirStruct) error {
-	var sender *imap.Address
-	if len(s.message.Envelope.ReplyTo) > 0 {
-		sender = s.message.Envelope.ReplyTo[0]
-	} else {
-		sender = s.message.Envelope.From[0]
-	}
-	messageID := s.message.Envelope.MessageId
-	return updateRecipients(s.db, sender, messageID)
-}
-
-func getMessageRecipients(s mimirStruct) (mimirStruct, error) {
-	sender := s.message.Envelope.From[0]
-	messageID := s.message.Envelope.MessageId
-	recipients, err := getRecipients(s.db, messageID, sender)
-	if sender.Address() != s.config.Mimir.PersonalAddress {
-		recipients = append(recipients, strings.Replace(s.config.Mimir.ForwardAddress, "[:]", s.category, 1))
-	}
-	s.recipients = recipients
-	return s, err
-}
-
-func forwardMimirMessage(s mimirStruct) error {
-	messageID := s.message.Envelope.MessageId
-	inReplyTo := s.message.Envelope.InReplyTo
-	subject := s.message.Envelope.Subject
-	sender := s.message.Envelope.From[0]
-	log.Printf("forwarding %s to %v\n", messageID, s.recipients)
-	return forwardMessage(s.config, s.category, messageID, inReplyTo, subject, s.plainTextBody, s.recipients, sender)
-}
-
-func recoverMalformedMessage(s mimirStruct, err error) (mimirStruct, error) {
-	var malformedMessageError MalformedMessageError
-	if errors.As(err, &malformedMessageError) {
-		err = nil
-		log.Println(malformedMessageError.Error())
-		log.Println(string(s.messageBytes))
-	}
-	return s, err
-}
-
-func recoverUnknownCategory(s mimirStruct, err error) (mimirStruct, error) {
-	var unknownCategoryError UnknownCategoryError
-	if errors.As(err, &unknownCategoryError) {
-		err = nil
-		log.Println(unknownCategoryError.Error())
-	}
-	return s, err
-}
-
-func removeMimirMessage(s mimirStruct) error {
-	return removeMessage(s.c, s.message.Uid, s.mboxName)
-}
-
-func recoverErroredMessages(s mimirStruct, err error) (mimirStruct, error) {
-	log.Printf("message %s errored: %v\n", s.message.Envelope.MessageId, err)
-	return s, nil
-}
-
-func archiveMessages(s mimirStruct) (mimirStruct, error) {
-	for msg := range s.messages {
-		s.message = msg
-		r := gott.R[mimirStruct]{S: s}.
-			Bind(getMessageCategory).
-			Bind(readMimirBody).
-			Bind(verifyDkim).
-			Bind(parseMimeMessage).
-			Bind(getPlainTextBody).
-			Bind(readPlainTextBody).
-			Tee(archiveMessage).
-			Tee(updateTopicRecipients).
-			Bind(getMessageRecipients).
-			Tee(forwardMimirMessage).
-			Recover(recoverMalformedMessage).
-			Recover(recoverUnknownCategory).
-			Tee(removeMimirMessage).
-			Recover(recoverErroredMessages)
-		if r.E != nil {
-			return s, r.E
-		}
-	}
-	return s, nil
-}
-
-func checkFetchError(s mimirStruct) error {
-	return <-s.done
-}
-
-func expunge(s mimirStruct) error {
-	return s.c.Expunge(nil)
-}
-
-func ignoreEmptyBox(s mimirStruct, e error) (mimirStruct, error) {
-	var emptyBoxError EmptyBoxError
-	if errors.As(e, &emptyBoxError) {
-		log.Println("Mailbox is empty")
-		return s, nil
-	}
-	return s, e
-}
-
-func archiveInbox(db *sql.DB, mboxName string, c *client.Client, config Config) error {
-	r := gott.R[mimirStruct]{
-		S: mimirStruct{
-			mboxName: mboxName,
-			c:        c,
-			config:   config,
-			db:       db,
-		},
-	}.
-		Bind(getCategories).
-		Bind(prepareCategoryRegexp).
-		Bind(selectMimirInbox).
-		Tee(checkEmptyBox).
-		Map(fetchMessages).
-		Bind(archiveMessages).
-		Tee(checkFetchError).
-		Tee(expunge).
-		Recover(ignoreEmptyBox)
-
-	return r.E
-}
-
-func mimir(db *sql.DB, config Config) {
-	c, err := client.DialTLS(config.Mimir.ImapAddress, nil)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	log.Println("Connected")
-	defer c.Close()
-	defer c.Logout()
-	if err := c.Login(config.Mimir.ImapUsername, config.Mimir.ImapPassword); err != nil {
-		log.Fatalln(err)
-	}
-	log.Println("Logged in")
-	err = archiveInbox(db, config.Mimir.ImapInbox, c, config)
-	if err != nil {
-		log.Fatalln(err)
-	}
-}
-
-func mimir_serve(db *sql.DB) 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.ParseFiles("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.ParseFiles("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 c4f2459c9bb2cf2ff20ee7044d21e7ecf9521bb6..0000000000000000000000000000000000000000
--- a/tyr.go
+++ /dev/null
@@ -1,366 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"crypto/sha256"
-	"database/sql"
-	"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
-}
-
-func NewLock(address string) Lock {
-	token := strconv.FormatUint(rand.Uint64(), 16)
-	lock := Lock{
-		address: address,
-		token:   token,
-		date:    time.Now(),
-	}
-	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) error {
-	// todo also release from Quarantine
-	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...)
-		for _, recipient := range recipients {
-			knownAddress := KnownAddress{
-				addressFrom: recipient.Address(),
-				addressTo:   "*",
-				ban:         false,
-			}
-			err := insertKnownAddress(db, knownAddress)
-			if err != nil {
-				log.Println(err)
-				continue
-			}
-		}
-	}
-
-	if err := <-done; err != nil {
-		return err
-	}
-	return nil
-}
-
-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 {
-	rand.Seed(time.Now().UnixNano())
-	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_ := 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) {
-				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
-		}
-
-		sendQuarantine(sender)
-		lock = NewLock(sender.Address())
-		insertLock(db, lock)
-		log.Printf("moving %v : %s from %s to quarantine\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address())
-		moveSet.AddNum(msg.Uid)
-	}
-
-	if err := <-done; err != nil {
-		return err
-	}
-	return moveMultiple(c, moveSet, config.Tyr.ImapFolderQuarantine)
-}
-
-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)
-	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)
-	}
-	err = releaseQuarantine(db, config, lock, addressTo, 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, addressTo, dest string) error {
-	deleteLock(db, lock)
-	knownAddress := KnownAddress{
-		addressFrom: lock.address,
-		addressTo:   addressTo,
-		ban:         false,
-	}
-	if dest == config.Tyr.ImapFolderJunk {
-		knownAddress.ban = true
-	}
-	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) 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.ParseFiles("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() {
-}