123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348 |
- 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 {
- mailbox := &MimirMailbox{
- Mailbox: idavollr.Mailbox{
- MboxName: config.Mimir.ImapInbox,
- ImapAdr: config.Mimir.ImapAddress,
- ImapUser: config.Mimir.ImapUsername,
- ImapPass: config.Mimir.ImapPassword,
- Conf: config,
- },
- db: db,
- }
- mailbox.SetupChannels()
- r := gott.R[idavollr.AbstractMailbox]{
- S: mailbox,
- LogLevel: gott.Info,
- }.
- 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.ReadMessageBody).
- 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)
- }
- }
- }
|