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.
 
 
 
 

1221 lines
31 KiB

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