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.
 
 
 
 

1181 lines
30 KiB

  1. package alpsbase
  2. import (
  3. "bytes"
  4. "fmt"
  5. "io"
  6. "io/ioutil"
  7. "mime"
  8. "net/http"
  9. "net/textproto"
  10. "net/url"
  11. "strconv"
  12. "strings"
  13. "git.sr.ht/~emersion/alps"
  14. "github.com/emersion/go-imap"
  15. "github.com/emersion/go-message"
  16. "github.com/emersion/go-message/mail"
  17. "github.com/emersion/go-smtp"
  18. "github.com/labstack/echo/v4"
  19. "jaytaylor.com/html2text"
  20. imapclient "github.com/emersion/go-imap/client"
  21. imapmove "github.com/emersion/go-imap-move"
  22. )
  23. func registerRoutes(p *alps.GoPlugin) {
  24. p.GET("/", func(ctx *alps.Context) error {
  25. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  26. })
  27. p.GET("/mailbox/:mbox", handleGetMailbox)
  28. p.POST("/mailbox/:mbox", handleGetMailbox)
  29. p.GET("/new-mailbox", handleNewMailbox)
  30. p.POST("/new-mailbox", handleNewMailbox)
  31. p.GET("/delete-mailbox/:mbox", handleDeleteMailbox)
  32. p.POST("/delete-mailbox/:mbox", handleDeleteMailbox)
  33. p.GET("/message/:mbox/:uid", func(ctx *alps.Context) error {
  34. return handleGetPart(ctx, false)
  35. })
  36. p.GET("/message/:mbox/:uid/raw", func(ctx *alps.Context) error {
  37. return handleGetPart(ctx, true)
  38. })
  39. p.GET("/login", handleLogin)
  40. p.POST("/login", handleLogin)
  41. p.GET("/logout", handleLogout)
  42. p.GET("/compose", handleComposeNew)
  43. p.POST("/compose", handleComposeNew)
  44. p.POST("/compose/attachment", handleComposeAttachment)
  45. p.POST("/compose/attachment/:uuid/remove", handleCancelAttachment)
  46. p.GET("/message/:mbox/:uid/reply", handleReply)
  47. p.POST("/message/:mbox/:uid/reply", handleReply)
  48. p.GET("/message/:mbox/:uid/forward", handleForward)
  49. p.POST("/message/:mbox/:uid/forward", handleForward)
  50. p.GET("/message/:mbox/:uid/edit", handleEdit)
  51. p.POST("/message/:mbox/:uid/edit", handleEdit)
  52. p.POST("/message/:mbox/move", handleMove)
  53. p.POST("/message/:mbox/delete", handleDelete)
  54. p.POST("/message/:mbox/flag", handleSetFlags)
  55. p.GET("/settings", handleSettings)
  56. p.POST("/settings", handleSettings)
  57. }
  58. type IMAPBaseRenderData struct {
  59. alps.BaseRenderData
  60. CategorizedMailboxes CategorizedMailboxes
  61. Mailboxes []MailboxInfo
  62. Mailbox *MailboxStatus
  63. Inbox *MailboxStatus
  64. }
  65. type MailboxRenderData struct {
  66. IMAPBaseRenderData
  67. Messages []IMAPMessage
  68. PrevPage, NextPage int
  69. Query string
  70. }
  71. // Organizes mailboxes into common/uncommon categories
  72. type CategorizedMailboxes struct {
  73. Common struct {
  74. Inbox *MailboxInfo
  75. Drafts *MailboxInfo
  76. Sent *MailboxInfo
  77. Junk *MailboxInfo
  78. Trash *MailboxInfo
  79. Archive *MailboxInfo
  80. }
  81. Additional []*MailboxInfo
  82. }
  83. func newIMAPBaseRenderData(ctx *alps.Context,
  84. base *alps.BaseRenderData) (*IMAPBaseRenderData, error) {
  85. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  86. if err != nil {
  87. return nil, echo.NewHTTPError(http.StatusBadRequest, err)
  88. }
  89. var mailboxes []MailboxInfo
  90. var active, inbox *MailboxStatus
  91. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  92. var err error
  93. if mailboxes, err = listMailboxes(c); err != nil {
  94. return err
  95. }
  96. if mboxName != "" {
  97. if active, err = getMailboxStatus(c, mboxName); err != nil {
  98. return echo.NewHTTPError(http.StatusNotFound, err)
  99. }
  100. }
  101. if mboxName == "INBOX" {
  102. inbox = active
  103. } else {
  104. if inbox, err = getMailboxStatus(c, "INBOX"); err != nil {
  105. return err
  106. }
  107. }
  108. return nil
  109. })
  110. if err != nil {
  111. return nil, err
  112. }
  113. var categorized CategorizedMailboxes
  114. mmap := map[string]**MailboxInfo{
  115. "INBOX": &categorized.Common.Inbox,
  116. "Drafts": &categorized.Common.Drafts,
  117. "Sent": &categorized.Common.Sent,
  118. "Junk": &categorized.Common.Junk,
  119. "Trash": &categorized.Common.Trash,
  120. "Archive": &categorized.Common.Archive,
  121. }
  122. for i, _ := range mailboxes {
  123. // Populate unseen & active states
  124. if active != nil && mailboxes[i].Name == active.Name {
  125. mailboxes[i].Unseen = int(active.Unseen)
  126. mailboxes[i].Total = int(active.Messages)
  127. mailboxes[i].Active = true
  128. }
  129. if mailboxes[i].Name == inbox.Name {
  130. mailboxes[i].Unseen = int(inbox.Unseen)
  131. mailboxes[i].Total = int(inbox.Messages)
  132. }
  133. if ptr, ok := mmap[mailboxes[i].Name]; ok {
  134. *ptr = &mailboxes[i]
  135. } else {
  136. categorized.Additional = append(
  137. categorized.Additional, &mailboxes[i])
  138. }
  139. }
  140. return &IMAPBaseRenderData{
  141. BaseRenderData: *base,
  142. CategorizedMailboxes: categorized,
  143. Mailboxes: mailboxes,
  144. Inbox: inbox,
  145. Mailbox: active,
  146. }, nil
  147. }
  148. func handleGetMailbox(ctx *alps.Context) error {
  149. ibase, err := newIMAPBaseRenderData(ctx, alps.NewBaseRenderData(ctx))
  150. if err != nil {
  151. return err
  152. }
  153. mbox := ibase.Mailbox
  154. title := mbox.Name
  155. if title == "INBOX" {
  156. title = "Inbox"
  157. }
  158. if mbox.Unseen > 0 {
  159. title = fmt.Sprintf("(%d) %s", mbox.Unseen, title)
  160. }
  161. ibase.BaseRenderData.WithTitle(title)
  162. page := 0
  163. if pageStr := ctx.QueryParam("page"); pageStr != "" {
  164. var err error
  165. if page, err = strconv.Atoi(pageStr); err != nil || page < 0 {
  166. return echo.NewHTTPError(http.StatusBadRequest, "invalid page index")
  167. }
  168. }
  169. settings, err := loadSettings(ctx.Session.Store())
  170. if err != nil {
  171. return err
  172. }
  173. messagesPerPage := settings.MessagesPerPage
  174. query := ctx.QueryParam("query")
  175. var (
  176. msgs []IMAPMessage
  177. total int
  178. )
  179. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  180. var err error
  181. if query != "" {
  182. msgs, total, err = searchMessages(c, mbox.Name, query, page, messagesPerPage)
  183. } else {
  184. msgs, err = listMessages(c, mbox, page, messagesPerPage)
  185. }
  186. if err != nil {
  187. return err
  188. }
  189. return nil
  190. })
  191. if err != nil {
  192. return err
  193. }
  194. prevPage, nextPage := -1, -1
  195. if query != "" {
  196. if page > 0 {
  197. prevPage = page - 1
  198. }
  199. if (page+1)*messagesPerPage <= total {
  200. nextPage = page + 1
  201. }
  202. } else {
  203. if page > 0 {
  204. prevPage = page - 1
  205. }
  206. if (page+1)*messagesPerPage < int(mbox.Messages) {
  207. nextPage = page + 1
  208. }
  209. }
  210. return ctx.Render(http.StatusOK, "mailbox.html", &MailboxRenderData{
  211. IMAPBaseRenderData: *ibase,
  212. Messages: msgs,
  213. PrevPage: prevPage,
  214. NextPage: nextPage,
  215. Query: query,
  216. })
  217. }
  218. type NewMailboxRenderData struct {
  219. IMAPBaseRenderData
  220. Error string
  221. }
  222. func handleNewMailbox(ctx *alps.Context) error {
  223. ibase, err := newIMAPBaseRenderData(ctx, alps.NewBaseRenderData(ctx))
  224. if err != nil {
  225. return err
  226. }
  227. ibase.BaseRenderData.WithTitle("Create new folder")
  228. if ctx.Request().Method == http.MethodPost {
  229. name := ctx.FormValue("name")
  230. if name == "" {
  231. return ctx.Render(http.StatusOK, "new-mailbox.html", &NewMailboxRenderData{
  232. IMAPBaseRenderData: *ibase,
  233. Error: "Name is required",
  234. })
  235. }
  236. err := ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  237. return c.Create(name)
  238. })
  239. if err != nil {
  240. return ctx.Render(http.StatusOK, "new-mailbox.html", &NewMailboxRenderData{
  241. IMAPBaseRenderData: *ibase,
  242. Error: err.Error(),
  243. })
  244. }
  245. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%s", url.PathEscape(name)))
  246. }
  247. return ctx.Render(http.StatusOK, "new-mailbox.html", &NewMailboxRenderData{
  248. IMAPBaseRenderData: *ibase,
  249. Error: "",
  250. })
  251. }
  252. func handleDeleteMailbox(ctx *alps.Context) error {
  253. ibase, err := newIMAPBaseRenderData(ctx, alps.NewBaseRenderData(ctx))
  254. if err != nil {
  255. return err
  256. }
  257. mbox := ibase.Mailbox
  258. ibase.BaseRenderData.WithTitle("Delete folder '" + mbox.Name + "'")
  259. if ctx.Request().Method == http.MethodPost {
  260. ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  261. return c.Delete(mbox.Name)
  262. })
  263. ctx.Session.PutNotice("Mailbox deleted.")
  264. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  265. }
  266. return ctx.Render(http.StatusOK, "delete-mailbox.html", ibase)
  267. }
  268. func handleLogin(ctx *alps.Context) error {
  269. username := ctx.FormValue("username")
  270. password := ctx.FormValue("password")
  271. remember := ctx.FormValue("remember-me")
  272. renderData := struct {
  273. alps.BaseRenderData
  274. CanRememberMe bool
  275. }{
  276. BaseRenderData: *alps.NewBaseRenderData(ctx),
  277. CanRememberMe: ctx.Server.Options.LoginKey != nil,
  278. }
  279. if username == "" && password == "" {
  280. username, password = ctx.GetLoginToken()
  281. }
  282. if username != "" && password != "" {
  283. s, err := ctx.Server.Sessions.Put(username, password)
  284. if err != nil {
  285. if _, ok := err.(alps.AuthError); ok {
  286. return ctx.Render(http.StatusOK, "login.html", &renderData)
  287. }
  288. return fmt.Errorf("failed to put connection in pool: %v", err)
  289. }
  290. ctx.SetSession(s)
  291. if remember == "on" {
  292. ctx.SetLoginToken(username, password)
  293. }
  294. if path := ctx.QueryParam("next"); path != "" && path[0] == '/' && path != "/login" {
  295. return ctx.Redirect(http.StatusFound, path)
  296. }
  297. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  298. }
  299. return ctx.Render(http.StatusOK, "login.html", &renderData)
  300. }
  301. func handleLogout(ctx *alps.Context) error {
  302. ctx.Session.Close()
  303. ctx.SetSession(nil)
  304. ctx.SetLoginToken("", "")
  305. return ctx.Redirect(http.StatusFound, "/login")
  306. }
  307. type MessageRenderData struct {
  308. IMAPBaseRenderData
  309. Message *IMAPMessage
  310. Part *IMAPPartNode
  311. View interface{}
  312. MailboxPage int
  313. Flags map[string]bool
  314. }
  315. func handleGetPart(ctx *alps.Context, raw bool) error {
  316. _, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  317. ibase, err := newIMAPBaseRenderData(ctx, alps.NewBaseRenderData(ctx))
  318. if err != nil {
  319. return err
  320. }
  321. mbox := ibase.Mailbox
  322. partPath, err := parsePartPath(ctx.QueryParam("part"))
  323. if err != nil {
  324. return echo.NewHTTPError(http.StatusBadRequest, err)
  325. }
  326. settings, err := loadSettings(ctx.Session.Store())
  327. if err != nil {
  328. return err
  329. }
  330. messagesPerPage := settings.MessagesPerPage
  331. var msg *IMAPMessage
  332. var part *message.Entity
  333. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  334. var err error
  335. if msg, part, err = getMessagePart(c, mbox.Name, uid, partPath); err != nil {
  336. return err
  337. }
  338. return nil
  339. })
  340. if err != nil {
  341. return err
  342. }
  343. mimeType, _, err := part.Header.ContentType()
  344. if err != nil {
  345. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  346. }
  347. if len(partPath) == 0 {
  348. if ctx.QueryParam("plain") == "1" {
  349. mimeType = "text/plain"
  350. } else {
  351. mimeType = "message/rfc822"
  352. }
  353. }
  354. if raw {
  355. ctx.Response().Header().Set("Content-Type", mimeType)
  356. disp, dispParams, _ := part.Header.ContentDisposition()
  357. filename := dispParams["filename"]
  358. if len(partPath) == 0 {
  359. filename = msg.Envelope.Subject + ".eml"
  360. }
  361. // TODO: set Content-Length if possible
  362. // Be careful not to serve types like text/html as inline
  363. if !strings.EqualFold(mimeType, "text/plain") || strings.EqualFold(disp, "attachment") {
  364. dispParams := make(map[string]string)
  365. if filename != "" {
  366. dispParams["filename"] = filename
  367. }
  368. disp := mime.FormatMediaType("attachment", dispParams)
  369. ctx.Response().Header().Set("Content-Disposition", disp)
  370. }
  371. if len(partPath) == 0 {
  372. return part.WriteTo(ctx.Response())
  373. } else {
  374. return ctx.Stream(http.StatusOK, mimeType, part.Body)
  375. }
  376. }
  377. view, err := viewMessagePart(ctx, msg, part)
  378. if err == ErrViewUnsupported {
  379. view = nil
  380. }
  381. flags := make(map[string]bool)
  382. for _, f := range mbox.PermanentFlags {
  383. f = imap.CanonicalFlag(f)
  384. if f == imap.TryCreateFlag {
  385. continue
  386. }
  387. flags[f] = msg.HasFlag(f)
  388. }
  389. ibase.BaseRenderData.WithTitle(msg.Envelope.Subject)
  390. return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{
  391. IMAPBaseRenderData: *ibase,
  392. Message: msg,
  393. Part: msg.PartByPath(partPath),
  394. View: view,
  395. MailboxPage: int(mbox.Messages-msg.SeqNum) / messagesPerPage,
  396. Flags: flags,
  397. })
  398. }
  399. type ComposeRenderData struct {
  400. IMAPBaseRenderData
  401. Message *OutgoingMessage
  402. }
  403. type messagePath struct {
  404. Mailbox string
  405. Uid uint32
  406. }
  407. type composeOptions struct {
  408. Draft *messagePath
  409. Forward *messagePath
  410. InReplyTo *messagePath
  411. }
  412. // Send message, append it to the Sent mailbox, mark the original message as
  413. // answered
  414. func submitCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOptions) error {
  415. err := ctx.Session.DoSMTP(func (c *smtp.Client) error {
  416. return sendMessage(c, msg)
  417. })
  418. if err != nil {
  419. if _, ok := err.(alps.AuthError); ok {
  420. return echo.NewHTTPError(http.StatusForbidden, err)
  421. }
  422. return fmt.Errorf("failed to send message: %v", err)
  423. }
  424. if inReplyTo := options.InReplyTo; inReplyTo != nil {
  425. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  426. return markMessageAnswered(c, inReplyTo.Mailbox, inReplyTo.Uid)
  427. })
  428. if err != nil {
  429. return fmt.Errorf("failed to mark original message as answered: %v", err)
  430. }
  431. }
  432. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  433. if _, err := appendMessage(c, msg, mailboxSent); err != nil {
  434. return err
  435. }
  436. if draft := options.Draft; draft != nil {
  437. if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
  438. return err
  439. }
  440. }
  441. return nil
  442. })
  443. if err != nil {
  444. return fmt.Errorf("failed to save message to Sent mailbox: %v", err)
  445. }
  446. ctx.Session.PutNotice("Message sent.")
  447. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  448. }
  449. func handleCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOptions) error {
  450. ibase, err := newIMAPBaseRenderData(ctx, alps.NewBaseRenderData(ctx))
  451. if err != nil {
  452. return err
  453. }
  454. if msg.From == "" && strings.ContainsRune(ctx.Session.Username(), '@') {
  455. msg.From = ctx.Session.Username()
  456. }
  457. if ctx.Request().Method == http.MethodPost {
  458. formParams, err := ctx.FormParams()
  459. if err != nil {
  460. return fmt.Errorf("failed to parse form: %v", err)
  461. }
  462. _, saveAsDraft := formParams["save_as_draft"]
  463. msg.From = ctx.FormValue("from")
  464. msg.To = parseAddressList(ctx.FormValue("to"))
  465. msg.Subject = ctx.FormValue("subject")
  466. msg.Text = ctx.FormValue("text")
  467. msg.InReplyTo = ctx.FormValue("in_reply_to")
  468. msg.MessageID = ctx.FormValue("message_id")
  469. form, err := ctx.MultipartForm()
  470. if err != nil {
  471. return fmt.Errorf("failed to get multipart form: %v", err)
  472. }
  473. // Fetch previous attachments from original message
  474. var original *messagePath
  475. if options.Draft != nil {
  476. original = options.Draft
  477. } else if options.Forward != nil {
  478. original = options.Forward
  479. }
  480. if original != nil {
  481. for _, s := range form.Value["prev_attachments"] {
  482. path, err := parsePartPath(s)
  483. if err != nil {
  484. return fmt.Errorf("failed to parse original attachment path: %v", err)
  485. }
  486. var part *message.Entity
  487. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  488. var err error
  489. _, part, err = getMessagePart(c, original.Mailbox, original.Uid, path)
  490. return err
  491. })
  492. if err != nil {
  493. return fmt.Errorf("failed to fetch attachment from original message: %v", err)
  494. }
  495. var buf bytes.Buffer
  496. if _, err := io.Copy(&buf, part.Body); err != nil {
  497. return fmt.Errorf("failed to copy attachment from original message: %v", err)
  498. }
  499. h := mail.AttachmentHeader{part.Header}
  500. mimeType, _, _ := h.ContentType()
  501. filename, _ := h.Filename()
  502. msg.Attachments = append(msg.Attachments, &imapAttachment{
  503. Mailbox: original.Mailbox,
  504. Uid: original.Uid,
  505. Node: &IMAPPartNode{
  506. Path: path,
  507. MIMEType: mimeType,
  508. Filename: filename,
  509. },
  510. Body: buf.Bytes(),
  511. })
  512. }
  513. } else if len(form.Value["prev_attachments"]) > 0 {
  514. return fmt.Errorf("previous attachments specified but no original message available")
  515. }
  516. for _, fh := range form.File["attachments"] {
  517. msg.Attachments = append(msg.Attachments, &formAttachment{fh})
  518. }
  519. uuids := ctx.FormValue("attachment-uuids")
  520. for _, uuid := range strings.Split(uuids, ",") {
  521. if uuid == "" {
  522. continue
  523. }
  524. attachment := ctx.Session.PopAttachment(uuid)
  525. if attachment == nil {
  526. return fmt.Errorf("Unable to retrieve message attachment %s from session", uuid)
  527. }
  528. msg.Attachments = append(msg.Attachments,
  529. &formAttachment{attachment.File})
  530. defer attachment.Form.RemoveAll()
  531. }
  532. if saveAsDraft {
  533. var (
  534. drafts *MailboxInfo
  535. uid uint32
  536. )
  537. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  538. drafts, err = appendMessage(c, msg, mailboxDrafts)
  539. if err != nil {
  540. return err
  541. }
  542. if draft := options.Draft; draft != nil {
  543. if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
  544. return err
  545. }
  546. }
  547. criteria := &imap.SearchCriteria{
  548. Header: make(textproto.MIMEHeader),
  549. }
  550. criteria.Header.Add("Message-Id", msg.MessageID)
  551. if uids, err := c.UidSearch(criteria); err != nil {
  552. return err
  553. } else {
  554. if len(uids) != 1 {
  555. panic(fmt.Errorf("Duplicate message ID"))
  556. }
  557. uid = uids[0]
  558. }
  559. return nil
  560. })
  561. if err != nil {
  562. return fmt.Errorf("failed to save message to Draft mailbox: %v", err)
  563. }
  564. ctx.Session.PutNotice("Message saved as draft.")
  565. return ctx.Redirect(http.StatusFound, fmt.Sprintf(
  566. "/message/%s/%d/edit?part=1", drafts.Name, uid))
  567. } else {
  568. return submitCompose(ctx, msg, options)
  569. }
  570. }
  571. return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{
  572. IMAPBaseRenderData: *ibase,
  573. Message: msg,
  574. })
  575. }
  576. func handleComposeNew(ctx *alps.Context) error {
  577. // These are common mailto URL query parameters
  578. // TODO: cc, bcc
  579. return handleCompose(ctx, &OutgoingMessage{
  580. To: strings.Split(ctx.QueryParam("to"), ","),
  581. Subject: ctx.QueryParam("subject"),
  582. Text: ctx.QueryParam("body"),
  583. MessageID: mail.GenerateMessageID(),
  584. InReplyTo: ctx.QueryParam("in-reply-to"),
  585. }, &composeOptions{})
  586. }
  587. func handleComposeAttachment(ctx *alps.Context) error {
  588. reader, err := ctx.Request().MultipartReader()
  589. if err != nil {
  590. return ctx.JSON(http.StatusBadRequest, map[string]string{
  591. "error": "Invalid request",
  592. })
  593. }
  594. form, err := reader.ReadForm(32 << 20) // 32 MB
  595. if err != nil {
  596. return ctx.JSON(http.StatusBadRequest, map[string]string{
  597. "error": "Invalid request",
  598. })
  599. }
  600. var uuids []string
  601. for _, fh := range form.File["attachments"] {
  602. uuid, err := ctx.Session.PutAttachment(fh, form)
  603. if err == alps.ErrAttachmentCacheSize {
  604. form.RemoveAll()
  605. return ctx.JSON(http.StatusBadRequest, map[string]string{
  606. "error": "Your attachments exceed the maximum file size. Remove some and try again.",
  607. })
  608. } else if err != nil {
  609. form.RemoveAll()
  610. ctx.Logger().Printf("PutAttachment: %v\n", err)
  611. return ctx.JSON(http.StatusBadRequest, map[string]string{
  612. "error": "failed to store attachment",
  613. })
  614. }
  615. uuids = append(uuids, uuid)
  616. }
  617. return ctx.JSON(http.StatusOK, &uuids)
  618. }
  619. func handleCancelAttachment(ctx *alps.Context) error {
  620. uuid := ctx.Param("uuid")
  621. a := ctx.Session.PopAttachment(uuid)
  622. if a != nil {
  623. a.Form.RemoveAll()
  624. }
  625. return ctx.JSON(http.StatusOK, nil)
  626. }
  627. func unwrapIMAPAddressList(addrs []*imap.Address) []string {
  628. l := make([]string, len(addrs))
  629. for i, addr := range addrs {
  630. l[i] = addr.Address()
  631. }
  632. return l
  633. }
  634. func handleReply(ctx *alps.Context) error {
  635. var inReplyToPath messagePath
  636. var err error
  637. inReplyToPath.Mailbox, inReplyToPath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  638. if err != nil {
  639. return echo.NewHTTPError(http.StatusBadRequest, err)
  640. }
  641. var msg OutgoingMessage
  642. if ctx.Request().Method == http.MethodGet {
  643. // Populate fields from original message
  644. partPath, err := parsePartPath(ctx.QueryParam("part"))
  645. if err != nil {
  646. return echo.NewHTTPError(http.StatusBadRequest, err)
  647. }
  648. var inReplyTo *IMAPMessage
  649. var part *message.Entity
  650. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  651. var err error
  652. inReplyTo, part, err = getMessagePart(c, inReplyToPath.Mailbox, inReplyToPath.Uid, partPath)
  653. return err
  654. })
  655. if err != nil {
  656. return err
  657. }
  658. mimeType, _, err := part.Header.ContentType()
  659. if err != nil {
  660. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  661. }
  662. if mimeType == "text/plain" {
  663. msg.Text, err = quote(part.Body)
  664. if err != nil {
  665. return err
  666. }
  667. } else if mimeType == "text/html" {
  668. text, err := html2text.FromReader(part.Body, html2text.Options{})
  669. if err != nil {
  670. return err
  671. }
  672. msg.Text, err = quote(strings.NewReader(text))
  673. if err != nil {
  674. return nil
  675. }
  676. } else {
  677. err := fmt.Errorf("cannot forward %q part", mimeType)
  678. return echo.NewHTTPError(http.StatusBadRequest, err)
  679. }
  680. msg.MessageID = mail.GenerateMessageID()
  681. msg.InReplyTo = inReplyTo.Envelope.MessageId
  682. // TODO: populate From from known user addresses and inReplyTo.Envelope.To
  683. replyTo := inReplyTo.Envelope.ReplyTo
  684. if len(replyTo) == 0 {
  685. replyTo = inReplyTo.Envelope.From
  686. }
  687. msg.To = unwrapIMAPAddressList(replyTo)
  688. msg.Subject = inReplyTo.Envelope.Subject
  689. if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") {
  690. msg.Subject = "Re: " + msg.Subject
  691. }
  692. }
  693. return handleCompose(ctx, &msg, &composeOptions{InReplyTo: &inReplyToPath})
  694. }
  695. func handleForward(ctx *alps.Context) error {
  696. var sourcePath messagePath
  697. var err error
  698. sourcePath.Mailbox, sourcePath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  699. if err != nil {
  700. return echo.NewHTTPError(http.StatusBadRequest, err)
  701. }
  702. var msg OutgoingMessage
  703. if ctx.Request().Method == http.MethodGet {
  704. // Populate fields from original message
  705. partPath, err := parsePartPath(ctx.QueryParam("part"))
  706. if err != nil {
  707. return echo.NewHTTPError(http.StatusBadRequest, err)
  708. }
  709. var source *IMAPMessage
  710. var part *message.Entity
  711. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  712. var err error
  713. source, part, err = getMessagePart(c, sourcePath.Mailbox, sourcePath.Uid, partPath)
  714. return err
  715. })
  716. if err != nil {
  717. return err
  718. }
  719. mimeType, _, err := part.Header.ContentType()
  720. if err != nil {
  721. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  722. }
  723. if mimeType == "text/plain" {
  724. msg.Text, err = quote(part.Body)
  725. if err != nil {
  726. return err
  727. }
  728. } else if mimeType == "text/html" {
  729. msg.Text, err = html2text.FromReader(part.Body, html2text.Options{})
  730. if err != nil {
  731. return err
  732. }
  733. } else {
  734. err := fmt.Errorf("cannot forward %q part", mimeType)
  735. return echo.NewHTTPError(http.StatusBadRequest, err)
  736. }
  737. msg.MessageID = mail.GenerateMessageID()
  738. msg.Subject = source.Envelope.Subject
  739. if !strings.HasPrefix(strings.ToLower(msg.Subject), "fwd:") &&
  740. !strings.HasPrefix(strings.ToLower(msg.Subject), "fw:") {
  741. msg.Subject = "Fwd: " + msg.Subject
  742. }
  743. msg.InReplyTo = source.Envelope.InReplyTo
  744. attachments := source.Attachments()
  745. for i := range attachments {
  746. // No need to populate attachment body here, we just need the
  747. // metadata
  748. msg.Attachments = append(msg.Attachments, &imapAttachment{
  749. Mailbox: sourcePath.Mailbox,
  750. Uid: sourcePath.Uid,
  751. Node: &attachments[i],
  752. })
  753. }
  754. }
  755. return handleCompose(ctx, &msg, &composeOptions{Forward: &sourcePath})
  756. }
  757. func handleEdit(ctx *alps.Context) error {
  758. var sourcePath messagePath
  759. var err error
  760. sourcePath.Mailbox, sourcePath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  761. if err != nil {
  762. return echo.NewHTTPError(http.StatusBadRequest, err)
  763. }
  764. // TODO: somehow get the path to the In-Reply-To message (with a search?)
  765. var msg OutgoingMessage
  766. if ctx.Request().Method == http.MethodGet {
  767. // Populate fields from source message
  768. partPath, err := parsePartPath(ctx.QueryParam("part"))
  769. if err != nil {
  770. return echo.NewHTTPError(http.StatusBadRequest, err)
  771. }
  772. var source *IMAPMessage
  773. var part *message.Entity
  774. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  775. var err error
  776. source, part, err = getMessagePart(c, sourcePath.Mailbox, sourcePath.Uid, partPath)
  777. return err
  778. })
  779. if err != nil {
  780. return err
  781. }
  782. mimeType, _, err := part.Header.ContentType()
  783. if err != nil {
  784. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  785. }
  786. if !strings.EqualFold(mimeType, "text/plain") {
  787. err := fmt.Errorf("cannot edit %q part", mimeType)
  788. return echo.NewHTTPError(http.StatusBadRequest, err)
  789. }
  790. b, err := ioutil.ReadAll(part.Body)
  791. if err != nil {
  792. return fmt.Errorf("failed to read part body: %v", err)
  793. }
  794. msg.Text = string(b)
  795. if len(source.Envelope.From) > 0 {
  796. msg.From = source.Envelope.From[0].Address()
  797. }
  798. msg.To = unwrapIMAPAddressList(source.Envelope.To)
  799. msg.Subject = source.Envelope.Subject
  800. msg.InReplyTo = source.Envelope.InReplyTo
  801. msg.MessageID = source.Envelope.MessageId
  802. attachments := source.Attachments()
  803. for i := range attachments {
  804. // No need to populate attachment body here, we just need the
  805. // metadata
  806. msg.Attachments = append(msg.Attachments, &imapAttachment{
  807. Mailbox: sourcePath.Mailbox,
  808. Uid: sourcePath.Uid,
  809. Node: &attachments[i],
  810. })
  811. }
  812. }
  813. return handleCompose(ctx, &msg, &composeOptions{Draft: &sourcePath})
  814. }
  815. func formOrQueryParam(ctx *alps.Context, k string) string {
  816. if v := ctx.FormValue(k); v != "" {
  817. return v
  818. }
  819. return ctx.QueryParam(k)
  820. }
  821. func handleMove(ctx *alps.Context) error {
  822. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  823. if err != nil {
  824. return echo.NewHTTPError(http.StatusBadRequest, err)
  825. }
  826. formParams, err := ctx.FormParams()
  827. if err != nil {
  828. return echo.NewHTTPError(http.StatusBadRequest, err)
  829. }
  830. uids, err := parseUidList(formParams["uids"])
  831. if err != nil {
  832. return echo.NewHTTPError(http.StatusBadRequest, err)
  833. }
  834. to := formOrQueryParam(ctx, "to")
  835. if to == "" {
  836. return echo.NewHTTPError(http.StatusBadRequest, "missing 'to' form parameter")
  837. }
  838. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  839. mc := imapmove.NewClient(c)
  840. if err := ensureMailboxSelected(c, mboxName); err != nil {
  841. return err
  842. }
  843. var seqSet imap.SeqSet
  844. seqSet.AddNum(uids...)
  845. if err := mc.UidMoveWithFallback(&seqSet, to); err != nil {
  846. return fmt.Errorf("failed to move message: %v", err)
  847. }
  848. // TODO: get the UID of the message in the destination mailbox with UIDPLUS
  849. return nil
  850. })
  851. if err != nil {
  852. return err
  853. }
  854. ctx.Session.PutNotice("Message(s) moved.")
  855. if path := formOrQueryParam(ctx, "next"); path != "" {
  856. return ctx.Redirect(http.StatusFound, path)
  857. }
  858. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
  859. }
  860. func handleDelete(ctx *alps.Context) error {
  861. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  862. if err != nil {
  863. return echo.NewHTTPError(http.StatusBadRequest, err)
  864. }
  865. formParams, err := ctx.FormParams()
  866. if err != nil {
  867. return echo.NewHTTPError(http.StatusBadRequest, err)
  868. }
  869. uids, err := parseUidList(formParams["uids"])
  870. if err != nil {
  871. return echo.NewHTTPError(http.StatusBadRequest, err)
  872. }
  873. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  874. if err := ensureMailboxSelected(c, mboxName); err != nil {
  875. return err
  876. }
  877. var seqSet imap.SeqSet
  878. seqSet.AddNum(uids...)
  879. item := imap.FormatFlagsOp(imap.AddFlags, true)
  880. flags := []interface{}{imap.DeletedFlag}
  881. if err := c.UidStore(&seqSet, item, flags, nil); err != nil {
  882. return fmt.Errorf("failed to add deleted flag: %v", err)
  883. }
  884. if err := c.Expunge(nil); err != nil {
  885. return fmt.Errorf("failed to expunge mailbox: %v", err)
  886. }
  887. // Deleting a message invalidates our cached message count
  888. // TODO: listen to async updates instead
  889. if _, err := c.Select(mboxName, false); err != nil {
  890. return fmt.Errorf("failed to select mailbox: %v", err)
  891. }
  892. return nil
  893. })
  894. if err != nil {
  895. return err
  896. }
  897. ctx.Session.PutNotice("Message(s) deleted.")
  898. if path := formOrQueryParam(ctx, "next"); path != "" {
  899. return ctx.Redirect(http.StatusFound, path)
  900. }
  901. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
  902. }
  903. func handleSetFlags(ctx *alps.Context) error {
  904. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  905. if err != nil {
  906. return echo.NewHTTPError(http.StatusBadRequest, err)
  907. }
  908. formParams, err := ctx.FormParams()
  909. if err != nil {
  910. return echo.NewHTTPError(http.StatusBadRequest, err)
  911. }
  912. uids, err := parseUidList(formParams["uids"])
  913. if err != nil {
  914. return echo.NewHTTPError(http.StatusBadRequest, err)
  915. }
  916. flags, ok := formParams["flags"]
  917. if !ok {
  918. flagsStr := ctx.QueryParam("to")
  919. if flagsStr == "" {
  920. return echo.NewHTTPError(http.StatusBadRequest, "missing 'flags' form parameter")
  921. }
  922. flags = strings.Fields(flagsStr)
  923. }
  924. actionStr := ctx.FormValue("action")
  925. if actionStr == "" {
  926. actionStr = ctx.QueryParam("action")
  927. }
  928. var op imap.FlagsOp
  929. switch actionStr {
  930. case "", "set":
  931. op = imap.SetFlags
  932. case "add":
  933. op = imap.AddFlags
  934. case "remove":
  935. op = imap.RemoveFlags
  936. default:
  937. return echo.NewHTTPError(http.StatusBadRequest, "invalid 'action' value")
  938. }
  939. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  940. if err := ensureMailboxSelected(c, mboxName); err != nil {
  941. return err
  942. }
  943. var seqSet imap.SeqSet
  944. seqSet.AddNum(uids...)
  945. storeItems := make([]interface{}, len(flags))
  946. for i, f := range flags {
  947. storeItems[i] = f
  948. }
  949. item := imap.FormatFlagsOp(op, true)
  950. if err := c.UidStore(&seqSet, item, storeItems, nil); err != nil {
  951. return fmt.Errorf("failed to add deleted flag: %v", err)
  952. }
  953. return nil
  954. })
  955. if err != nil {
  956. return err
  957. }
  958. if path := formOrQueryParam(ctx, "next"); path != "" {
  959. return ctx.Redirect(http.StatusFound, path)
  960. }
  961. if len(uids) != 1 || (op == imap.RemoveFlags && len(flags) == 1 && flags[0] == imap.SeenFlag) {
  962. // Redirecting to the message view would mark the message as read again
  963. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
  964. }
  965. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/message/%v/%v", url.PathEscape(mboxName), uids[0]))
  966. }
  967. const settingsKey = "base.settings"
  968. const maxMessagesPerPage = 100
  969. type Settings struct {
  970. MessagesPerPage int
  971. }
  972. func loadSettings(s alps.Store) (*Settings, error) {
  973. settings := &Settings{
  974. MessagesPerPage: 50,
  975. }
  976. if err := s.Get(settingsKey, settings); err != nil && err != alps.ErrNoStoreEntry {
  977. return nil, err
  978. }
  979. if err := settings.check(); err != nil {
  980. return nil, err
  981. }
  982. return settings, nil
  983. }
  984. func (s *Settings) check() error {
  985. if s.MessagesPerPage <= 0 || s.MessagesPerPage > maxMessagesPerPage {
  986. return fmt.Errorf("messages per page out of bounds: %v", s.MessagesPerPage)
  987. }
  988. return nil
  989. }
  990. type SettingsRenderData struct {
  991. alps.BaseRenderData
  992. Settings *Settings
  993. }
  994. func handleSettings(ctx *alps.Context) error {
  995. settings, err := loadSettings(ctx.Session.Store())
  996. if err != nil {
  997. return fmt.Errorf("failed to load settings: %v", err)
  998. }
  999. if ctx.Request().Method == http.MethodPost {
  1000. settings.MessagesPerPage, err = strconv.Atoi(ctx.FormValue("messages_per_page"))
  1001. if err != nil {
  1002. return echo.NewHTTPError(http.StatusBadRequest, "invalid messages per page: %v", err)
  1003. }
  1004. if err := settings.check(); err != nil {
  1005. return echo.NewHTTPError(http.StatusBadRequest, err)
  1006. }
  1007. if err := ctx.Session.Store().Put(settingsKey, settings); err != nil {
  1008. return fmt.Errorf("failed to save settings: %v", err)
  1009. }
  1010. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  1011. }
  1012. return ctx.Render(http.StatusOK, "settings.html", &SettingsRenderData{
  1013. BaseRenderData: *alps.NewBaseRenderData(ctx),
  1014. Settings: settings,
  1015. })
  1016. }