A webmail client. Forked from https://git.sr.ht/~migadu/alps
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

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