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.
 
 
 
 

895 lines
23 KiB

  1. package alpsbase
  2. import (
  3. "bytes"
  4. "fmt"
  5. "io"
  6. "io/ioutil"
  7. "mime"
  8. "net/http"
  9. "net/url"
  10. "strconv"
  11. "strings"
  12. "git.sr.ht/~emersion/alps"
  13. "github.com/emersion/go-imap"
  14. imapmove "github.com/emersion/go-imap-move"
  15. imapclient "github.com/emersion/go-imap/client"
  16. "github.com/emersion/go-message"
  17. "github.com/emersion/go-message/mail"
  18. "github.com/emersion/go-smtp"
  19. "github.com/labstack/echo/v4"
  20. )
  21. func registerRoutes(p *alps.GoPlugin) {
  22. p.GET("/", func(ctx *alps.Context) error {
  23. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  24. })
  25. p.GET("/mailbox/:mbox", handleGetMailbox)
  26. p.POST("/mailbox/:mbox", handleGetMailbox)
  27. p.GET("/message/:mbox/:uid", func(ctx *alps.Context) error {
  28. return handleGetPart(ctx, false)
  29. })
  30. p.GET("/message/:mbox/:uid/raw", func(ctx *alps.Context) error {
  31. return handleGetPart(ctx, true)
  32. })
  33. p.GET("/login", handleLogin)
  34. p.POST("/login", handleLogin)
  35. p.GET("/logout", handleLogout)
  36. p.GET("/compose", handleComposeNew)
  37. p.POST("/compose", handleComposeNew)
  38. p.GET("/message/:mbox/:uid/reply", handleReply)
  39. p.POST("/message/:mbox/:uid/reply", handleReply)
  40. p.GET("/message/:mbox/:uid/forward", handleForward)
  41. p.POST("/message/:mbox/:uid/forward", handleForward)
  42. p.GET("/message/:mbox/:uid/edit", handleEdit)
  43. p.POST("/message/:mbox/:uid/edit", handleEdit)
  44. p.POST("/message/:mbox/move", handleMove)
  45. p.POST("/message/:mbox/delete", handleDelete)
  46. p.POST("/message/:mbox/flag", handleSetFlags)
  47. p.GET("/settings", handleSettings)
  48. p.POST("/settings", handleSettings)
  49. }
  50. type MailboxRenderData struct {
  51. alps.BaseRenderData
  52. Mailbox *MailboxStatus
  53. Mailboxes []MailboxInfo
  54. Messages []IMAPMessage
  55. PrevPage, NextPage int
  56. Query string
  57. }
  58. func handleGetMailbox(ctx *alps.Context) error {
  59. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  60. if err != nil {
  61. return echo.NewHTTPError(http.StatusBadRequest, err)
  62. }
  63. page := 0
  64. if pageStr := ctx.QueryParam("page"); pageStr != "" {
  65. var err error
  66. if page, err = strconv.Atoi(pageStr); err != nil || page < 0 {
  67. return echo.NewHTTPError(http.StatusBadRequest, "invalid page index")
  68. }
  69. }
  70. settings, err := loadSettings(ctx.Session.Store())
  71. if err != nil {
  72. return err
  73. }
  74. messagesPerPage := settings.MessagesPerPage
  75. query := ctx.QueryParam("query")
  76. var mailboxes []MailboxInfo
  77. var msgs []IMAPMessage
  78. var mbox *MailboxStatus
  79. var total int
  80. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  81. var err error
  82. if mailboxes, err = listMailboxes(c); err != nil {
  83. return err
  84. }
  85. if query != "" {
  86. msgs, total, err = searchMessages(c, mboxName, query, page, messagesPerPage)
  87. } else {
  88. msgs, err = listMessages(c, mboxName, page, messagesPerPage)
  89. }
  90. if err != nil {
  91. return err
  92. }
  93. mbox = &MailboxStatus{c.Mailbox()}
  94. return nil
  95. })
  96. if err != nil {
  97. return err
  98. }
  99. prevPage, nextPage := -1, -1
  100. if query != "" {
  101. if page > 0 {
  102. prevPage = page - 1
  103. }
  104. if (page+1)*messagesPerPage <= total {
  105. nextPage = page + 1
  106. }
  107. } else {
  108. if page > 0 {
  109. prevPage = page - 1
  110. }
  111. if (page+1)*messagesPerPage < int(mbox.Messages) {
  112. nextPage = page + 1
  113. }
  114. }
  115. return ctx.Render(http.StatusOK, "mailbox.html", &MailboxRenderData{
  116. BaseRenderData: *alps.NewBaseRenderData(ctx),
  117. Mailbox: mbox,
  118. Mailboxes: mailboxes,
  119. Messages: msgs,
  120. PrevPage: prevPage,
  121. NextPage: nextPage,
  122. Query: query,
  123. })
  124. }
  125. func handleLogin(ctx *alps.Context) error {
  126. username := ctx.FormValue("username")
  127. password := ctx.FormValue("password")
  128. if username != "" && password != "" {
  129. s, err := ctx.Server.Sessions.Put(username, password)
  130. if err != nil {
  131. if _, ok := err.(alps.AuthError); ok {
  132. return ctx.Render(http.StatusOK, "login.html", nil)
  133. }
  134. return fmt.Errorf("failed to put connection in pool: %v", err)
  135. }
  136. ctx.SetSession(s)
  137. if path := ctx.QueryParam("next"); path != "" && path[0] == '/' && path != "/login" {
  138. return ctx.Redirect(http.StatusFound, path)
  139. }
  140. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  141. }
  142. return ctx.Render(http.StatusOK, "login.html", alps.NewBaseRenderData(ctx))
  143. }
  144. func handleLogout(ctx *alps.Context) error {
  145. ctx.Session.Close()
  146. ctx.SetSession(nil)
  147. return ctx.Redirect(http.StatusFound, "/login")
  148. }
  149. type MessageRenderData struct {
  150. alps.BaseRenderData
  151. Mailboxes []MailboxInfo
  152. Mailbox *MailboxStatus
  153. Message *IMAPMessage
  154. Part *IMAPPartNode
  155. View interface{}
  156. MailboxPage int
  157. Flags map[string]bool
  158. }
  159. func handleGetPart(ctx *alps.Context, raw bool) error {
  160. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  161. if err != nil {
  162. return echo.NewHTTPError(http.StatusBadRequest, err)
  163. }
  164. partPath, err := parsePartPath(ctx.QueryParam("part"))
  165. if err != nil {
  166. return echo.NewHTTPError(http.StatusBadRequest, err)
  167. }
  168. settings, err := loadSettings(ctx.Session.Store())
  169. if err != nil {
  170. return err
  171. }
  172. messagesPerPage := settings.MessagesPerPage
  173. var mailboxes []MailboxInfo
  174. var msg *IMAPMessage
  175. var part *message.Entity
  176. var mbox *MailboxStatus
  177. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  178. var err error
  179. if mailboxes, err = listMailboxes(c); err != nil {
  180. return err
  181. }
  182. if msg, part, err = getMessagePart(c, mboxName, uid, partPath); err != nil {
  183. return err
  184. }
  185. mbox = &MailboxStatus{c.Mailbox()}
  186. return nil
  187. })
  188. if err != nil {
  189. return err
  190. }
  191. mimeType, _, err := part.Header.ContentType()
  192. if err != nil {
  193. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  194. }
  195. if len(partPath) == 0 {
  196. mimeType = "message/rfc822"
  197. }
  198. if raw {
  199. ctx.Response().Header().Set("Content-Type", mimeType)
  200. disp, dispParams, _ := part.Header.ContentDisposition()
  201. filename := dispParams["filename"]
  202. if len(partPath) == 0 {
  203. filename = msg.Envelope.Subject + ".eml"
  204. }
  205. // TODO: set Content-Length if possible
  206. // Be careful not to serve types like text/html as inline
  207. if !strings.EqualFold(mimeType, "text/plain") || strings.EqualFold(disp, "attachment") {
  208. dispParams := make(map[string]string)
  209. if filename != "" {
  210. dispParams["filename"] = filename
  211. }
  212. disp := mime.FormatMediaType("attachment", dispParams)
  213. ctx.Response().Header().Set("Content-Disposition", disp)
  214. }
  215. if len(partPath) == 0 {
  216. return part.WriteTo(ctx.Response())
  217. } else {
  218. return ctx.Stream(http.StatusOK, mimeType, part.Body)
  219. }
  220. }
  221. view, err := viewMessagePart(ctx, msg, part)
  222. if err == ErrViewUnsupported {
  223. view = nil
  224. }
  225. flags := make(map[string]bool)
  226. for _, f := range mbox.PermanentFlags {
  227. f = imap.CanonicalFlag(f)
  228. if f == imap.TryCreateFlag {
  229. continue
  230. }
  231. flags[f] = msg.HasFlag(f)
  232. }
  233. return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{
  234. BaseRenderData: *alps.NewBaseRenderData(ctx),
  235. Mailboxes: mailboxes,
  236. Mailbox: mbox,
  237. Message: msg,
  238. Part: msg.PartByPath(partPath),
  239. View: view,
  240. MailboxPage: int(mbox.Messages-msg.SeqNum) / messagesPerPage,
  241. Flags: flags,
  242. })
  243. }
  244. type ComposeRenderData struct {
  245. alps.BaseRenderData
  246. Message *OutgoingMessage
  247. }
  248. type messagePath struct {
  249. Mailbox string
  250. Uid uint32
  251. }
  252. type composeOptions struct {
  253. Draft *messagePath
  254. Forward *messagePath
  255. InReplyTo *messagePath
  256. }
  257. // Send message, append it to the Sent mailbox, mark the original message as
  258. // answered
  259. func submitCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOptions) error {
  260. err := ctx.Session.DoSMTP(func(c *smtp.Client) error {
  261. return sendMessage(c, msg)
  262. })
  263. if err != nil {
  264. if _, ok := err.(alps.AuthError); ok {
  265. return echo.NewHTTPError(http.StatusForbidden, err)
  266. }
  267. return fmt.Errorf("failed to send message: %v", err)
  268. }
  269. if inReplyTo := options.InReplyTo; inReplyTo != nil {
  270. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  271. return markMessageAnswered(c, inReplyTo.Mailbox, inReplyTo.Uid)
  272. })
  273. if err != nil {
  274. return fmt.Errorf("failed to mark original message as answered: %v", err)
  275. }
  276. }
  277. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  278. if _, err := appendMessage(c, msg, mailboxSent); err != nil {
  279. return err
  280. }
  281. if draft := options.Draft; draft != nil {
  282. if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
  283. return err
  284. }
  285. }
  286. return nil
  287. })
  288. if err != nil {
  289. return fmt.Errorf("failed to save message to Sent mailbox: %v", err)
  290. }
  291. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  292. }
  293. func handleCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOptions) error {
  294. if msg.From == "" && strings.ContainsRune(ctx.Session.Username(), '@') {
  295. msg.From = ctx.Session.Username()
  296. }
  297. if ctx.Request().Method == http.MethodPost {
  298. formParams, err := ctx.FormParams()
  299. if err != nil {
  300. return fmt.Errorf("failed to parse form: %v", err)
  301. }
  302. _, saveAsDraft := formParams["save_as_draft"]
  303. msg.From = ctx.FormValue("from")
  304. msg.To = parseAddressList(ctx.FormValue("to"))
  305. msg.Subject = ctx.FormValue("subject")
  306. msg.Text = ctx.FormValue("text")
  307. msg.InReplyTo = ctx.FormValue("in_reply_to")
  308. form, err := ctx.MultipartForm()
  309. if err != nil {
  310. return fmt.Errorf("failed to get multipart form: %v", err)
  311. }
  312. // Fetch previous attachments from original message
  313. var original *messagePath
  314. if options.Draft != nil {
  315. original = options.Draft
  316. } else if options.Forward != nil {
  317. original = options.Forward
  318. }
  319. if original != nil {
  320. for _, s := range form.Value["prev_attachments"] {
  321. path, err := parsePartPath(s)
  322. if err != nil {
  323. return fmt.Errorf("failed to parse original attachment path: %v", err)
  324. }
  325. var part *message.Entity
  326. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  327. var err error
  328. _, part, err = getMessagePart(c, original.Mailbox, original.Uid, path)
  329. return err
  330. })
  331. if err != nil {
  332. return fmt.Errorf("failed to fetch attachment from original message: %v", err)
  333. }
  334. var buf bytes.Buffer
  335. if _, err := io.Copy(&buf, part.Body); err != nil {
  336. return fmt.Errorf("failed to copy attachment from original message: %v", err)
  337. }
  338. h := mail.AttachmentHeader{part.Header}
  339. mimeType, _, _ := h.ContentType()
  340. filename, _ := h.Filename()
  341. msg.Attachments = append(msg.Attachments, &imapAttachment{
  342. Mailbox: original.Mailbox,
  343. Uid: original.Uid,
  344. Node: &IMAPPartNode{
  345. Path: path,
  346. MIMEType: mimeType,
  347. Filename: filename,
  348. },
  349. Body: buf.Bytes(),
  350. })
  351. }
  352. } else if len(form.Value["prev_attachments"]) > 0 {
  353. return fmt.Errorf("previous attachments specified but no original message available")
  354. }
  355. for _, fh := range form.File["attachments"] {
  356. msg.Attachments = append(msg.Attachments, &formAttachment{fh})
  357. }
  358. if saveAsDraft {
  359. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  360. copied, err := appendMessage(c, msg, mailboxDrafts)
  361. if err != nil {
  362. return err
  363. }
  364. if !copied {
  365. return fmt.Errorf("no Draft mailbox found")
  366. }
  367. if draft := options.Draft; draft != nil {
  368. if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
  369. return err
  370. }
  371. }
  372. return nil
  373. })
  374. if err != nil {
  375. return fmt.Errorf("failed to save message to Draft mailbox: %v", err)
  376. }
  377. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  378. } else {
  379. return submitCompose(ctx, msg, options)
  380. }
  381. }
  382. return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{
  383. BaseRenderData: *alps.NewBaseRenderData(ctx),
  384. Message: msg,
  385. })
  386. }
  387. func handleComposeNew(ctx *alps.Context) error {
  388. // These are common mailto URL query parameters
  389. // TODO: cc, bcc
  390. return handleCompose(ctx, &OutgoingMessage{
  391. To: strings.Split(ctx.QueryParam("to"), ","),
  392. Subject: ctx.QueryParam("subject"),
  393. Text: ctx.QueryParam("body"),
  394. InReplyTo: ctx.QueryParam("in-reply-to"),
  395. }, &composeOptions{})
  396. }
  397. func unwrapIMAPAddressList(addrs []*imap.Address) []string {
  398. l := make([]string, len(addrs))
  399. for i, addr := range addrs {
  400. l[i] = addr.Address()
  401. }
  402. return l
  403. }
  404. func handleReply(ctx *alps.Context) error {
  405. var inReplyToPath messagePath
  406. var err error
  407. inReplyToPath.Mailbox, inReplyToPath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  408. if err != nil {
  409. return echo.NewHTTPError(http.StatusBadRequest, err)
  410. }
  411. var msg OutgoingMessage
  412. if ctx.Request().Method == http.MethodGet {
  413. // Populate fields from original message
  414. partPath, err := parsePartPath(ctx.QueryParam("part"))
  415. if err != nil {
  416. return echo.NewHTTPError(http.StatusBadRequest, err)
  417. }
  418. var inReplyTo *IMAPMessage
  419. var part *message.Entity
  420. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  421. var err error
  422. inReplyTo, part, err = getMessagePart(c, inReplyToPath.Mailbox, inReplyToPath.Uid, partPath)
  423. return err
  424. })
  425. if err != nil {
  426. return err
  427. }
  428. mimeType, _, err := part.Header.ContentType()
  429. if err != nil {
  430. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  431. }
  432. if !strings.EqualFold(mimeType, "text/plain") {
  433. err := fmt.Errorf("cannot reply to %q part", mimeType)
  434. return echo.NewHTTPError(http.StatusBadRequest, err)
  435. }
  436. // TODO: strip HTML tags if text/html
  437. msg.Text, err = quote(part.Body)
  438. if err != nil {
  439. return err
  440. }
  441. msg.InReplyTo = inReplyTo.Envelope.MessageId
  442. // TODO: populate From from known user addresses and inReplyTo.Envelope.To
  443. replyTo := inReplyTo.Envelope.ReplyTo
  444. if len(replyTo) == 0 {
  445. replyTo = inReplyTo.Envelope.From
  446. }
  447. msg.To = unwrapIMAPAddressList(replyTo)
  448. msg.Subject = inReplyTo.Envelope.Subject
  449. if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") {
  450. msg.Subject = "Re: " + msg.Subject
  451. }
  452. }
  453. return handleCompose(ctx, &msg, &composeOptions{InReplyTo: &inReplyToPath})
  454. }
  455. func handleForward(ctx *alps.Context) error {
  456. var sourcePath messagePath
  457. var err error
  458. sourcePath.Mailbox, sourcePath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  459. if err != nil {
  460. return echo.NewHTTPError(http.StatusBadRequest, err)
  461. }
  462. var msg OutgoingMessage
  463. if ctx.Request().Method == http.MethodGet {
  464. // Populate fields from original message
  465. partPath, err := parsePartPath(ctx.QueryParam("part"))
  466. if err != nil {
  467. return echo.NewHTTPError(http.StatusBadRequest, err)
  468. }
  469. var source *IMAPMessage
  470. var part *message.Entity
  471. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  472. var err error
  473. source, part, err = getMessagePart(c, sourcePath.Mailbox, sourcePath.Uid, partPath)
  474. return err
  475. })
  476. if err != nil {
  477. return err
  478. }
  479. mimeType, _, err := part.Header.ContentType()
  480. if err != nil {
  481. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  482. }
  483. if !strings.EqualFold(mimeType, "text/plain") {
  484. err := fmt.Errorf("cannot forward %q part", mimeType)
  485. return echo.NewHTTPError(http.StatusBadRequest, err)
  486. }
  487. msg.Text, err = quote(part.Body)
  488. if err != nil {
  489. return err
  490. }
  491. msg.Subject = source.Envelope.Subject
  492. if !strings.HasPrefix(strings.ToLower(msg.Subject), "fwd:") &&
  493. !strings.HasPrefix(strings.ToLower(msg.Subject), "fw:") {
  494. msg.Subject = "Fwd: " + msg.Subject
  495. }
  496. msg.InReplyTo = source.Envelope.InReplyTo
  497. attachments := source.Attachments()
  498. for i := range attachments {
  499. // No need to populate attachment body here, we just need the
  500. // metadata
  501. msg.Attachments = append(msg.Attachments, &imapAttachment{
  502. Mailbox: sourcePath.Mailbox,
  503. Uid: sourcePath.Uid,
  504. Node: &attachments[i],
  505. })
  506. }
  507. }
  508. return handleCompose(ctx, &msg, &composeOptions{Forward: &sourcePath})
  509. }
  510. func handleEdit(ctx *alps.Context) error {
  511. var sourcePath messagePath
  512. var err error
  513. sourcePath.Mailbox, sourcePath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  514. if err != nil {
  515. return echo.NewHTTPError(http.StatusBadRequest, err)
  516. }
  517. // TODO: somehow get the path to the In-Reply-To message (with a search?)
  518. var msg OutgoingMessage
  519. if ctx.Request().Method == http.MethodGet {
  520. // Populate fields from source message
  521. partPath, err := parsePartPath(ctx.QueryParam("part"))
  522. if err != nil {
  523. return echo.NewHTTPError(http.StatusBadRequest, err)
  524. }
  525. var source *IMAPMessage
  526. var part *message.Entity
  527. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  528. var err error
  529. source, part, err = getMessagePart(c, sourcePath.Mailbox, sourcePath.Uid, partPath)
  530. return err
  531. })
  532. if err != nil {
  533. return err
  534. }
  535. mimeType, _, err := part.Header.ContentType()
  536. if err != nil {
  537. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  538. }
  539. if !strings.EqualFold(mimeType, "text/plain") {
  540. err := fmt.Errorf("cannot edit %q part", mimeType)
  541. return echo.NewHTTPError(http.StatusBadRequest, err)
  542. }
  543. b, err := ioutil.ReadAll(part.Body)
  544. if err != nil {
  545. return fmt.Errorf("failed to read part body: %v", err)
  546. }
  547. msg.Text = string(b)
  548. if len(source.Envelope.From) > 0 {
  549. msg.From = source.Envelope.From[0].Address()
  550. }
  551. msg.To = unwrapIMAPAddressList(source.Envelope.To)
  552. msg.Subject = source.Envelope.Subject
  553. msg.InReplyTo = source.Envelope.InReplyTo
  554. // TODO: preserve Message-Id
  555. attachments := source.Attachments()
  556. for i := range attachments {
  557. // No need to populate attachment body here, we just need the
  558. // metadata
  559. msg.Attachments = append(msg.Attachments, &imapAttachment{
  560. Mailbox: sourcePath.Mailbox,
  561. Uid: sourcePath.Uid,
  562. Node: &attachments[i],
  563. })
  564. }
  565. }
  566. return handleCompose(ctx, &msg, &composeOptions{Draft: &sourcePath})
  567. }
  568. func formOrQueryParam(ctx *alps.Context, k string) string {
  569. if v := ctx.FormValue(k); v != "" {
  570. return v
  571. }
  572. return ctx.QueryParam(k)
  573. }
  574. func handleMove(ctx *alps.Context) error {
  575. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  576. if err != nil {
  577. return echo.NewHTTPError(http.StatusBadRequest, err)
  578. }
  579. formParams, err := ctx.FormParams()
  580. if err != nil {
  581. return echo.NewHTTPError(http.StatusBadRequest, err)
  582. }
  583. uids, err := parseUidList(formParams["uids"])
  584. if err != nil {
  585. return echo.NewHTTPError(http.StatusBadRequest, err)
  586. }
  587. to := formOrQueryParam(ctx, "to")
  588. if to == "" {
  589. return echo.NewHTTPError(http.StatusBadRequest, "missing 'to' form parameter")
  590. }
  591. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  592. mc := imapmove.NewClient(c)
  593. if err := ensureMailboxSelected(c, mboxName); err != nil {
  594. return err
  595. }
  596. var seqSet imap.SeqSet
  597. seqSet.AddNum(uids...)
  598. if err := mc.UidMoveWithFallback(&seqSet, to); err != nil {
  599. return fmt.Errorf("failed to move message: %v", err)
  600. }
  601. // TODO: get the UID of the message in the destination mailbox with UIDPLUS
  602. return nil
  603. })
  604. if err != nil {
  605. return err
  606. }
  607. if path := formOrQueryParam(ctx, "next"); path != "" {
  608. return ctx.Redirect(http.StatusFound, path)
  609. }
  610. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(to)))
  611. }
  612. func handleDelete(ctx *alps.Context) error {
  613. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  614. if err != nil {
  615. return echo.NewHTTPError(http.StatusBadRequest, err)
  616. }
  617. formParams, err := ctx.FormParams()
  618. if err != nil {
  619. return echo.NewHTTPError(http.StatusBadRequest, err)
  620. }
  621. uids, err := parseUidList(formParams["uids"])
  622. if err != nil {
  623. return echo.NewHTTPError(http.StatusBadRequest, err)
  624. }
  625. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  626. if err := ensureMailboxSelected(c, mboxName); err != nil {
  627. return err
  628. }
  629. var seqSet imap.SeqSet
  630. seqSet.AddNum(uids...)
  631. item := imap.FormatFlagsOp(imap.AddFlags, true)
  632. flags := []interface{}{imap.DeletedFlag}
  633. if err := c.UidStore(&seqSet, item, flags, nil); err != nil {
  634. return fmt.Errorf("failed to add deleted flag: %v", err)
  635. }
  636. if err := c.Expunge(nil); err != nil {
  637. return fmt.Errorf("failed to expunge mailbox: %v", err)
  638. }
  639. // Deleting a message invalidates our cached message count
  640. // TODO: listen to async updates instead
  641. if _, err := c.Select(mboxName, false); err != nil {
  642. return fmt.Errorf("failed to select mailbox: %v", err)
  643. }
  644. return nil
  645. })
  646. if err != nil {
  647. return err
  648. }
  649. if path := formOrQueryParam(ctx, "next"); path != "" {
  650. return ctx.Redirect(http.StatusFound, path)
  651. }
  652. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
  653. }
  654. func handleSetFlags(ctx *alps.Context) error {
  655. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  656. if err != nil {
  657. return echo.NewHTTPError(http.StatusBadRequest, err)
  658. }
  659. formParams, err := ctx.FormParams()
  660. if err != nil {
  661. return echo.NewHTTPError(http.StatusBadRequest, err)
  662. }
  663. uids, err := parseUidList(formParams["uids"])
  664. if err != nil {
  665. return echo.NewHTTPError(http.StatusBadRequest, err)
  666. }
  667. flags, ok := formParams["flags"]
  668. if !ok {
  669. flagsStr := ctx.QueryParam("to")
  670. if flagsStr == "" {
  671. return echo.NewHTTPError(http.StatusBadRequest, "missing 'flags' form parameter")
  672. }
  673. flags = strings.Fields(flagsStr)
  674. }
  675. actionStr := ctx.FormValue("action")
  676. if actionStr == "" {
  677. actionStr = ctx.QueryParam("action")
  678. }
  679. var op imap.FlagsOp
  680. switch actionStr {
  681. case "", "set":
  682. op = imap.SetFlags
  683. case "add":
  684. op = imap.AddFlags
  685. case "remove":
  686. op = imap.RemoveFlags
  687. default:
  688. return echo.NewHTTPError(http.StatusBadRequest, "invalid 'action' value")
  689. }
  690. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  691. if err := ensureMailboxSelected(c, mboxName); err != nil {
  692. return err
  693. }
  694. var seqSet imap.SeqSet
  695. seqSet.AddNum(uids...)
  696. storeItems := make([]interface{}, len(flags))
  697. for i, f := range flags {
  698. storeItems[i] = f
  699. }
  700. item := imap.FormatFlagsOp(op, true)
  701. if err := c.UidStore(&seqSet, item, storeItems, nil); err != nil {
  702. return fmt.Errorf("failed to add deleted flag: %v", err)
  703. }
  704. return nil
  705. })
  706. if err != nil {
  707. return err
  708. }
  709. if path := formOrQueryParam(ctx, "next"); path != "" {
  710. return ctx.Redirect(http.StatusFound, path)
  711. }
  712. if len(uids) != 1 || (op == imap.RemoveFlags && len(flags) == 1 && flags[0] == imap.SeenFlag) {
  713. // Redirecting to the message view would mark the message as read again
  714. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
  715. }
  716. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/message/%v/%v", url.PathEscape(mboxName), uids[0]))
  717. }
  718. const settingsKey = "base.settings"
  719. const maxMessagesPerPage = 100
  720. type Settings struct {
  721. MessagesPerPage int
  722. }
  723. func loadSettings(s alps.Store) (*Settings, error) {
  724. settings := &Settings{
  725. MessagesPerPage: 50,
  726. }
  727. if err := s.Get(settingsKey, settings); err != nil && err != alps.ErrNoStoreEntry {
  728. return nil, err
  729. }
  730. if err := settings.check(); err != nil {
  731. return nil, err
  732. }
  733. return settings, nil
  734. }
  735. func (s *Settings) check() error {
  736. if s.MessagesPerPage <= 0 || s.MessagesPerPage > maxMessagesPerPage {
  737. return fmt.Errorf("messages per page out of bounds: %v", s.MessagesPerPage)
  738. }
  739. return nil
  740. }
  741. type SettingsRenderData struct {
  742. alps.BaseRenderData
  743. Settings *Settings
  744. }
  745. func handleSettings(ctx *alps.Context) error {
  746. settings, err := loadSettings(ctx.Session.Store())
  747. if err != nil {
  748. return fmt.Errorf("failed to load settings: %v", err)
  749. }
  750. if ctx.Request().Method == http.MethodPost {
  751. settings.MessagesPerPage, err = strconv.Atoi(ctx.FormValue("messages_per_page"))
  752. if err != nil {
  753. return echo.NewHTTPError(http.StatusBadRequest, "invalid messages per page: %v", err)
  754. }
  755. if err := settings.check(); err != nil {
  756. return echo.NewHTTPError(http.StatusBadRequest, err)
  757. }
  758. if err := ctx.Session.Store().Put(settingsKey, settings); err != nil {
  759. return fmt.Errorf("failed to save settings: %v", err)
  760. }
  761. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  762. }
  763. return ctx.Render(http.StatusOK, "settings.html", &SettingsRenderData{
  764. BaseRenderData: *alps.NewBaseRenderData(ctx),
  765. Settings: settings,
  766. })
  767. }