A webmail client. Forked from https://git.sr.ht/~migadu/alps
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 
 

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