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.
 
 
 
 

1199 lines
31 KiB

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