A webmail client. Forked from https://git.sr.ht/~migadu/alps
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

497 lines
13 KiB

  1. package koushinbase
  2. import (
  3. "fmt"
  4. "io/ioutil"
  5. "mime"
  6. "net/http"
  7. "net/url"
  8. "strconv"
  9. "strings"
  10. "git.sr.ht/~emersion/koushin"
  11. "github.com/emersion/go-imap"
  12. imapmove "github.com/emersion/go-imap-move"
  13. imapclient "github.com/emersion/go-imap/client"
  14. "github.com/emersion/go-message"
  15. "github.com/emersion/go-smtp"
  16. "github.com/labstack/echo/v4"
  17. "github.com/microcosm-cc/bluemonday"
  18. )
  19. func registerRoutes(p *koushin.GoPlugin) {
  20. p.GET("/", func(ctx *koushin.Context) error {
  21. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  22. })
  23. p.GET("/mailbox/:mbox", handleGetMailbox)
  24. p.POST("/mailbox/:mbox", handleGetMailbox)
  25. p.GET("/message/:mbox/:uid", func(ctx *koushin.Context) error {
  26. return handleGetPart(ctx, false)
  27. })
  28. p.GET("/message/:mbox/:uid/raw", func(ctx *koushin.Context) error {
  29. return handleGetPart(ctx, true)
  30. })
  31. p.GET("/login", handleLogin)
  32. p.POST("/login", handleLogin)
  33. p.GET("/logout", handleLogout)
  34. p.GET("/compose", handleCompose)
  35. p.POST("/compose", handleCompose)
  36. p.GET("/message/:mbox/:uid/reply", handleCompose)
  37. p.POST("/message/:mbox/:uid/reply", handleCompose)
  38. p.POST("/message/:mbox/:uid/move", handleMove)
  39. p.POST("/message/:mbox/:uid/delete", handleDelete)
  40. p.POST("/message/:mbox/:uid/flag", handleSetFlags)
  41. }
  42. type MailboxRenderData struct {
  43. koushin.BaseRenderData
  44. Mailbox *imap.MailboxStatus
  45. Mailboxes []*imap.MailboxInfo
  46. Messages []IMAPMessage
  47. PrevPage, NextPage int
  48. Query string
  49. }
  50. func handleGetMailbox(ctx *koushin.Context) error {
  51. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  52. if err != nil {
  53. return echo.NewHTTPError(http.StatusBadRequest, err)
  54. }
  55. page := 0
  56. if pageStr := ctx.QueryParam("page"); pageStr != "" {
  57. var err error
  58. if page, err = strconv.Atoi(pageStr); err != nil || page < 0 {
  59. return echo.NewHTTPError(http.StatusBadRequest, "invalid page index")
  60. }
  61. }
  62. query := ctx.QueryParam("query")
  63. var mailboxes []*imap.MailboxInfo
  64. var msgs []IMAPMessage
  65. var mbox *imap.MailboxStatus
  66. var total int
  67. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  68. var err error
  69. if mailboxes, err = listMailboxes(c); err != nil {
  70. return err
  71. }
  72. if query != "" {
  73. msgs, total, err = searchMessages(c, mboxName, query, page)
  74. } else {
  75. msgs, err = listMessages(c, mboxName, page)
  76. }
  77. if err != nil {
  78. return err
  79. }
  80. mbox = c.Mailbox()
  81. return nil
  82. })
  83. if err != nil {
  84. return err
  85. }
  86. prevPage, nextPage := -1, -1
  87. if query != "" {
  88. if page > 0 {
  89. prevPage = page - 1
  90. }
  91. if (page+1)*messagesPerPage <= total {
  92. nextPage = page + 1
  93. }
  94. } else {
  95. if page > 0 {
  96. prevPage = page - 1
  97. }
  98. if (page+1)*messagesPerPage < int(mbox.Messages) {
  99. nextPage = page + 1
  100. }
  101. }
  102. return ctx.Render(http.StatusOK, "mailbox.html", &MailboxRenderData{
  103. BaseRenderData: *koushin.NewBaseRenderData(ctx),
  104. Mailbox: mbox,
  105. Mailboxes: mailboxes,
  106. Messages: msgs,
  107. PrevPage: prevPage,
  108. NextPage: nextPage,
  109. Query: query,
  110. })
  111. }
  112. func handleLogin(ctx *koushin.Context) error {
  113. username := ctx.FormValue("username")
  114. password := ctx.FormValue("password")
  115. if username != "" && password != "" {
  116. s, err := ctx.Server.Sessions.Put(username, password)
  117. if err != nil {
  118. if _, ok := err.(koushin.AuthError); ok {
  119. return ctx.Render(http.StatusOK, "login.html", nil)
  120. }
  121. return fmt.Errorf("failed to put connection in pool: %v", err)
  122. }
  123. ctx.SetSession(s)
  124. if path := ctx.QueryParam("next"); path != "" && path[0] == '/' && path != "/login" {
  125. return ctx.Redirect(http.StatusFound, path)
  126. }
  127. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  128. }
  129. return ctx.Render(http.StatusOK, "login.html", koushin.NewBaseRenderData(ctx))
  130. }
  131. func handleLogout(ctx *koushin.Context) error {
  132. ctx.Session.Close()
  133. ctx.SetSession(nil)
  134. return ctx.Redirect(http.StatusFound, "/login")
  135. }
  136. type MessageRenderData struct {
  137. koushin.BaseRenderData
  138. Mailboxes []*imap.MailboxInfo
  139. Mailbox *imap.MailboxStatus
  140. Message *IMAPMessage
  141. Body string
  142. IsHTML bool
  143. PartPath string
  144. MailboxPage int
  145. Flags map[string]bool
  146. }
  147. func handleGetPart(ctx *koushin.Context, raw bool) error {
  148. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  149. if err != nil {
  150. return echo.NewHTTPError(http.StatusBadRequest, err)
  151. }
  152. partPathString := ctx.QueryParam("part")
  153. partPath, err := parsePartPath(partPathString)
  154. if err != nil {
  155. return echo.NewHTTPError(http.StatusBadRequest, err)
  156. }
  157. var mailboxes []*imap.MailboxInfo
  158. var msg *IMAPMessage
  159. var part *message.Entity
  160. var mbox *imap.MailboxStatus
  161. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  162. var err error
  163. if mailboxes, err = listMailboxes(c); err != nil {
  164. return err
  165. }
  166. if msg, part, err = getMessagePart(c, mboxName, uid, partPath); err != nil {
  167. return err
  168. }
  169. mbox = c.Mailbox()
  170. return nil
  171. })
  172. if err != nil {
  173. return err
  174. }
  175. mimeType, _, err := part.Header.ContentType()
  176. if err != nil {
  177. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  178. }
  179. if len(partPath) == 0 {
  180. mimeType = "message/rfc822"
  181. }
  182. if raw {
  183. ctx.Response().Header().Set("Content-Type", mimeType)
  184. disp, dispParams, _ := part.Header.ContentDisposition()
  185. filename := dispParams["filename"]
  186. if len(partPath) == 0 {
  187. filename = msg.Envelope.Subject + ".eml"
  188. }
  189. // TODO: set Content-Length if possible
  190. // Be careful not to serve types like text/html as inline
  191. if !strings.EqualFold(mimeType, "text/plain") || strings.EqualFold(disp, "attachment") {
  192. dispParams := make(map[string]string)
  193. if filename != "" {
  194. dispParams["filename"] = filename
  195. }
  196. disp := mime.FormatMediaType("attachment", dispParams)
  197. ctx.Response().Header().Set("Content-Disposition", disp)
  198. }
  199. if len(partPath) == 0 {
  200. return part.WriteTo(ctx.Response())
  201. } else {
  202. return ctx.Stream(http.StatusOK, mimeType, part.Body)
  203. }
  204. }
  205. var body string
  206. if strings.HasPrefix(strings.ToLower(mimeType), "text/") {
  207. b, err := ioutil.ReadAll(part.Body)
  208. if err != nil {
  209. return fmt.Errorf("failed to read part body: %v", err)
  210. }
  211. body = string(b)
  212. }
  213. isHTML := false
  214. if strings.EqualFold(mimeType, "text/html") {
  215. p := bluemonday.UGCPolicy()
  216. // TODO: be more strict
  217. p.AllowElements("style")
  218. p.AllowAttrs("style")
  219. p.AddTargetBlankToFullyQualifiedLinks(true)
  220. body = p.Sanitize(body)
  221. isHTML = true
  222. }
  223. flags := make(map[string]bool)
  224. for _, f := range mbox.PermanentFlags {
  225. f = imap.CanonicalFlag(f)
  226. if f == imap.TryCreateFlag {
  227. continue
  228. }
  229. flags[f] = msg.HasFlag(f)
  230. }
  231. return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{
  232. BaseRenderData: *koushin.NewBaseRenderData(ctx),
  233. Mailboxes: mailboxes,
  234. Mailbox: mbox,
  235. Message: msg,
  236. Body: body,
  237. IsHTML: isHTML,
  238. PartPath: partPathString,
  239. MailboxPage: int(mbox.Messages-msg.SeqNum) / messagesPerPage,
  240. Flags: flags,
  241. })
  242. }
  243. type ComposeRenderData struct {
  244. koushin.BaseRenderData
  245. Message *OutgoingMessage
  246. }
  247. func handleCompose(ctx *koushin.Context) error {
  248. var msg OutgoingMessage
  249. if strings.ContainsRune(ctx.Session.Username(), '@') {
  250. msg.From = ctx.Session.Username()
  251. }
  252. msg.To = strings.Split(ctx.QueryParam("to"), ",")
  253. msg.Subject = ctx.QueryParam("subject")
  254. msg.Text = ctx.QueryParam("body")
  255. msg.InReplyTo = ctx.QueryParam("in-reply-to")
  256. if ctx.Request().Method == http.MethodGet && ctx.Param("uid") != "" {
  257. // This is a reply
  258. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  259. if err != nil {
  260. return echo.NewHTTPError(http.StatusBadRequest, err)
  261. }
  262. partPath, err := parsePartPath(ctx.QueryParam("part"))
  263. if err != nil {
  264. return echo.NewHTTPError(http.StatusBadRequest, err)
  265. }
  266. var inReplyTo *IMAPMessage
  267. var part *message.Entity
  268. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  269. var err error
  270. inReplyTo, part, err = getMessagePart(c, mboxName, uid, partPath)
  271. return err
  272. })
  273. if err != nil {
  274. return err
  275. }
  276. mimeType, _, err := part.Header.ContentType()
  277. if err != nil {
  278. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  279. }
  280. if !strings.HasPrefix(strings.ToLower(mimeType), "text/") {
  281. err := fmt.Errorf("cannot reply to \"%v\" part", mimeType)
  282. return echo.NewHTTPError(http.StatusBadRequest, err)
  283. }
  284. msg.Text, err = quote(part.Body)
  285. if err != nil {
  286. return err
  287. }
  288. msg.InReplyTo = inReplyTo.Envelope.MessageId
  289. // TODO: populate From from known user addresses and inReplyTo.Envelope.To
  290. replyTo := inReplyTo.Envelope.ReplyTo
  291. if len(replyTo) == 0 {
  292. replyTo = inReplyTo.Envelope.From
  293. }
  294. if len(replyTo) > 0 {
  295. msg.To = make([]string, len(replyTo))
  296. for i, to := range replyTo {
  297. msg.To[i] = to.Address()
  298. }
  299. }
  300. msg.Subject = inReplyTo.Envelope.Subject
  301. if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") {
  302. msg.Subject = "Re: " + msg.Subject
  303. }
  304. }
  305. if ctx.Request().Method == http.MethodPost {
  306. msg.From = ctx.FormValue("from")
  307. msg.To = parseAddressList(ctx.FormValue("to"))
  308. msg.Subject = ctx.FormValue("subject")
  309. msg.Text = ctx.FormValue("text")
  310. msg.InReplyTo = ctx.FormValue("in_reply_to")
  311. form, err := ctx.MultipartForm()
  312. if err != nil {
  313. return fmt.Errorf("failed to get multipart form: %v", err)
  314. }
  315. msg.Attachments = form.File["attachments"]
  316. err = ctx.Session.DoSMTP(func(c *smtp.Client) error {
  317. return sendMessage(c, &msg)
  318. })
  319. if err != nil {
  320. if _, ok := err.(koushin.AuthError); ok {
  321. return echo.NewHTTPError(http.StatusForbidden, err)
  322. }
  323. return err
  324. }
  325. // TODO: append to IMAP Sent mailbox
  326. // TODO: add \Answered flag to original IMAP message
  327. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  328. }
  329. return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{
  330. BaseRenderData: *koushin.NewBaseRenderData(ctx),
  331. Message: &msg,
  332. })
  333. }
  334. func handleMove(ctx *koushin.Context) error {
  335. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  336. if err != nil {
  337. return echo.NewHTTPError(http.StatusBadRequest, err)
  338. }
  339. to := ctx.FormValue("to")
  340. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  341. mc := imapmove.NewClient(c)
  342. if err := ensureMailboxSelected(c, mboxName); err != nil {
  343. return err
  344. }
  345. var seqSet imap.SeqSet
  346. seqSet.AddNum(uid)
  347. if err := mc.UidMoveWithFallback(&seqSet, to); err != nil {
  348. return fmt.Errorf("failed to move message: %v", err)
  349. }
  350. // TODO: get the UID of the message in the destination mailbox with UIDPLUS
  351. return nil
  352. })
  353. if err != nil {
  354. return err
  355. }
  356. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(to)))
  357. }
  358. func handleDelete(ctx *koushin.Context) error {
  359. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  360. if err != nil {
  361. return echo.NewHTTPError(http.StatusBadRequest, err)
  362. }
  363. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  364. if err := ensureMailboxSelected(c, mboxName); err != nil {
  365. return err
  366. }
  367. var seqSet imap.SeqSet
  368. seqSet.AddNum(uid)
  369. item := imap.FormatFlagsOp(imap.AddFlags, true)
  370. flags := []interface{}{imap.DeletedFlag}
  371. if err := c.UidStore(&seqSet, item, flags, nil); err != nil {
  372. return fmt.Errorf("failed to add deleted flag: %v", err)
  373. }
  374. if err := c.Expunge(nil); err != nil {
  375. return fmt.Errorf("failed to expunge mailbox: %v", err)
  376. }
  377. // Deleting a message invalidates our cached message count
  378. // TODO: listen to async updates instead
  379. if _, err := c.Select(mboxName, false); err != nil {
  380. return fmt.Errorf("failed to select mailbox: %v", err)
  381. }
  382. return nil
  383. })
  384. if err != nil {
  385. return err
  386. }
  387. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
  388. }
  389. func handleSetFlags(ctx *koushin.Context) error {
  390. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  391. if err != nil {
  392. return echo.NewHTTPError(http.StatusBadRequest, err)
  393. }
  394. form, err := ctx.FormParams()
  395. if err != nil {
  396. return echo.NewHTTPError(http.StatusBadRequest, err)
  397. }
  398. flags, ok := form["flags"]
  399. if !ok {
  400. return echo.NewHTTPError(http.StatusBadRequest, "missing 'flags' form values")
  401. }
  402. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  403. if err := ensureMailboxSelected(c, mboxName); err != nil {
  404. return err
  405. }
  406. var seqSet imap.SeqSet
  407. seqSet.AddNum(uid)
  408. storeItems := make([]interface{}, len(flags))
  409. for i, f := range flags {
  410. storeItems[i] = f
  411. }
  412. item := imap.FormatFlagsOp(imap.SetFlags, true)
  413. if err := c.UidStore(&seqSet, item, storeItems, nil); err != nil {
  414. return fmt.Errorf("failed to add deleted flag: %v", err)
  415. }
  416. return nil
  417. })
  418. if err != nil {
  419. return err
  420. }
  421. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/message/%v/%v", url.PathEscape(mboxName), uid))
  422. }