asgard.git

commit 6cbd3c7e726bb746bfa4f5b16c844c8abde03808

Author: Adam <git@apiote.xyz>

add mimir

 db.go | 151 +++++++++++
 go.mod | 5 
 go.sum | 3 
 imap.go | 79 ++++++
 mimir.go | 466 ++++++++++++++++++++++++++++++++++++++
 mímir.go | 169 -------------
 templates/mimir_message.html | 46 +++
 templates/mimir_threads.html | 60 ++++


diff --git a/db.go b/db.go
index 4eed9bd7afe4360eb3a555f3f089fa4d9b242314..1242932e4b7c24e7f5acebac4d18ed606eb421be 100644
--- a/db.go
+++ b/db.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"database/sql"
+	"fmt"
 	"strings"
 	"time"
 
@@ -10,6 +11,58 @@
 	_ "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() (*sql.DB, error) {
 	db, err := open()
 	_, err = db.Exec(`create table tyr_knownAddresses(address_from text, address_to text, ban boolean, unique(address, direction))`)
@@ -20,8 +73,13 @@ 	_, 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, recipients text, root_id text, foreign key(in_reply_to) references mimir_archive(message_id))`)
+
+	_, 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
 	}
 
@@ -122,13 +180,7 @@ 		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, recipients []*imap.Address) error {
-	recipientsAddresses := []string{}
-	for _, recipient := range recipients {
-		recipientsAddresses = append(recipientsAddresses, recipient.Address())
-	}
-	recipientsJoined := strings.Join(recipientsAddresses, ", ")
-
+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)
@@ -139,7 +191,86 @@ 		} else {
 			return err
 		}
 	}
-	db.Exec(`insert inti mimir_archive values(?, ?, ?, ?, ?, ?, ?, ?, ?)`, messageID, subject, body, date, inReplyTo, dkim, sender, recipientsJoined, rootID)
+	_, 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
+}
 
-	return nil
+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/go.mod b/go.mod
index 9a9ac82bd9a2a7121718f5b84dfbec7b94b68117..9fecc7ee4dfabd8b81f1928a55e83960afd1bd3b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,14 +1,16 @@
 module git.apiote.xyz/me/asgard
 
-go 1.17
+go 1.18
 
 require (
 	apiote.xyz/p/go-dirty v0.0.0-20211022164923-8652e7927cd7
+	apiote.xyz/p/gott/v2 v2.0.0
 	github.com/ProtonMail/gopenpgp/v2 v2.2.4
 	github.com/emersion/go-imap v1.2.0
 	github.com/emersion/go-imap-move v0.0.0-20210907172020-fe4558f9c872
 	github.com/emersion/go-message v0.15.0
 	github.com/emersion/go-msgauth v0.6.5
+	github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
 	github.com/emersion/go-smtp v0.15.0
 	github.com/mattn/go-sqlite3 v1.14.9
 	notabug.org/apiote/gott v1.1.2
@@ -17,7 +19,6 @@
 require (
 	github.com/ProtonMail/go-crypto v0.0.0-20210920160938-87db9fbc61c7 // indirect
 	github.com/ProtonMail/go-mime v0.0.0-20190923161245-9b5a4261663a // indirect
-	github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
 	github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/sirupsen/logrus v1.8.1 // indirect




diff --git a/go.sum b/go.sum
index b130b7163bcb05d68c0008faf8d2217e1e9c2926..76cf17ab10db18853b35cdcaafff9b0ca006fb66 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
 apiote.xyz/p/go-dirty v0.0.0-20211022164923-8652e7927cd7 h1:ab0pCRm0uGxLVUMg2fpRL7Jzz/vcI0i3sMam82l2cXU=
 apiote.xyz/p/go-dirty v0.0.0-20211022164923-8652e7927cd7/go.mod h1:8QnoYcdnf+1AmRhC7GBUKuCl/wRpfI3Bd58JqDbJNtw=
+apiote.xyz/p/gott/v2 v2.0.0 h1:1Ug0mgBVXCzzKehGGIV3CTHTgd9yj9bH6CFQB7+sbpk=
+apiote.xyz/p/gott/v2 v2.0.0/go.mod h1:H87aFMqvof1DWBzJuxzLaQRby4+PrIvFRwMfTTB6lK8=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/ProtonMail/go-crypto v0.0.0-20210920160938-87db9fbc61c7 h1:DSqTh6nEes/uO8BlNcGk8PzZsxY2sN9ZL//veWBdTRI=
 github.com/ProtonMail/go-crypto v0.0.0-20210920160938-87db9fbc61c7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
@@ -72,7 +74,6 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211015200801-69063c4bb744 h1:KzbpndAYEM+4oHRp9JmB2ewj0NHHxO3Z0g7Gus2O1kk=
 golang.org/x/sys v0.0.0-20211015200801-69063c4bb744/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=




diff --git a/imap.go b/imap.go
index a0471ff6392bb59c4d770b78d28b9aa8ff053d90..9e5bf0c3fff00194f30018b290b2fd086ad6a81c 100644
--- a/imap.go
+++ b/imap.go
@@ -1,11 +1,17 @@
 package main
 
 import (
+	"io"
 	"log"
+	"mime"
+	"mime/quotedprintable"
+	"strings"
 
 	"github.com/emersion/go-imap"
 	"github.com/emersion/go-imap-move"
 	"github.com/emersion/go-imap/client"
+	"github.com/emersion/go-sasl"
+	"github.com/emersion/go-smtp"
 )
 
 func moveMsg(c *client.Client, msg *imap.Message, dest string) {
@@ -67,8 +73,73 @@ 	// todo
 	log.Printf("sending quarantine to %s\n", address.Address())
 }
 
-func forwardMessage(config Config, category string, replyTo []string, messageID, inReplyTo, subject string, body []byte) {
-	//todo set recipient as config.Mímir.PersonalAddress with [:] replaced with category
-	//todo login to smtp with smtpAddress, imapUsername, imapPassword
-	//todo send mail with subject, messageID, inReplyTo, body; to recipient; from smtpSender
+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); 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/mimir.go b/mimir.go
new file mode 100644
index 0000000000000000000000000000000000000000..451c7dde37fcc3072baf89e7b0321f4c33842474
--- /dev/null
+++ b/mimir.go
@@ -0,0 +1,466 @@
+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 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())
+}
+
+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 {
+	if s.mbox.Messages == 0 {
+		return EmptyBoxError{}
+	}
+	return nil
+}
+
+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) (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)
+		}
+	} else {
+		t, _, err := message.Header.ContentType()
+		if err != nil {
+			return nil, fmt.Errorf("while getting content type: %w", err)
+		}
+		if t == "text/plain" {
+			return message.Body, nil
+		}
+	}
+	return nil, MalformedMessageError{
+		Cause:     errors.New("text/plain not found"),
+		MessageID: ID,
+	}
+}
+
+func getPlainTextBody(s mimirStruct) (mimirStruct, error) {
+	body, err := getNextPart(s.mimeMessage, s.message.Envelope.MessageId)
+	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/mímir.go b/mímir.go
deleted file mode 100644
index 93e716a624aa2af12a68576576b14f7699930328..0000000000000000000000000000000000000000
--- a/mímir.go
+++ /dev/null
@@ -1,169 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"database/sql"
-	"errors"
-	"fmt"
-	"io"
-	"log"
-	"net/http"
-	"regexp"
-	"strings"
-
-	"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"
-)
-
-func checkRecipientTemplate(recipients []*imap.Address, config Config) (string, error) {
-	categories := config.Mímir.Categories
-	if len(categories) == 0 {
-		return "", nil
-	}
-	parts := strings.SplitN(config.Mímir.RecipientTemplate, "[:]", 2) // todo if [:] not found
-	r, err := regexp.Compile(parts[0] + "(.*)" + parts[1])
-	if err != nil {
-		return "", err
-	}
-	for _, recipient := range recipients {
-		matches := r.FindStringSubmatch(recipient.Address())
-		if len(matches) != 2 {
-			return "", errors.New("No category in recipient")
-		}
-		for _, category := range categories {
-			if matches[1] == category {
-				return category, nil
-			}
-		}
-	}
-	return "", errors.New("Unknown category")
-}
-
-func getPlainTextPart(r io.Reader) (io.Reader, error) {
-	m, err := message.Read(r)
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	if mr := m.MultipartReader(); mr != nil {
-		for {
-			p, err := mr.NextPart()
-			if err == io.EOF {
-				break
-			} else if err != nil {
-				log.Fatal(err)
-			}
-
-			t, _, _ := p.Header.ContentType()
-			if t == "text/plain" {
-				return p.Body, nil
-			}
-		}
-	} else {
-		t, _, _ := m.Header.ContentType()
-		if t == "text/plain" {
-			return m.Body, nil
-		}
-	}
-	return nil, errors.New("text/plain no found")
-}
-
-func archiveInbox(db *sql.DB, mboxName string, c *client.Client, config Config) error {
-	mbox, err := c.Select(mboxName, true)
-	if err != nil {
-		return err
-	}
-	fmt.Printf("reading %d messages from %s\n", mbox.Messages, mboxName)
-	if mbox.Messages == 0 {
-		return nil
-	}
-
-	from := uint32(1)
-	to := mbox.Messages
-	seqset := new(imap.SeqSet)
-	seqset.AddRange(from, to)
-
-	section := &imap.BodySectionName{}
-	items := []imap.FetchItem{imap.FetchEnvelope, section.FetchItem()}
-	messages := make(chan *imap.Message, 10)
-	done := make(chan error, 1)
-	go func() {
-		done <- c.Fetch(seqset, items, messages)
-	}()
-	for msg := range messages {
-		recipients := append(msg.Envelope.To, msg.Envelope.Cc...)
-		category, err := checkRecipientTemplate(recipients, config)
-		if err != nil {
-			return err
-		}
-		sender := msg.Envelope.From[0]
-		messageID := msg.Envelope.MessageId
-		subject := msg.Envelope.Subject
-		date := msg.Envelope.Date.UTC()
-		inReplyTo := msg.Envelope.InReplyTo
-		dkimStatus := false
-		r := msg.GetBody(section)
-		if r == nil {
-			return errors.New("No body in message")
-		}
-		messageBytes, err := io.ReadAll(r)
-		if err != nil {
-			panic(err)
-		}
-		r = bytes.NewReader(messageBytes)
-		verifications, err := dkim.Verify(r)
-		if err != nil {
-			fmt.Println("there")
-			return err
-		}
-		for _, v := range verifications {
-			if v.Err == nil && sender.HostName == v.Domain {
-				dkimStatus = true
-			}
-		}
-
-		r = bytes.NewReader(messageBytes)
-		bodyReader, err := getPlainTextPart(r)
-		if err != nil {
-			return err
-		}
-		body, err := io.ReadAll(bodyReader)
-		if err != nil {
-			return err
-		}
-
-		addArchiveEntry(db, messageID, category, subject, body, date, inReplyTo, dkimStatus, sender, recipients)
-		if sender.Address() != config.Mímir.PersonalAddress { // todo if forwarding is on
-			forwardMessage(config, category, []string{sender.Address(), strings.Replace(config.Mímir.RecipientTemplate, "[:]", category, 1)}, messageID, inReplyTo, subject, body)
-		}
-	}
-
-	if err := <-done; err != nil {
-		return err
-	}
-	return nil
-}
-
-func mimir(db *sql.DB, config Config) {
-	c, err := client.DialTLS(config.Mímir.ImapAddress, nil)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	log.Println("Connected")
-	defer c.Logout()
-	if err := c.Login(config.Mímir.ImapUsername, config.Mímir.ImapPassword); err != nil {
-		log.Fatalln(err)
-	}
-	log.Println("Logged in")
-	err = archiveInbox(db, config.Mímir.ImapInbox, c, config)
-	if err != nil {
-		log.Fatalln(err)
-	}
-}
-
-func mimir_serve(w http.ResponseWriter, r *http.Request) {
-	// todo
-}




diff --git a/templates/mimir_message.html b/templates/mimir_message.html
new file mode 100644
index 0000000000000000000000000000000000000000..b65fe20a835226bb6f0f1d4966b533939a480be4
--- /dev/null
+++ b/templates/mimir_message.html
@@ -0,0 +1,46 @@
+<!DOCTYPE html>
+<html>
+	<head>
+		<meta charset="UTF-8">
+		<meta name="viewport" content="width=device-width, initial-scale=1.0">
+		<title>Mímir | {{(index . 0).Subject}} – in {{(index . 0).Category}}</title>
+		<style>
+			a {
+				cursor: pointer;
+				color: #00afff;
+				text-decoration: none;
+			}
+			a:hover {
+				text-decoration: underline;
+			}
+			.anchor {
+				color: #292929;
+			}
+			.anchor:hover {
+				tex-decoration: none !important;
+				color: #00afff;
+			}
+		</style>
+	</head>
+	<body style="font-family: monospace; background-color: #292929; color: #fafafa;">
+		<a href="/mimir"><header style="color: #fafafa; font-size: 1.25rem; padding-top: .3125rem; padding-bottom: .3125rem; margin-rigt: 1rem;">mímir for git.apiote.xyz</header></a>
+		<main style="margin-left: 1rem; margin-top: 2rem;">
+			{{range .}}
+			<div style="margin-bottom: 2rem; width: 85%; background-color: #212121; padding-left: 1rem; padding-right: 1rem; padding-bottom: .5rem;">
+				<div style="padding: .5rem 0; display: block; border-bottom: 1px solid #e9ecef; display: flex; flex-wrap: wrap;">
+					<div style="font-size: 1.25rem; flex: auto;">
+						<div>{{.Subject}}<a style="margin-left: .5rem;" class="anchor" id="{{.ID}}" href="#{{.ID}}">¶</a></div>
+						<div style="margin-top: .5rem; margin-bottom: .5rem; font-size: 1rem;">{{.Sender}} to {{.Category}}</div>
+					</div>
+					<div style="flex: none;">
+						{{.FormatDate}}<br/>
+						DKIM: {{if .Dkim}}<span style="color: #5af78e">✔</span>{{else}}<span style="color: #ff5c57">✘</span>{{end}}<br/>
+						<a style="color: #000000; font-family: sans; border: 1px solid #000000; padding: .1rem .75rem; font-size: .8rem; background-color: #db9d3b; cursor: pointer;" href="mailto:{{.Category}}@git.apiote.xyz?in-reply-to={{.ID}}&subject={{.RESubject}}">reply to this message</a>
+					</div>
+				</div>
+				<pre style="overflow: auto">{{.Body}}</pre>
+			</div>
+			{{end}}
+		</main>
+	</body>
+</html>




diff --git a/templates/mimir_threads.html b/templates/mimir_threads.html
new file mode 100644
index 0000000000000000000000000000000000000000..60eb4141ea0305a98f01551511671bd6c9eb4e43
--- /dev/null
+++ b/templates/mimir_threads.html
@@ -0,0 +1,60 @@
+<!DOCTYPE html>
+<html>
+	<head>
+		<meta charset="UTF-8">
+		<meta name="viewport" content="width=device-width, initial-scale=1.0">
+		<title>Mímir</title>
+		<style>
+			a {
+				color: #00afff;
+				text-decoration: none;
+			}
+			a:hover {
+				text-decoration: underline;
+			}
+		</style>
+	</head>
+	<body style="font-family: monospace; background-color: #292929; color: #fafafa;">
+		<a href="/mimir"><header style="color: #fafafa; font-size: 1.25rem; padding-top: .3125rem; padding-bottom: .3125rem; margin-rigt: 1rem;">mímir for git.apiote.xyz</header></a> <!-- todo mimir for {config:??} -->
+		<div style="display: flex; flex-wrap: wrap; justify-content: space-between; margin: 1rem;">
+			<div>
+				{{if gt .Page 1}}
+				<a href="/mimir?page={{.PrevPage}}">previous page</a>
+				{{end}}
+			</div>
+			<div>
+				{{if lt .Page .NumPages}}
+				<a href="/mimir?page={{.NextPage}}">next page</a>
+				{{end}}
+			</div>
+		</div>
+		<main style="margin-left: 1rem; margin-top: 2rem;">
+			{{if not .Messages}}
+				<span style="font-size: 2rem; color: #ff5c57;">No messages</span>
+			{{else}}
+			{{range .Messages}}
+				<div style="margin-bottom: 2rem; width: 85%; background-color: #212121; padding-left: 1rem; padding-right: 1rem; padding-bottom: .5rem;">
+					<div style="padding: .5rem 0; display: block; border-bottom: 1px solid #e9ecef; display: flex; flex-wrap: wrap">
+						<div style="flex: auto; padding-right: 1rem;"><a style="font-size: 1.25rem;" href="/mimir/m/{{.Thread}}{{if ne .Thread .ID}}#{{.ID}}{{end}}">{{.Subject}}</a></div>
+						<div style="flex: none; margin-top: .5rem; margin-bottom: .1rem;">{{.FormatDate}}</div>
+					</div>
+					<div style="margin-top: .5rem; font-size: 1rem;">{{.Sender}} to {{.Category}}</div>
+					<blockquote style="border-left: 2px solid #aaa; margin-left: .1rem; margin-bottom: 1rem;"><pre style="margin-left: .5rem; overflow: auto">{{.Body}}</pre></blockquote>
+				</div>
+			{{end}}
+			{{end}}
+		</main>
+		<div style="display: flex; flex-wrap: wrap; justify-content: space-between; margin: 1rem;">
+			<div>
+				{{if gt .Page 1}}
+				<a href="/mimir?page={{.PrevPage}}">previous page</a>
+				{{end}}
+			</div>
+			<div>
+				{{if lt .Page .NumPages}}
+				<a href="/mimir?page={{.NextPage}}">next page</a>
+				{{end}}
+			</div>
+		</div>
+	</body>
+</html>