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>