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.
 
 
 
 

1176 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. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  264. }
  265. return ctx.Render(http.StatusOK, "delete-mailbox.html", ibase)
  266. }
  267. func handleLogin(ctx *alps.Context) error {
  268. username := ctx.FormValue("username")
  269. password := ctx.FormValue("password")
  270. remember := ctx.FormValue("remember-me")
  271. renderData := struct {
  272. alps.BaseRenderData
  273. CanRememberMe bool
  274. }{
  275. BaseRenderData: *alps.NewBaseRenderData(ctx),
  276. CanRememberMe: ctx.Server.Options.LoginKey != nil,
  277. }
  278. if username == "" && password == "" {
  279. username, password = ctx.GetLoginToken()
  280. }
  281. if username != "" && password != "" {
  282. s, err := ctx.Server.Sessions.Put(username, password)
  283. if err != nil {
  284. if _, ok := err.(alps.AuthError); ok {
  285. return ctx.Render(http.StatusOK, "login.html", &renderData)
  286. }
  287. return fmt.Errorf("failed to put connection in pool: %v", err)
  288. }
  289. ctx.SetSession(s)
  290. if remember == "on" {
  291. ctx.SetLoginToken(username, password)
  292. }
  293. if path := ctx.QueryParam("next"); path != "" && path[0] == '/' && path != "/login" {
  294. return ctx.Redirect(http.StatusFound, path)
  295. }
  296. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  297. }
  298. return ctx.Render(http.StatusOK, "login.html", &renderData)
  299. }
  300. func handleLogout(ctx *alps.Context) error {
  301. ctx.Session.Close()
  302. ctx.SetSession(nil)
  303. ctx.SetLoginToken("", "")
  304. return ctx.Redirect(http.StatusFound, "/login")
  305. }
  306. type MessageRenderData struct {
  307. IMAPBaseRenderData
  308. Message *IMAPMessage
  309. Part *IMAPPartNode
  310. View interface{}
  311. MailboxPage int
  312. Flags map[string]bool
  313. }
  314. func handleGetPart(ctx *alps.Context, raw bool) error {
  315. _, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  316. ibase, err := newIMAPBaseRenderData(ctx, alps.NewBaseRenderData(ctx))
  317. if err != nil {
  318. return err
  319. }
  320. mbox := ibase.Mailbox
  321. partPath, err := parsePartPath(ctx.QueryParam("part"))
  322. if err != nil {
  323. return echo.NewHTTPError(http.StatusBadRequest, err)
  324. }
  325. settings, err := loadSettings(ctx.Session.Store())
  326. if err != nil {
  327. return err
  328. }
  329. messagesPerPage := settings.MessagesPerPage
  330. var msg *IMAPMessage
  331. var part *message.Entity
  332. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  333. var err error
  334. if msg, part, err = getMessagePart(c, mbox.Name, uid, partPath); err != nil {
  335. return err
  336. }
  337. return nil
  338. })
  339. if err != nil {
  340. return err
  341. }
  342. mimeType, _, err := part.Header.ContentType()
  343. if err != nil {
  344. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  345. }
  346. if len(partPath) == 0 {
  347. if ctx.QueryParam("plain") == "1" {
  348. mimeType = "text/plain"
  349. } else {
  350. mimeType = "message/rfc822"
  351. }
  352. }
  353. if raw {
  354. ctx.Response().Header().Set("Content-Type", mimeType)
  355. disp, dispParams, _ := part.Header.ContentDisposition()
  356. filename := dispParams["filename"]
  357. if len(partPath) == 0 {
  358. filename = msg.Envelope.Subject + ".eml"
  359. }
  360. // TODO: set Content-Length if possible
  361. // Be careful not to serve types like text/html as inline
  362. if !strings.EqualFold(mimeType, "text/plain") || strings.EqualFold(disp, "attachment") {
  363. dispParams := make(map[string]string)
  364. if filename != "" {
  365. dispParams["filename"] = filename
  366. }
  367. disp := mime.FormatMediaType("attachment", dispParams)
  368. ctx.Response().Header().Set("Content-Disposition", disp)
  369. }
  370. if len(partPath) == 0 {
  371. return part.WriteTo(ctx.Response())
  372. } else {
  373. return ctx.Stream(http.StatusOK, mimeType, part.Body)
  374. }
  375. }
  376. view, err := viewMessagePart(ctx, msg, part)
  377. if err == ErrViewUnsupported {
  378. view = nil
  379. }
  380. flags := make(map[string]bool)
  381. for _, f := range mbox.PermanentFlags {
  382. f = imap.CanonicalFlag(f)
  383. if f == imap.TryCreateFlag {
  384. continue
  385. }
  386. flags[f] = msg.HasFlag(f)
  387. }
  388. ibase.BaseRenderData.WithTitle(msg.Envelope.Subject)
  389. return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{
  390. IMAPBaseRenderData: *ibase,
  391. Message: msg,
  392. Part: msg.PartByPath(partPath),
  393. View: view,
  394. MailboxPage: int(mbox.Messages-msg.SeqNum) / messagesPerPage,
  395. Flags: flags,
  396. })
  397. }
  398. type ComposeRenderData struct {
  399. IMAPBaseRenderData
  400. Message *OutgoingMessage
  401. }
  402. type messagePath struct {
  403. Mailbox string
  404. Uid uint32
  405. }
  406. type composeOptions struct {
  407. Draft *messagePath
  408. Forward *messagePath
  409. InReplyTo *messagePath
  410. }
  411. // Send message, append it to the Sent mailbox, mark the original message as
  412. // answered
  413. func submitCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOptions) error {
  414. err := ctx.Session.DoSMTP(func (c *smtp.Client) error {
  415. return sendMessage(c, msg)
  416. })
  417. if err != nil {
  418. if _, ok := err.(alps.AuthError); ok {
  419. return echo.NewHTTPError(http.StatusForbidden, err)
  420. }
  421. return fmt.Errorf("failed to send message: %v", err)
  422. }
  423. if inReplyTo := options.InReplyTo; inReplyTo != nil {
  424. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  425. return markMessageAnswered(c, inReplyTo.Mailbox, inReplyTo.Uid)
  426. })
  427. if err != nil {
  428. return fmt.Errorf("failed to mark original message as answered: %v", err)
  429. }
  430. }
  431. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  432. if _, err := appendMessage(c, msg, mailboxSent); err != nil {
  433. return err
  434. }
  435. if draft := options.Draft; draft != nil {
  436. if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
  437. return err
  438. }
  439. }
  440. return nil
  441. })
  442. if err != nil {
  443. return fmt.Errorf("failed to save message to Sent mailbox: %v", err)
  444. }
  445. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  446. }
  447. func handleCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOptions) error {
  448. ibase, err := newIMAPBaseRenderData(ctx, alps.NewBaseRenderData(ctx))
  449. if err != nil {
  450. return err
  451. }
  452. if msg.From == "" && strings.ContainsRune(ctx.Session.Username(), '@') {
  453. msg.From = ctx.Session.Username()
  454. }
  455. if ctx.Request().Method == http.MethodPost {
  456. formParams, err := ctx.FormParams()
  457. if err != nil {
  458. return fmt.Errorf("failed to parse form: %v", err)
  459. }
  460. _, saveAsDraft := formParams["save_as_draft"]
  461. msg.From = ctx.FormValue("from")
  462. msg.To = parseAddressList(ctx.FormValue("to"))
  463. msg.Subject = ctx.FormValue("subject")
  464. msg.Text = ctx.FormValue("text")
  465. msg.InReplyTo = ctx.FormValue("in_reply_to")
  466. msg.MessageID = ctx.FormValue("message_id")
  467. form, err := ctx.MultipartForm()
  468. if err != nil {
  469. return fmt.Errorf("failed to get multipart form: %v", err)
  470. }
  471. // Fetch previous attachments from original message
  472. var original *messagePath
  473. if options.Draft != nil {
  474. original = options.Draft
  475. } else if options.Forward != nil {
  476. original = options.Forward
  477. }
  478. if original != nil {
  479. for _, s := range form.Value["prev_attachments"] {
  480. path, err := parsePartPath(s)
  481. if err != nil {
  482. return fmt.Errorf("failed to parse original attachment path: %v", err)
  483. }
  484. var part *message.Entity
  485. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  486. var err error
  487. _, part, err = getMessagePart(c, original.Mailbox, original.Uid, path)
  488. return err
  489. })
  490. if err != nil {
  491. return fmt.Errorf("failed to fetch attachment from original message: %v", err)
  492. }
  493. var buf bytes.Buffer
  494. if _, err := io.Copy(&buf, part.Body); err != nil {
  495. return fmt.Errorf("failed to copy attachment from original message: %v", err)
  496. }
  497. h := mail.AttachmentHeader{part.Header}
  498. mimeType, _, _ := h.ContentType()
  499. filename, _ := h.Filename()
  500. msg.Attachments = append(msg.Attachments, &imapAttachment{
  501. Mailbox: original.Mailbox,
  502. Uid: original.Uid,
  503. Node: &IMAPPartNode{
  504. Path: path,
  505. MIMEType: mimeType,
  506. Filename: filename,
  507. },
  508. Body: buf.Bytes(),
  509. })
  510. }
  511. } else if len(form.Value["prev_attachments"]) > 0 {
  512. return fmt.Errorf("previous attachments specified but no original message available")
  513. }
  514. for _, fh := range form.File["attachments"] {
  515. msg.Attachments = append(msg.Attachments, &formAttachment{fh})
  516. }
  517. uuids := ctx.FormValue("attachment-uuids")
  518. for _, uuid := range strings.Split(uuids, ",") {
  519. if uuid == "" {
  520. continue
  521. }
  522. attachment := ctx.Session.PopAttachment(uuid)
  523. if attachment == nil {
  524. return fmt.Errorf("Unable to retrieve message attachment %s from session", uuid)
  525. }
  526. msg.Attachments = append(msg.Attachments,
  527. &formAttachment{attachment.File})
  528. defer attachment.Form.RemoveAll()
  529. }
  530. if saveAsDraft {
  531. var (
  532. drafts *MailboxInfo
  533. uid uint32
  534. )
  535. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  536. drafts, err = appendMessage(c, msg, mailboxDrafts)
  537. if err != nil {
  538. return err
  539. }
  540. if draft := options.Draft; draft != nil {
  541. if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
  542. return err
  543. }
  544. }
  545. criteria := &imap.SearchCriteria{
  546. Header: make(textproto.MIMEHeader),
  547. }
  548. criteria.Header.Add("Message-Id", msg.MessageID)
  549. if uids, err := c.UidSearch(criteria); err != nil {
  550. return err
  551. } else {
  552. if len(uids) != 1 {
  553. panic(fmt.Errorf("Duplicate message ID"))
  554. }
  555. uid = uids[0]
  556. }
  557. return nil
  558. })
  559. if err != nil {
  560. return fmt.Errorf("failed to save message to Draft mailbox: %v", err)
  561. }
  562. return ctx.Redirect(http.StatusFound, fmt.Sprintf(
  563. "/message/%s/%d/edit?part=1", drafts.Name, uid))
  564. } else {
  565. return submitCompose(ctx, msg, options)
  566. }
  567. }
  568. return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{
  569. IMAPBaseRenderData: *ibase,
  570. Message: msg,
  571. })
  572. }
  573. func handleComposeNew(ctx *alps.Context) error {
  574. // These are common mailto URL query parameters
  575. // TODO: cc, bcc
  576. return handleCompose(ctx, &OutgoingMessage{
  577. To: strings.Split(ctx.QueryParam("to"), ","),
  578. Subject: ctx.QueryParam("subject"),
  579. Text: ctx.QueryParam("body"),
  580. MessageID: mail.GenerateMessageID(),
  581. InReplyTo: ctx.QueryParam("in-reply-to"),
  582. }, &composeOptions{})
  583. }
  584. func handleComposeAttachment(ctx *alps.Context) error {
  585. reader, err := ctx.Request().MultipartReader()
  586. if err != nil {
  587. return ctx.JSON(http.StatusBadRequest, map[string]string{
  588. "error": "Invalid request",
  589. })
  590. }
  591. form, err := reader.ReadForm(32 << 20) // 32 MB
  592. if err != nil {
  593. return ctx.JSON(http.StatusBadRequest, map[string]string{
  594. "error": "Invalid request",
  595. })
  596. }
  597. var uuids []string
  598. for _, fh := range form.File["attachments"] {
  599. uuid, err := ctx.Session.PutAttachment(fh, form)
  600. if err == alps.ErrAttachmentCacheSize {
  601. form.RemoveAll()
  602. return ctx.JSON(http.StatusBadRequest, map[string]string{
  603. "error": "Your attachments exceed the maximum file size. Remove some and try again.",
  604. })
  605. } else if err != nil {
  606. form.RemoveAll()
  607. ctx.Logger().Printf("PutAttachment: %v\n", err)
  608. return ctx.JSON(http.StatusBadRequest, map[string]string{
  609. "error": "failed to store attachment",
  610. })
  611. }
  612. uuids = append(uuids, uuid)
  613. }
  614. return ctx.JSON(http.StatusOK, &uuids)
  615. }
  616. func handleCancelAttachment(ctx *alps.Context) error {
  617. uuid := ctx.Param("uuid")
  618. a := ctx.Session.PopAttachment(uuid)
  619. if a != nil {
  620. a.Form.RemoveAll()
  621. }
  622. return ctx.JSON(http.StatusOK, nil)
  623. }
  624. func unwrapIMAPAddressList(addrs []*imap.Address) []string {
  625. l := make([]string, len(addrs))
  626. for i, addr := range addrs {
  627. l[i] = addr.Address()
  628. }
  629. return l
  630. }
  631. func handleReply(ctx *alps.Context) error {
  632. var inReplyToPath messagePath
  633. var err error
  634. inReplyToPath.Mailbox, inReplyToPath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  635. if err != nil {
  636. return echo.NewHTTPError(http.StatusBadRequest, err)
  637. }
  638. var msg OutgoingMessage
  639. if ctx.Request().Method == http.MethodGet {
  640. // Populate fields from original message
  641. partPath, err := parsePartPath(ctx.QueryParam("part"))
  642. if err != nil {
  643. return echo.NewHTTPError(http.StatusBadRequest, err)
  644. }
  645. var inReplyTo *IMAPMessage
  646. var part *message.Entity
  647. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  648. var err error
  649. inReplyTo, part, err = getMessagePart(c, inReplyToPath.Mailbox, inReplyToPath.Uid, partPath)
  650. return err
  651. })
  652. if err != nil {
  653. return err
  654. }
  655. mimeType, _, err := part.Header.ContentType()
  656. if err != nil {
  657. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  658. }
  659. if mimeType == "text/plain" {
  660. msg.Text, err = quote(part.Body)
  661. if err != nil {
  662. return err
  663. }
  664. } else if mimeType == "text/html" {
  665. text, err := html2text.FromReader(part.Body, html2text.Options{})
  666. if err != nil {
  667. return err
  668. }
  669. msg.Text, err = quote(strings.NewReader(text))
  670. if err != nil {
  671. return nil
  672. }
  673. } else {
  674. err := fmt.Errorf("cannot forward %q part", mimeType)
  675. return echo.NewHTTPError(http.StatusBadRequest, err)
  676. }
  677. msg.MessageID = mail.GenerateMessageID()
  678. msg.InReplyTo = inReplyTo.Envelope.MessageId
  679. // TODO: populate From from known user addresses and inReplyTo.Envelope.To
  680. replyTo := inReplyTo.Envelope.ReplyTo
  681. if len(replyTo) == 0 {
  682. replyTo = inReplyTo.Envelope.From
  683. }
  684. msg.To = unwrapIMAPAddressList(replyTo)
  685. msg.Subject = inReplyTo.Envelope.Subject
  686. if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") {
  687. msg.Subject = "Re: " + msg.Subject
  688. }
  689. }
  690. return handleCompose(ctx, &msg, &composeOptions{InReplyTo: &inReplyToPath})
  691. }
  692. func handleForward(ctx *alps.Context) error {
  693. var sourcePath messagePath
  694. var err error
  695. sourcePath.Mailbox, sourcePath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  696. if err != nil {
  697. return echo.NewHTTPError(http.StatusBadRequest, err)
  698. }
  699. var msg OutgoingMessage
  700. if ctx.Request().Method == http.MethodGet {
  701. // Populate fields from original message
  702. partPath, err := parsePartPath(ctx.QueryParam("part"))
  703. if err != nil {
  704. return echo.NewHTTPError(http.StatusBadRequest, err)
  705. }
  706. var source *IMAPMessage
  707. var part *message.Entity
  708. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  709. var err error
  710. source, part, err = getMessagePart(c, sourcePath.Mailbox, sourcePath.Uid, partPath)
  711. return err
  712. })
  713. if err != nil {
  714. return err
  715. }
  716. mimeType, _, err := part.Header.ContentType()
  717. if err != nil {
  718. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  719. }
  720. if mimeType == "text/plain" {
  721. msg.Text, err = quote(part.Body)
  722. if err != nil {
  723. return err
  724. }
  725. } else if mimeType == "text/html" {
  726. msg.Text, err = html2text.FromReader(part.Body, html2text.Options{})
  727. if err != nil {
  728. return err
  729. }
  730. } else {
  731. err := fmt.Errorf("cannot forward %q part", mimeType)
  732. return echo.NewHTTPError(http.StatusBadRequest, err)
  733. }
  734. msg.MessageID = mail.GenerateMessageID()
  735. msg.Subject = source.Envelope.Subject
  736. if !strings.HasPrefix(strings.ToLower(msg.Subject), "fwd:") &&
  737. !strings.HasPrefix(strings.ToLower(msg.Subject), "fw:") {
  738. msg.Subject = "Fwd: " + msg.Subject
  739. }
  740. msg.InReplyTo = source.Envelope.InReplyTo
  741. attachments := source.Attachments()
  742. for i := range attachments {
  743. // No need to populate attachment body here, we just need the
  744. // metadata
  745. msg.Attachments = append(msg.Attachments, &imapAttachment{
  746. Mailbox: sourcePath.Mailbox,
  747. Uid: sourcePath.Uid,
  748. Node: &attachments[i],
  749. })
  750. }
  751. }
  752. return handleCompose(ctx, &msg, &composeOptions{Forward: &sourcePath})
  753. }
  754. func handleEdit(ctx *alps.Context) error {
  755. var sourcePath messagePath
  756. var err error
  757. sourcePath.Mailbox, sourcePath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  758. if err != nil {
  759. return echo.NewHTTPError(http.StatusBadRequest, err)
  760. }
  761. // TODO: somehow get the path to the In-Reply-To message (with a search?)
  762. var msg OutgoingMessage
  763. if ctx.Request().Method == http.MethodGet {
  764. // Populate fields from source message
  765. partPath, err := parsePartPath(ctx.QueryParam("part"))
  766. if err != nil {
  767. return echo.NewHTTPError(http.StatusBadRequest, err)
  768. }
  769. var source *IMAPMessage
  770. var part *message.Entity
  771. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  772. var err error
  773. source, part, err = getMessagePart(c, sourcePath.Mailbox, sourcePath.Uid, partPath)
  774. return err
  775. })
  776. if err != nil {
  777. return err
  778. }
  779. mimeType, _, err := part.Header.ContentType()
  780. if err != nil {
  781. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  782. }
  783. if !strings.EqualFold(mimeType, "text/plain") {
  784. err := fmt.Errorf("cannot edit %q part", mimeType)
  785. return echo.NewHTTPError(http.StatusBadRequest, err)
  786. }
  787. b, err := ioutil.ReadAll(part.Body)
  788. if err != nil {
  789. return fmt.Errorf("failed to read part body: %v", err)
  790. }
  791. msg.Text = string(b)
  792. if len(source.Envelope.From) > 0 {
  793. msg.From = source.Envelope.From[0].Address()
  794. }
  795. msg.To = unwrapIMAPAddressList(source.Envelope.To)
  796. msg.Subject = source.Envelope.Subject
  797. msg.InReplyTo = source.Envelope.InReplyTo
  798. msg.MessageID = source.Envelope.MessageId
  799. attachments := source.Attachments()
  800. for i := range attachments {
  801. // No need to populate attachment body here, we just need the
  802. // metadata
  803. msg.Attachments = append(msg.Attachments, &imapAttachment{
  804. Mailbox: sourcePath.Mailbox,
  805. Uid: sourcePath.Uid,
  806. Node: &attachments[i],
  807. })
  808. }
  809. }
  810. return handleCompose(ctx, &msg, &composeOptions{Draft: &sourcePath})
  811. }
  812. func formOrQueryParam(ctx *alps.Context, k string) string {
  813. if v := ctx.FormValue(k); v != "" {
  814. return v
  815. }
  816. return ctx.QueryParam(k)
  817. }
  818. func handleMove(ctx *alps.Context) error {
  819. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  820. if err != nil {
  821. return echo.NewHTTPError(http.StatusBadRequest, err)
  822. }
  823. formParams, err := ctx.FormParams()
  824. if err != nil {
  825. return echo.NewHTTPError(http.StatusBadRequest, err)
  826. }
  827. uids, err := parseUidList(formParams["uids"])
  828. if err != nil {
  829. return echo.NewHTTPError(http.StatusBadRequest, err)
  830. }
  831. to := formOrQueryParam(ctx, "to")
  832. if to == "" {
  833. return echo.NewHTTPError(http.StatusBadRequest, "missing 'to' form parameter")
  834. }
  835. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  836. mc := imapmove.NewClient(c)
  837. if err := ensureMailboxSelected(c, mboxName); err != nil {
  838. return err
  839. }
  840. var seqSet imap.SeqSet
  841. seqSet.AddNum(uids...)
  842. if err := mc.UidMoveWithFallback(&seqSet, to); err != nil {
  843. return fmt.Errorf("failed to move message: %v", err)
  844. }
  845. // TODO: get the UID of the message in the destination mailbox with UIDPLUS
  846. return nil
  847. })
  848. if err != nil {
  849. return err
  850. }
  851. if path := formOrQueryParam(ctx, "next"); path != "" {
  852. return ctx.Redirect(http.StatusFound, path)
  853. }
  854. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
  855. }
  856. func handleDelete(ctx *alps.Context) error {
  857. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  858. if err != nil {
  859. return echo.NewHTTPError(http.StatusBadRequest, err)
  860. }
  861. formParams, err := ctx.FormParams()
  862. if err != nil {
  863. return echo.NewHTTPError(http.StatusBadRequest, err)
  864. }
  865. uids, err := parseUidList(formParams["uids"])
  866. if err != nil {
  867. return echo.NewHTTPError(http.StatusBadRequest, err)
  868. }
  869. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  870. if err := ensureMailboxSelected(c, mboxName); err != nil {
  871. return err
  872. }
  873. var seqSet imap.SeqSet
  874. seqSet.AddNum(uids...)
  875. item := imap.FormatFlagsOp(imap.AddFlags, true)
  876. flags := []interface{}{imap.DeletedFlag}
  877. if err := c.UidStore(&seqSet, item, flags, nil); err != nil {
  878. return fmt.Errorf("failed to add deleted flag: %v", err)
  879. }
  880. if err := c.Expunge(nil); err != nil {
  881. return fmt.Errorf("failed to expunge mailbox: %v", err)
  882. }
  883. // Deleting a message invalidates our cached message count
  884. // TODO: listen to async updates instead
  885. if _, err := c.Select(mboxName, false); err != nil {
  886. return fmt.Errorf("failed to select mailbox: %v", err)
  887. }
  888. return nil
  889. })
  890. if err != nil {
  891. return err
  892. }
  893. if path := formOrQueryParam(ctx, "next"); path != "" {
  894. return ctx.Redirect(http.StatusFound, path)
  895. }
  896. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
  897. }
  898. func handleSetFlags(ctx *alps.Context) error {
  899. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  900. if err != nil {
  901. return echo.NewHTTPError(http.StatusBadRequest, err)
  902. }
  903. formParams, err := ctx.FormParams()
  904. if err != nil {
  905. return echo.NewHTTPError(http.StatusBadRequest, err)
  906. }
  907. uids, err := parseUidList(formParams["uids"])
  908. if err != nil {
  909. return echo.NewHTTPError(http.StatusBadRequest, err)
  910. }
  911. flags, ok := formParams["flags"]
  912. if !ok {
  913. flagsStr := ctx.QueryParam("to")
  914. if flagsStr == "" {
  915. return echo.NewHTTPError(http.StatusBadRequest, "missing 'flags' form parameter")
  916. }
  917. flags = strings.Fields(flagsStr)
  918. }
  919. actionStr := ctx.FormValue("action")
  920. if actionStr == "" {
  921. actionStr = ctx.QueryParam("action")
  922. }
  923. var op imap.FlagsOp
  924. switch actionStr {
  925. case "", "set":
  926. op = imap.SetFlags
  927. case "add":
  928. op = imap.AddFlags
  929. case "remove":
  930. op = imap.RemoveFlags
  931. default:
  932. return echo.NewHTTPError(http.StatusBadRequest, "invalid 'action' value")
  933. }
  934. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  935. if err := ensureMailboxSelected(c, mboxName); err != nil {
  936. return err
  937. }
  938. var seqSet imap.SeqSet
  939. seqSet.AddNum(uids...)
  940. storeItems := make([]interface{}, len(flags))
  941. for i, f := range flags {
  942. storeItems[i] = f
  943. }
  944. item := imap.FormatFlagsOp(op, true)
  945. if err := c.UidStore(&seqSet, item, storeItems, nil); err != nil {
  946. return fmt.Errorf("failed to add deleted flag: %v", err)
  947. }
  948. return nil
  949. })
  950. if err != nil {
  951. return err
  952. }
  953. if path := formOrQueryParam(ctx, "next"); path != "" {
  954. return ctx.Redirect(http.StatusFound, path)
  955. }
  956. if len(uids) != 1 || (op == imap.RemoveFlags && len(flags) == 1 && flags[0] == imap.SeenFlag) {
  957. // Redirecting to the message view would mark the message as read again
  958. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
  959. }
  960. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/message/%v/%v", url.PathEscape(mboxName), uids[0]))
  961. }
  962. const settingsKey = "base.settings"
  963. const maxMessagesPerPage = 100
  964. type Settings struct {
  965. MessagesPerPage int
  966. }
  967. func loadSettings(s alps.Store) (*Settings, error) {
  968. settings := &Settings{
  969. MessagesPerPage: 50,
  970. }
  971. if err := s.Get(settingsKey, settings); err != nil && err != alps.ErrNoStoreEntry {
  972. return nil, err
  973. }
  974. if err := settings.check(); err != nil {
  975. return nil, err
  976. }
  977. return settings, nil
  978. }
  979. func (s *Settings) check() error {
  980. if s.MessagesPerPage <= 0 || s.MessagesPerPage > maxMessagesPerPage {
  981. return fmt.Errorf("messages per page out of bounds: %v", s.MessagesPerPage)
  982. }
  983. return nil
  984. }
  985. type SettingsRenderData struct {
  986. alps.BaseRenderData
  987. Settings *Settings
  988. }
  989. func handleSettings(ctx *alps.Context) error {
  990. settings, err := loadSettings(ctx.Session.Store())
  991. if err != nil {
  992. return fmt.Errorf("failed to load settings: %v", err)
  993. }
  994. if ctx.Request().Method == http.MethodPost {
  995. settings.MessagesPerPage, err = strconv.Atoi(ctx.FormValue("messages_per_page"))
  996. if err != nil {
  997. return echo.NewHTTPError(http.StatusBadRequest, "invalid messages per page: %v", err)
  998. }
  999. if err := settings.check(); err != nil {
  1000. return echo.NewHTTPError(http.StatusBadRequest, err)
  1001. }
  1002. if err := ctx.Session.Store().Put(settingsKey, settings); err != nil {
  1003. return fmt.Errorf("failed to save settings: %v", err)
  1004. }
  1005. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  1006. }
  1007. return ctx.Render(http.StatusOK, "settings.html", &SettingsRenderData{
  1008. BaseRenderData: *alps.NewBaseRenderData(ctx),
  1009. Settings: settings,
  1010. })
  1011. }