A webmail client. Forked from https://git.sr.ht/~migadu/alps
Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

749 linhas
19 KiB

  1. package koushinbase
  2. import (
  3. "bytes"
  4. "fmt"
  5. "io"
  6. "io/ioutil"
  7. "mime"
  8. "net/http"
  9. "net/url"
  10. "strconv"
  11. "strings"
  12. "git.sr.ht/~emersion/koushin"
  13. "github.com/emersion/go-imap"
  14. imapmove "github.com/emersion/go-imap-move"
  15. imapclient "github.com/emersion/go-imap/client"
  16. "github.com/emersion/go-message"
  17. "github.com/emersion/go-message/mail"
  18. "github.com/emersion/go-smtp"
  19. "github.com/labstack/echo/v4"
  20. )
  21. func registerRoutes(p *koushin.GoPlugin) {
  22. p.GET("/", func(ctx *koushin.Context) error {
  23. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  24. })
  25. p.GET("/mailbox/:mbox", handleGetMailbox)
  26. p.POST("/mailbox/:mbox", handleGetMailbox)
  27. p.GET("/message/:mbox/:uid", func(ctx *koushin.Context) error {
  28. return handleGetPart(ctx, false)
  29. })
  30. p.GET("/message/:mbox/:uid/raw", func(ctx *koushin.Context) error {
  31. return handleGetPart(ctx, true)
  32. })
  33. p.GET("/login", handleLogin)
  34. p.POST("/login", handleLogin)
  35. p.GET("/logout", handleLogout)
  36. p.GET("/compose", handleComposeNew)
  37. p.POST("/compose", handleComposeNew)
  38. p.GET("/message/:mbox/:uid/reply", handleReply)
  39. p.POST("/message/:mbox/:uid/reply", handleReply)
  40. p.GET("/message/:mbox/:uid/edit", handleEdit)
  41. p.POST("/message/:mbox/:uid/edit", handleEdit)
  42. p.POST("/message/:mbox/:uid/move", handleMove)
  43. p.POST("/message/:mbox/:uid/delete", handleDelete)
  44. p.POST("/message/:mbox/:uid/flag", handleSetFlags)
  45. p.GET("/settings", handleSettings)
  46. p.POST("/settings", handleSettings)
  47. }
  48. type MailboxRenderData struct {
  49. koushin.BaseRenderData
  50. Mailbox *imap.MailboxStatus
  51. Mailboxes []*imap.MailboxInfo
  52. Messages []IMAPMessage
  53. PrevPage, NextPage int
  54. Query string
  55. }
  56. func handleGetMailbox(ctx *koushin.Context) error {
  57. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  58. if err != nil {
  59. return echo.NewHTTPError(http.StatusBadRequest, err)
  60. }
  61. page := 0
  62. if pageStr := ctx.QueryParam("page"); pageStr != "" {
  63. var err error
  64. if page, err = strconv.Atoi(pageStr); err != nil || page < 0 {
  65. return echo.NewHTTPError(http.StatusBadRequest, "invalid page index")
  66. }
  67. }
  68. settings, err := loadSettings(ctx.Session.Store())
  69. if err != nil {
  70. return err
  71. }
  72. messagesPerPage := settings.MessagesPerPage
  73. query := ctx.QueryParam("query")
  74. var mailboxes []*imap.MailboxInfo
  75. var msgs []IMAPMessage
  76. var mbox *imap.MailboxStatus
  77. var total int
  78. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  79. var err error
  80. if mailboxes, err = listMailboxes(c); err != nil {
  81. return err
  82. }
  83. if query != "" {
  84. msgs, total, err = searchMessages(c, mboxName, query, page, messagesPerPage)
  85. } else {
  86. msgs, err = listMessages(c, mboxName, page, messagesPerPage)
  87. }
  88. if err != nil {
  89. return err
  90. }
  91. mbox = c.Mailbox()
  92. return nil
  93. })
  94. if err != nil {
  95. return err
  96. }
  97. prevPage, nextPage := -1, -1
  98. if query != "" {
  99. if page > 0 {
  100. prevPage = page - 1
  101. }
  102. if (page+1)*messagesPerPage <= total {
  103. nextPage = page + 1
  104. }
  105. } else {
  106. if page > 0 {
  107. prevPage = page - 1
  108. }
  109. if (page+1)*messagesPerPage < int(mbox.Messages) {
  110. nextPage = page + 1
  111. }
  112. }
  113. return ctx.Render(http.StatusOK, "mailbox.html", &MailboxRenderData{
  114. BaseRenderData: *koushin.NewBaseRenderData(ctx),
  115. Mailbox: mbox,
  116. Mailboxes: mailboxes,
  117. Messages: msgs,
  118. PrevPage: prevPage,
  119. NextPage: nextPage,
  120. Query: query,
  121. })
  122. }
  123. func handleLogin(ctx *koushin.Context) error {
  124. username := ctx.FormValue("username")
  125. password := ctx.FormValue("password")
  126. if username != "" && password != "" {
  127. s, err := ctx.Server.Sessions.Put(username, password)
  128. if err != nil {
  129. if _, ok := err.(koushin.AuthError); ok {
  130. return ctx.Render(http.StatusOK, "login.html", nil)
  131. }
  132. return fmt.Errorf("failed to put connection in pool: %v", err)
  133. }
  134. ctx.SetSession(s)
  135. if path := ctx.QueryParam("next"); path != "" && path[0] == '/' && path != "/login" {
  136. return ctx.Redirect(http.StatusFound, path)
  137. }
  138. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  139. }
  140. return ctx.Render(http.StatusOK, "login.html", koushin.NewBaseRenderData(ctx))
  141. }
  142. func handleLogout(ctx *koushin.Context) error {
  143. ctx.Session.Close()
  144. ctx.SetSession(nil)
  145. return ctx.Redirect(http.StatusFound, "/login")
  146. }
  147. type MessageRenderData struct {
  148. koushin.BaseRenderData
  149. Mailboxes []*imap.MailboxInfo
  150. Mailbox *imap.MailboxStatus
  151. Message *IMAPMessage
  152. View interface{}
  153. PartPath string
  154. MailboxPage int
  155. Flags map[string]bool
  156. }
  157. func handleGetPart(ctx *koushin.Context, raw bool) error {
  158. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  159. if err != nil {
  160. return echo.NewHTTPError(http.StatusBadRequest, err)
  161. }
  162. partPathString := ctx.QueryParam("part")
  163. partPath, err := parsePartPath(partPathString)
  164. if err != nil {
  165. return echo.NewHTTPError(http.StatusBadRequest, err)
  166. }
  167. settings, err := loadSettings(ctx.Session.Store())
  168. if err != nil {
  169. return err
  170. }
  171. messagesPerPage := settings.MessagesPerPage
  172. var mailboxes []*imap.MailboxInfo
  173. var msg *IMAPMessage
  174. var part *message.Entity
  175. var mbox *imap.MailboxStatus
  176. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  177. var err error
  178. if mailboxes, err = listMailboxes(c); err != nil {
  179. return err
  180. }
  181. if msg, part, err = getMessagePart(c, mboxName, uid, partPath); err != nil {
  182. return err
  183. }
  184. mbox = c.Mailbox()
  185. return nil
  186. })
  187. if err != nil {
  188. return err
  189. }
  190. mimeType, _, err := part.Header.ContentType()
  191. if err != nil {
  192. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  193. }
  194. if len(partPath) == 0 {
  195. mimeType = "message/rfc822"
  196. }
  197. if raw {
  198. ctx.Response().Header().Set("Content-Type", mimeType)
  199. disp, dispParams, _ := part.Header.ContentDisposition()
  200. filename := dispParams["filename"]
  201. if len(partPath) == 0 {
  202. filename = msg.Envelope.Subject + ".eml"
  203. }
  204. // TODO: set Content-Length if possible
  205. // Be careful not to serve types like text/html as inline
  206. if !strings.EqualFold(mimeType, "text/plain") || strings.EqualFold(disp, "attachment") {
  207. dispParams := make(map[string]string)
  208. if filename != "" {
  209. dispParams["filename"] = filename
  210. }
  211. disp := mime.FormatMediaType("attachment", dispParams)
  212. ctx.Response().Header().Set("Content-Disposition", disp)
  213. }
  214. if len(partPath) == 0 {
  215. return part.WriteTo(ctx.Response())
  216. } else {
  217. return ctx.Stream(http.StatusOK, mimeType, part.Body)
  218. }
  219. }
  220. view, err := viewMessagePart(ctx, msg, part)
  221. if err == ErrViewUnsupported {
  222. view = nil
  223. }
  224. flags := make(map[string]bool)
  225. for _, f := range mbox.PermanentFlags {
  226. f = imap.CanonicalFlag(f)
  227. if f == imap.TryCreateFlag {
  228. continue
  229. }
  230. flags[f] = msg.HasFlag(f)
  231. }
  232. return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{
  233. BaseRenderData: *koushin.NewBaseRenderData(ctx),
  234. Mailboxes: mailboxes,
  235. Mailbox: mbox,
  236. Message: msg,
  237. View: view,
  238. PartPath: partPathString,
  239. MailboxPage: int(mbox.Messages-msg.SeqNum) / messagesPerPage,
  240. Flags: flags,
  241. })
  242. }
  243. type ComposeRenderData struct {
  244. koushin.BaseRenderData
  245. Message *OutgoingMessage
  246. }
  247. type messagePath struct {
  248. Mailbox string
  249. Uid uint32
  250. }
  251. // Send message, append it to the Sent mailbox, mark the original message as
  252. // answered
  253. func submitCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePath, inReplyTo *messagePath) error {
  254. err := ctx.Session.DoSMTP(func(c *smtp.Client) error {
  255. return sendMessage(c, msg)
  256. })
  257. if err != nil {
  258. if _, ok := err.(koushin.AuthError); ok {
  259. return echo.NewHTTPError(http.StatusForbidden, err)
  260. }
  261. return fmt.Errorf("failed to send message: %v", err)
  262. }
  263. if inReplyTo != nil {
  264. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  265. return markMessageAnswered(c, inReplyTo.Mailbox, inReplyTo.Uid)
  266. })
  267. if err != nil {
  268. return fmt.Errorf("failed to mark original message as answered: %v", err)
  269. }
  270. }
  271. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  272. if _, err := appendMessage(c, msg, mailboxSent); err != nil {
  273. return err
  274. }
  275. if draft != nil {
  276. if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
  277. return err
  278. }
  279. }
  280. return nil
  281. })
  282. if err != nil {
  283. return fmt.Errorf("failed to save message to Sent mailbox: %v", err)
  284. }
  285. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  286. }
  287. func handleCompose(ctx *koushin.Context, msg *OutgoingMessage, draft *messagePath, inReplyTo *messagePath) error {
  288. if msg.From == "" && strings.ContainsRune(ctx.Session.Username(), '@') {
  289. msg.From = ctx.Session.Username()
  290. }
  291. if ctx.Request().Method == http.MethodPost {
  292. formParams, err := ctx.FormParams()
  293. if err != nil {
  294. return fmt.Errorf("failed to parse form: %v", err)
  295. }
  296. _, saveAsDraft := formParams["save_as_draft"]
  297. msg.From = ctx.FormValue("from")
  298. msg.To = parseAddressList(ctx.FormValue("to"))
  299. msg.Subject = ctx.FormValue("subject")
  300. msg.Text = ctx.FormValue("text")
  301. msg.InReplyTo = ctx.FormValue("in_reply_to")
  302. form, err := ctx.MultipartForm()
  303. if err != nil {
  304. return fmt.Errorf("failed to get multipart form: %v", err)
  305. }
  306. // Fetch previous attachments from draft
  307. if draft != nil {
  308. for _, s := range form.Value["prev_attachments"] {
  309. path, err := parsePartPath(s)
  310. if err != nil {
  311. return fmt.Errorf("failed to parse draft attachment path: %v", err)
  312. }
  313. var part *message.Entity
  314. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  315. var err error
  316. _, part, err = getMessagePart(c, draft.Mailbox, draft.Uid, path)
  317. return err
  318. })
  319. if err != nil {
  320. return fmt.Errorf("failed to fetch attachment from draft: %v", err)
  321. }
  322. var buf bytes.Buffer
  323. if _, err := io.Copy(&buf, part.Body); err != nil {
  324. return fmt.Errorf("failed to copy attachment from draft: %v", err)
  325. }
  326. h := mail.AttachmentHeader{part.Header}
  327. mimeType, _, _ := h.ContentType()
  328. filename, _ := h.Filename()
  329. msg.Attachments = append(msg.Attachments, &imapAttachment{
  330. Mailbox: draft.Mailbox,
  331. Uid: draft.Uid,
  332. Node: &IMAPPartNode{
  333. Path: path,
  334. MIMEType: mimeType,
  335. Filename: filename,
  336. },
  337. Body: buf.Bytes(),
  338. })
  339. }
  340. } else if len(form.Value["prev_attachments"]) > 0 {
  341. return fmt.Errorf("previous attachments specified but no draft available")
  342. }
  343. for _, fh := range form.File["attachments"] {
  344. msg.Attachments = append(msg.Attachments, &formAttachment{fh})
  345. }
  346. if saveAsDraft {
  347. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  348. copied, err := appendMessage(c, msg, mailboxDrafts)
  349. if err != nil {
  350. return err
  351. }
  352. if !copied {
  353. return fmt.Errorf("no Draft mailbox found")
  354. }
  355. if draft != nil {
  356. if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
  357. return err
  358. }
  359. }
  360. return nil
  361. })
  362. if err != nil {
  363. return fmt.Errorf("failed to save message to Draft mailbox: %v", err)
  364. }
  365. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  366. } else {
  367. return submitCompose(ctx, msg, draft, inReplyTo)
  368. }
  369. }
  370. return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{
  371. BaseRenderData: *koushin.NewBaseRenderData(ctx),
  372. Message: msg,
  373. })
  374. }
  375. func handleComposeNew(ctx *koushin.Context) error {
  376. // These are common mailto URL query parameters
  377. return handleCompose(ctx, &OutgoingMessage{
  378. To: strings.Split(ctx.QueryParam("to"), ","),
  379. Subject: ctx.QueryParam("subject"),
  380. Text: ctx.QueryParam("body"),
  381. InReplyTo: ctx.QueryParam("in-reply-to"),
  382. }, nil, nil)
  383. }
  384. func unwrapIMAPAddressList(addrs []*imap.Address) []string {
  385. l := make([]string, len(addrs))
  386. for i, addr := range addrs {
  387. l[i] = addr.Address()
  388. }
  389. return l
  390. }
  391. func handleReply(ctx *koushin.Context) error {
  392. var inReplyToPath messagePath
  393. var err error
  394. inReplyToPath.Mailbox, inReplyToPath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  395. if err != nil {
  396. return echo.NewHTTPError(http.StatusBadRequest, err)
  397. }
  398. var msg OutgoingMessage
  399. if ctx.Request().Method == http.MethodGet {
  400. // Populate fields from original message
  401. partPath, err := parsePartPath(ctx.QueryParam("part"))
  402. if err != nil {
  403. return echo.NewHTTPError(http.StatusBadRequest, err)
  404. }
  405. var inReplyTo *IMAPMessage
  406. var part *message.Entity
  407. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  408. var err error
  409. inReplyTo, part, err = getMessagePart(c, inReplyToPath.Mailbox, inReplyToPath.Uid, partPath)
  410. return err
  411. })
  412. if err != nil {
  413. return err
  414. }
  415. mimeType, _, err := part.Header.ContentType()
  416. if err != nil {
  417. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  418. }
  419. if !strings.HasPrefix(strings.ToLower(mimeType), "text/") {
  420. err := fmt.Errorf("cannot reply to %q part", mimeType)
  421. return echo.NewHTTPError(http.StatusBadRequest, err)
  422. }
  423. // TODO: strip HTML tags if text/html
  424. msg.Text, err = quote(part.Body)
  425. if err != nil {
  426. return err
  427. }
  428. msg.InReplyTo = inReplyTo.Envelope.MessageId
  429. // TODO: populate From from known user addresses and inReplyTo.Envelope.To
  430. replyTo := inReplyTo.Envelope.ReplyTo
  431. if len(replyTo) == 0 {
  432. replyTo = inReplyTo.Envelope.From
  433. }
  434. msg.To = unwrapIMAPAddressList(replyTo)
  435. msg.Subject = inReplyTo.Envelope.Subject
  436. if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") {
  437. msg.Subject = "Re: " + msg.Subject
  438. }
  439. }
  440. return handleCompose(ctx, &msg, nil, &inReplyToPath)
  441. }
  442. func handleEdit(ctx *koushin.Context) error {
  443. var sourcePath messagePath
  444. var err error
  445. sourcePath.Mailbox, sourcePath.Uid, err = parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  446. if err != nil {
  447. return echo.NewHTTPError(http.StatusBadRequest, err)
  448. }
  449. // TODO: somehow get the path to the In-Reply-To message (with a search?)
  450. var msg OutgoingMessage
  451. if ctx.Request().Method == http.MethodGet {
  452. // Populate fields from source message
  453. partPath, err := parsePartPath(ctx.QueryParam("part"))
  454. if err != nil {
  455. return echo.NewHTTPError(http.StatusBadRequest, err)
  456. }
  457. var source *IMAPMessage
  458. var part *message.Entity
  459. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  460. var err error
  461. source, part, err = getMessagePart(c, sourcePath.Mailbox, sourcePath.Uid, partPath)
  462. return err
  463. })
  464. if err != nil {
  465. return err
  466. }
  467. mimeType, _, err := part.Header.ContentType()
  468. if err != nil {
  469. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  470. }
  471. if !strings.EqualFold(mimeType, "text/plain") {
  472. err := fmt.Errorf("cannot edit %q part", mimeType)
  473. return echo.NewHTTPError(http.StatusBadRequest, err)
  474. }
  475. b, err := ioutil.ReadAll(part.Body)
  476. if err != nil {
  477. return fmt.Errorf("failed to read part body: %v", err)
  478. }
  479. msg.Text = string(b)
  480. if len(source.Envelope.From) > 0 {
  481. msg.From = source.Envelope.From[0].Address()
  482. }
  483. msg.To = unwrapIMAPAddressList(source.Envelope.To)
  484. msg.Subject = source.Envelope.Subject
  485. msg.InReplyTo = source.Envelope.InReplyTo
  486. // TODO: preserve Message-Id
  487. attachments := source.Attachments()
  488. for i := range attachments {
  489. att := &attachments[i]
  490. // No need to populate attachment body here, we just need the
  491. // metadata
  492. msg.Attachments = append(msg.Attachments, &imapAttachment{
  493. Mailbox: sourcePath.Mailbox,
  494. Uid: sourcePath.Uid,
  495. Node: att,
  496. })
  497. }
  498. }
  499. return handleCompose(ctx, &msg, &sourcePath, nil)
  500. }
  501. func handleMove(ctx *koushin.Context) error {
  502. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  503. if err != nil {
  504. return echo.NewHTTPError(http.StatusBadRequest, err)
  505. }
  506. to := ctx.FormValue("to")
  507. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  508. mc := imapmove.NewClient(c)
  509. if err := ensureMailboxSelected(c, mboxName); err != nil {
  510. return err
  511. }
  512. var seqSet imap.SeqSet
  513. seqSet.AddNum(uid)
  514. if err := mc.UidMoveWithFallback(&seqSet, to); err != nil {
  515. return fmt.Errorf("failed to move message: %v", err)
  516. }
  517. // TODO: get the UID of the message in the destination mailbox with UIDPLUS
  518. return nil
  519. })
  520. if err != nil {
  521. return err
  522. }
  523. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(to)))
  524. }
  525. func handleDelete(ctx *koushin.Context) error {
  526. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  527. if err != nil {
  528. return echo.NewHTTPError(http.StatusBadRequest, err)
  529. }
  530. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  531. if err := ensureMailboxSelected(c, mboxName); err != nil {
  532. return err
  533. }
  534. var seqSet imap.SeqSet
  535. seqSet.AddNum(uid)
  536. item := imap.FormatFlagsOp(imap.AddFlags, true)
  537. flags := []interface{}{imap.DeletedFlag}
  538. if err := c.UidStore(&seqSet, item, flags, nil); err != nil {
  539. return fmt.Errorf("failed to add deleted flag: %v", err)
  540. }
  541. if err := c.Expunge(nil); err != nil {
  542. return fmt.Errorf("failed to expunge mailbox: %v", err)
  543. }
  544. // Deleting a message invalidates our cached message count
  545. // TODO: listen to async updates instead
  546. if _, err := c.Select(mboxName, false); err != nil {
  547. return fmt.Errorf("failed to select mailbox: %v", err)
  548. }
  549. return nil
  550. })
  551. if err != nil {
  552. return err
  553. }
  554. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
  555. }
  556. func handleSetFlags(ctx *koushin.Context) error {
  557. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  558. if err != nil {
  559. return echo.NewHTTPError(http.StatusBadRequest, err)
  560. }
  561. form, err := ctx.FormParams()
  562. if err != nil {
  563. return echo.NewHTTPError(http.StatusBadRequest, err)
  564. }
  565. flags, ok := form["flags"]
  566. if !ok {
  567. return echo.NewHTTPError(http.StatusBadRequest, "missing 'flags' form values")
  568. }
  569. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  570. if err := ensureMailboxSelected(c, mboxName); err != nil {
  571. return err
  572. }
  573. var seqSet imap.SeqSet
  574. seqSet.AddNum(uid)
  575. storeItems := make([]interface{}, len(flags))
  576. for i, f := range flags {
  577. storeItems[i] = f
  578. }
  579. item := imap.FormatFlagsOp(imap.SetFlags, true)
  580. if err := c.UidStore(&seqSet, item, storeItems, nil); err != nil {
  581. return fmt.Errorf("failed to add deleted flag: %v", err)
  582. }
  583. return nil
  584. })
  585. if err != nil {
  586. return err
  587. }
  588. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/message/%v/%v", url.PathEscape(mboxName), uid))
  589. }
  590. const settingsKey = "base.settings"
  591. const maxMessagesPerPage = 100
  592. type Settings struct {
  593. MessagesPerPage int
  594. }
  595. func loadSettings(s koushin.Store) (*Settings, error) {
  596. settings := &Settings{
  597. MessagesPerPage: 50,
  598. }
  599. if err := s.Get(settingsKey, settings); err != nil && err != koushin.ErrNoStoreEntry {
  600. return nil, err
  601. }
  602. if err := settings.check(); err != nil {
  603. return nil, err
  604. }
  605. return settings, nil
  606. }
  607. func (s *Settings) check() error {
  608. if s.MessagesPerPage <= 0 || s.MessagesPerPage > maxMessagesPerPage {
  609. return fmt.Errorf("messages per page out of bounds: %v", s.MessagesPerPage)
  610. }
  611. return nil
  612. }
  613. type SettingsRenderData struct {
  614. koushin.BaseRenderData
  615. Settings *Settings
  616. }
  617. func handleSettings(ctx *koushin.Context) error {
  618. settings, err := loadSettings(ctx.Session.Store())
  619. if err != nil {
  620. return fmt.Errorf("failed to load settings: %v", err)
  621. }
  622. if ctx.Request().Method == http.MethodPost {
  623. settings.MessagesPerPage, err = strconv.Atoi(ctx.FormValue("messages_per_page"))
  624. if err != nil {
  625. return echo.NewHTTPError(http.StatusBadRequest, "invalid messages per page: %v", err)
  626. }
  627. if err := settings.check(); err != nil {
  628. return echo.NewHTTPError(http.StatusBadRequest, err)
  629. }
  630. if err := ctx.Session.Store().Put(settingsKey, settings); err != nil {
  631. return fmt.Errorf("failed to save settings: %v", err)
  632. }
  633. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  634. }
  635. return ctx.Render(http.StatusOK, "settings.html", &SettingsRenderData{
  636. BaseRenderData: *koushin.NewBaseRenderData(ctx),
  637. Settings: settings,
  638. })
  639. }