mimir.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. package mimir
  2. // todo test utf-8 in subject, sender (mímir), body
  3. // ---- release v1
  4. /* todo views:
  5. default: threads click-> thread
  6. thread: linear messages by datetime oldest on top, with #message_id, and is_reply_to: <a href=#message_id>
  7. search ordered by datetime, newest on top; thread – most recent message
  8. search by time: messages [in thread] click-> thread#message_id
  9. search by from: messages [in thread] click-> thread#message_id
  10. filter by category: inherit
  11. |search by subject: threads click-> thread
  12. |search by subject+: messages [in thread] click-> thread#message_id
  13. |search by full text: messages [in thread] click-> thread#message_id
  14. */
  15. /* todo moderation:
  16. ban address, right to forget, unsubscribe (from one topic, from all topics)
  17. */
  18. // ---- release v2
  19. // todo in thread card: add number of messages and interested people
  20. // todo highlight patches
  21. // todo check pgp/mime signatures
  22. import (
  23. "bytes"
  24. "database/sql"
  25. "embed"
  26. "errors"
  27. "fmt"
  28. "html/template"
  29. "log"
  30. "net/http"
  31. "regexp"
  32. "strconv"
  33. "strings"
  34. "apiote.xyz/p/asgard/himinbjorg"
  35. "apiote.xyz/p/asgard/idavollr"
  36. "apiote.xyz/p/asgard/jotunheim"
  37. "apiote.xyz/p/gott/v2"
  38. "github.com/emersion/go-imap"
  39. _ "github.com/emersion/go-message/charset"
  40. "github.com/emersion/go-msgauth/dkim"
  41. )
  42. type UnknownCategoryError struct {
  43. MessageID string
  44. Category string
  45. }
  46. func (e UnknownCategoryError) Error() string {
  47. return fmt.Sprintf("Unknown category ‘%s’ in message %s", e.Category, e.MessageID)
  48. }
  49. type ListingPage struct {
  50. Messages []himinbjorg.Message
  51. Page int
  52. NumPages int
  53. }
  54. func (l ListingPage) PrevPage() int {
  55. return l.Page - 1
  56. }
  57. func (l ListingPage) NextPage() int {
  58. return l.Page + 1
  59. }
  60. type MimirMailbox struct {
  61. idavollr.Mailbox
  62. categories []string
  63. categoryRegexp *regexp.Regexp
  64. db *sql.DB
  65. }
  66. type MimirImapMessage struct {
  67. idavollr.ImapMessage
  68. categoryRegexp *regexp.Regexp
  69. categories []string
  70. category string
  71. dkimStatus bool
  72. db *sql.DB
  73. config jotunheim.Config
  74. recipients []string
  75. mboxName string
  76. }
  77. func Mimir(db *sql.DB, config jotunheim.Config) error {
  78. mailbox := &MimirMailbox{
  79. Mailbox: idavollr.Mailbox{
  80. MboxName: config.Mimir.ImapInbox,
  81. ImapAdr: config.Mimir.ImapAddress,
  82. ImapUser: config.Mimir.ImapUsername,
  83. ImapPass: config.Mimir.ImapPassword,
  84. Conf: config,
  85. },
  86. db: db,
  87. }
  88. mailbox.SetupChannels()
  89. r := gott.R[idavollr.AbstractMailbox]{
  90. S: mailbox,
  91. LogLevel: gott.Info,
  92. }.
  93. Bind(getCategories).
  94. Bind(prepareCategoryRegexp).
  95. Bind(idavollr.Connect).
  96. Tee(idavollr.Login).
  97. Bind(idavollr.SelectInbox).
  98. Tee(idavollr.CheckEmptyBox).
  99. Map(idavollr.FetchMessages).
  100. Tee(archiveMessages).
  101. Tee(idavollr.CheckFetchError).
  102. Tee(idavollr.Expunge).
  103. Recover(idavollr.IgnoreEmptyBox).
  104. Recover(idavollr.Disconnect)
  105. return r.E
  106. }
  107. func getCategories(am idavollr.AbstractMailbox) (idavollr.AbstractMailbox, error) {
  108. m := am.(*MimirMailbox)
  109. m.categories = m.Config().Mimir.Categories
  110. if len(m.categories) == 0 {
  111. return m, errors.New("no categories defined")
  112. }
  113. return m, nil
  114. }
  115. func prepareCategoryRegexp(am idavollr.AbstractMailbox) (idavollr.AbstractMailbox, error) {
  116. m := am.(*MimirMailbox)
  117. if !strings.Contains(m.Config().Mimir.RecipientTemplate, "[:]") {
  118. return m, errors.New("recipient template does not contain ‘[:]’")
  119. }
  120. recipientRegexp := strings.Replace(m.Config().Mimir.RecipientTemplate, "[:]", "(.*)", 1)
  121. r, err := regexp.Compile(recipientRegexp)
  122. m.categoryRegexp = r
  123. return m, err
  124. }
  125. func archiveMessages(am idavollr.AbstractMailbox) error {
  126. m := am.(*MimirMailbox)
  127. for msg := range m.Messages() {
  128. imapMessage := idavollr.ImapMessage{
  129. Msg: msg,
  130. Sect: m.Section(),
  131. Mimetype: "text/plain",
  132. }
  133. imapMessage.SetClient(m.Client())
  134. r := gott.R[idavollr.AbstractImapMessage]{
  135. S: &MimirImapMessage{
  136. ImapMessage: imapMessage,
  137. categoryRegexp: m.categoryRegexp,
  138. categories: m.categories,
  139. db: m.db,
  140. config: m.Config(),
  141. mboxName: m.MboxName,
  142. },
  143. }.
  144. Bind(getMessageCategory).
  145. Bind(idavollr.ReadMessageBody).
  146. Bind(verifyDkim).
  147. Bind(idavollr.ParseMimeMessage).
  148. Bind(idavollr.GetBody).
  149. Bind(idavollr.ReadBody).
  150. Tee(archiveMessage).
  151. Tee(updateTopicRecipients).
  152. Bind(getMessageRecipients).
  153. Tee(forwardMimirMessage).
  154. Recover(idavollr.RecoverMalformedMessage).
  155. Recover(recoverUnknownCategory).
  156. Tee(removeMessage).
  157. Recover(idavollr.RecoverErroredMessages)
  158. if r.E != nil {
  159. return r.E
  160. }
  161. }
  162. return nil
  163. }
  164. func getMessageCategory(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) {
  165. m := am.(*MimirImapMessage)
  166. categoryInAddress := ""
  167. recipients := append(m.Message().Envelope.To, m.Message().Envelope.Cc...)
  168. for _, recipient := range recipients {
  169. matches := m.categoryRegexp.FindStringSubmatch(recipient.Address())
  170. if len(matches) != 2 {
  171. continue
  172. }
  173. categoryInAddress = matches[1]
  174. for _, category := range m.categories {
  175. if matches[1] == category {
  176. m.category = category
  177. return m, nil
  178. }
  179. }
  180. }
  181. return m, UnknownCategoryError{
  182. MessageID: m.Message().Envelope.MessageId,
  183. Category: categoryInAddress,
  184. }
  185. }
  186. func verifyDkim(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) {
  187. m := am.(*MimirImapMessage)
  188. dkimStatus := false
  189. r := bytes.NewReader(m.MessageBytes())
  190. verifications, err := dkim.Verify(r)
  191. if err != nil {
  192. return m, err
  193. }
  194. for _, v := range verifications {
  195. if v.Err == nil && m.Message().Envelope.From[0].HostName == v.Domain {
  196. dkimStatus = true
  197. }
  198. }
  199. m.dkimStatus = dkimStatus
  200. return m, nil
  201. }
  202. func archiveMessage(am idavollr.AbstractImapMessage) error {
  203. m := am.(*MimirImapMessage)
  204. messageID := m.Message().Envelope.MessageId
  205. subject := m.Message().Envelope.Subject
  206. date := m.Message().Envelope.Date.UTC()
  207. inReplyTo := m.Message().Envelope.InReplyTo
  208. sender := m.Message().Envelope.From[0]
  209. log.Printf("archiving %s\n", messageID)
  210. return himinbjorg.AddArchiveEntry(m.db, messageID, m.category, subject, m.MessageBody(), date, inReplyTo, m.dkimStatus, sender, string(m.MessageBytes()))
  211. }
  212. func updateTopicRecipients(am idavollr.AbstractImapMessage) error {
  213. m := am.(*MimirImapMessage)
  214. var sender *imap.Address
  215. if len(m.Message().Envelope.ReplyTo) > 0 {
  216. sender = m.Message().Envelope.ReplyTo[0]
  217. } else {
  218. sender = m.Message().Envelope.From[0]
  219. }
  220. messageID := m.Message().Envelope.MessageId
  221. return himinbjorg.UpdateRecipients(m.db, sender, messageID)
  222. }
  223. func getMessageRecipients(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) {
  224. m := am.(*MimirImapMessage)
  225. sender := m.Message().Envelope.From[0]
  226. messageID := m.Message().Envelope.MessageId
  227. recipients, err := himinbjorg.GetRecipients(m.db, messageID, sender)
  228. if sender.Address() != m.config.Mimir.PersonalAddress {
  229. recipients = append(recipients, strings.Replace(m.config.Mimir.ForwardAddress, "[:]", m.category, 1))
  230. }
  231. m.recipients = recipients
  232. return m, err
  233. }
  234. func forwardMimirMessage(am idavollr.AbstractImapMessage) error {
  235. m := am.(*MimirImapMessage)
  236. messageID := m.Message().Envelope.MessageId
  237. inReplyTo := m.Message().Envelope.InReplyTo
  238. subject := m.Message().Envelope.Subject
  239. sender := m.Message().Envelope.From[0]
  240. log.Printf("forwarding %s to %v\n", messageID, m.recipients)
  241. return idavollr.ForwardMessage(m.config, m.category, messageID, inReplyTo, subject, m.MessageBody(), m.recipients, sender)
  242. }
  243. func recoverUnknownCategory(m idavollr.AbstractImapMessage, err error) (idavollr.AbstractImapMessage, error) {
  244. var unknownCategoryError UnknownCategoryError
  245. if errors.As(err, &unknownCategoryError) {
  246. err = nil
  247. log.Println(unknownCategoryError.Error())
  248. }
  249. return m, err
  250. }
  251. func removeMessage(am idavollr.AbstractImapMessage) error {
  252. m := am.(*MimirImapMessage)
  253. return idavollr.RemoveMessage(m.Client(), m.Message().Uid, m.mboxName)
  254. }
  255. func Serve(db *sql.DB, templatesFs embed.FS) func(w http.ResponseWriter, r *http.Request) {
  256. // TODO on back check with cache
  257. return func(w http.ResponseWriter, r *http.Request) {
  258. path := strings.Split(r.URL.Path[1:], "/")
  259. if len(path) == 1 {
  260. r.ParseForm()
  261. pageParam := r.Form.Get("page")
  262. var (
  263. page int64 = 1
  264. err error
  265. )
  266. if pageParam != "" {
  267. page, err = strconv.ParseInt(pageParam, 10, 0)
  268. if err != nil {
  269. w.WriteHeader(http.StatusInternalServerError)
  270. log.Println(err)
  271. }
  272. }
  273. messages, numThreads, err := himinbjorg.GetArchivedThreads(db, page)
  274. if err != nil {
  275. w.WriteHeader(http.StatusInternalServerError)
  276. log.Println(err)
  277. }
  278. t, err := template.ParseFS(templatesFs, "templates/mimir_threads.html")
  279. if err != nil {
  280. w.WriteHeader(http.StatusInternalServerError)
  281. log.Println(err)
  282. }
  283. b := bytes.NewBuffer([]byte{})
  284. err = t.Execute(b, ListingPage{
  285. Messages: messages,
  286. Page: int(page),
  287. NumPages: numThreads / 12,
  288. })
  289. w.Write(b.Bytes())
  290. } else if len(path) == 3 && path[1] == "m" {
  291. thread, err := himinbjorg.GetArchivedThread(db, path[2])
  292. if err != nil {
  293. var noMsgErr himinbjorg.NoMessageError
  294. if errors.As(err, &noMsgErr) {
  295. w.WriteHeader(http.StatusNotFound)
  296. w.Write([]byte(noMsgErr.Error()))
  297. } else {
  298. w.WriteHeader(http.StatusInternalServerError)
  299. log.Println(err)
  300. }
  301. return
  302. }
  303. t, err := template.ParseFS(templatesFs, "templates/mimir_message.html")
  304. if err != nil {
  305. w.WriteHeader(http.StatusInternalServerError)
  306. log.Println(err)
  307. }
  308. b := bytes.NewBuffer([]byte{})
  309. err = t.Execute(b, thread)
  310. w.Write(b.Bytes())
  311. } else {
  312. w.WriteHeader(http.StatusNotFound)
  313. }
  314. }
  315. }