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.
 
 
 
 

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