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.
 
 
 
 

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