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.
 
 
 
 

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