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.
 
 
 
 

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