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.
 
 
 
 

478 lines
12 KiB

  1. package koushinbase
  2. import (
  3. "fmt"
  4. "io/ioutil"
  5. "mime"
  6. "net/http"
  7. "net/url"
  8. "strconv"
  9. "strings"
  10. "git.sr.ht/~emersion/koushin"
  11. "github.com/emersion/go-imap"
  12. imapmove "github.com/emersion/go-imap-move"
  13. imapclient "github.com/emersion/go-imap/client"
  14. "github.com/emersion/go-message"
  15. "github.com/emersion/go-smtp"
  16. "github.com/labstack/echo/v4"
  17. )
  18. func registerRoutes(p *koushin.GoPlugin) {
  19. p.GET("/", func(ectx echo.Context) error {
  20. return ectx.Redirect(http.StatusFound, "/mailbox/INBOX")
  21. })
  22. p.GET("/mailbox/:mbox", handleGetMailbox)
  23. p.POST("/mailbox/:mbox", handleGetMailbox)
  24. p.GET("/message/:mbox/:uid", func(ectx echo.Context) error {
  25. ctx := ectx.(*koushin.Context)
  26. return handleGetPart(ctx, false)
  27. })
  28. p.GET("/message/:mbox/:uid/raw", func(ectx echo.Context) error {
  29. ctx := ectx.(*koushin.Context)
  30. return handleGetPart(ctx, true)
  31. })
  32. p.GET("/login", handleLogin)
  33. p.POST("/login", handleLogin)
  34. p.GET("/logout", handleLogout)
  35. p.GET("/compose", handleCompose)
  36. p.POST("/compose", handleCompose)
  37. p.GET("/message/:mbox/:uid/reply", handleCompose)
  38. p.POST("/message/:mbox/:uid/reply", handleCompose)
  39. p.POST("/message/:mbox/:uid/move", handleMove)
  40. p.POST("/message/:mbox/:uid/delete", handleDelete)
  41. p.POST("/message/:mbox/:uid/flag", handleSetFlags)
  42. }
  43. type MailboxRenderData struct {
  44. koushin.BaseRenderData
  45. Mailbox *imap.MailboxStatus
  46. Mailboxes []*imap.MailboxInfo
  47. Messages []IMAPMessage
  48. PrevPage, NextPage int
  49. Query string
  50. }
  51. func handleGetMailbox(ectx echo.Context) error {
  52. ctx := ectx.(*koushin.Context)
  53. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  54. if err != nil {
  55. return echo.NewHTTPError(http.StatusBadRequest, err)
  56. }
  57. page := 0
  58. if pageStr := ctx.QueryParam("page"); pageStr != "" {
  59. var err error
  60. if page, err = strconv.Atoi(pageStr); err != nil || page < 0 {
  61. return echo.NewHTTPError(http.StatusBadRequest, "invalid page index")
  62. }
  63. }
  64. query := ctx.FormValue("query")
  65. var mailboxes []*imap.MailboxInfo
  66. var msgs []IMAPMessage
  67. var mbox *imap.MailboxStatus
  68. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  69. var err error
  70. if mailboxes, err = listMailboxes(c); err != nil {
  71. return err
  72. }
  73. if query != "" {
  74. msgs, err = searchMessages(c, mboxName, query)
  75. } else {
  76. msgs, err = listMessages(c, mboxName, page)
  77. }
  78. if err != nil {
  79. return err
  80. }
  81. mbox = c.Mailbox()
  82. return nil
  83. })
  84. if err != nil {
  85. return err
  86. }
  87. prevPage, nextPage := -1, -1
  88. if query == "" {
  89. // TODO: paging for search
  90. if page > 0 {
  91. prevPage = page - 1
  92. }
  93. if (page+1)*messagesPerPage < int(mbox.Messages) {
  94. nextPage = page + 1
  95. }
  96. }
  97. return ctx.Render(http.StatusOK, "mailbox.html", &MailboxRenderData{
  98. BaseRenderData: *koushin.NewBaseRenderData(ctx),
  99. Mailbox: mbox,
  100. Mailboxes: mailboxes,
  101. Messages: msgs,
  102. PrevPage: prevPage,
  103. NextPage: nextPage,
  104. Query: query,
  105. })
  106. }
  107. func handleLogin(ectx echo.Context) error {
  108. ctx := ectx.(*koushin.Context)
  109. username := ctx.FormValue("username")
  110. password := ctx.FormValue("password")
  111. if username != "" && password != "" {
  112. s, err := ctx.Server.Sessions.Put(username, password)
  113. if err != nil {
  114. if _, ok := err.(koushin.AuthError); ok {
  115. return ctx.Render(http.StatusOK, "login.html", nil)
  116. }
  117. return fmt.Errorf("failed to put connection in pool: %v", err)
  118. }
  119. ctx.SetSession(s)
  120. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  121. }
  122. return ctx.Render(http.StatusOK, "login.html", koushin.NewBaseRenderData(ctx))
  123. }
  124. func handleLogout(ectx echo.Context) error {
  125. ctx := ectx.(*koushin.Context)
  126. ctx.Session.Close()
  127. ctx.SetSession(nil)
  128. return ctx.Redirect(http.StatusFound, "/login")
  129. }
  130. type MessageRenderData struct {
  131. koushin.BaseRenderData
  132. Mailboxes []*imap.MailboxInfo
  133. Mailbox *imap.MailboxStatus
  134. Message *IMAPMessage
  135. Body string
  136. PartPath string
  137. MailboxPage int
  138. Flags map[string]bool
  139. }
  140. func handleGetPart(ctx *koushin.Context, raw bool) error {
  141. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  142. if err != nil {
  143. return echo.NewHTTPError(http.StatusBadRequest, err)
  144. }
  145. partPathString := ctx.QueryParam("part")
  146. partPath, err := parsePartPath(partPathString)
  147. if err != nil {
  148. return echo.NewHTTPError(http.StatusBadRequest, err)
  149. }
  150. var mailboxes []*imap.MailboxInfo
  151. var msg *IMAPMessage
  152. var part *message.Entity
  153. var mbox *imap.MailboxStatus
  154. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  155. var err error
  156. if mailboxes, err = listMailboxes(c); err != nil {
  157. return err
  158. }
  159. if msg, part, err = getMessagePart(c, mboxName, uid, partPath); err != nil {
  160. return err
  161. }
  162. mbox = c.Mailbox()
  163. return nil
  164. })
  165. if err != nil {
  166. return err
  167. }
  168. mimeType, _, err := part.Header.ContentType()
  169. if err != nil {
  170. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  171. }
  172. if len(partPath) == 0 {
  173. mimeType = "message/rfc822"
  174. }
  175. if raw {
  176. disp, dispParams, _ := part.Header.ContentDisposition()
  177. filename := dispParams["filename"]
  178. // TODO: set Content-Length if possible
  179. if !strings.EqualFold(mimeType, "text/plain") || strings.EqualFold(disp, "attachment") {
  180. dispParams := make(map[string]string)
  181. if filename != "" {
  182. dispParams["filename"] = filename
  183. }
  184. disp := mime.FormatMediaType("attachment", dispParams)
  185. ctx.Response().Header().Set("Content-Disposition", disp)
  186. }
  187. return ctx.Stream(http.StatusOK, mimeType, part.Body)
  188. }
  189. var body string
  190. if strings.HasPrefix(strings.ToLower(mimeType), "text/") {
  191. b, err := ioutil.ReadAll(part.Body)
  192. if err != nil {
  193. return fmt.Errorf("failed to read part body: %v", err)
  194. }
  195. body = string(b)
  196. }
  197. flags := make(map[string]bool)
  198. for _, f := range mbox.PermanentFlags {
  199. f = imap.CanonicalFlag(f)
  200. if f == imap.TryCreateFlag {
  201. continue
  202. }
  203. flags[f] = msg.HasFlag(f)
  204. }
  205. return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{
  206. BaseRenderData: *koushin.NewBaseRenderData(ctx),
  207. Mailboxes: mailboxes,
  208. Mailbox: mbox,
  209. Message: msg,
  210. Body: body,
  211. PartPath: partPathString,
  212. MailboxPage: int(mbox.Messages-msg.SeqNum) / messagesPerPage,
  213. Flags: flags,
  214. })
  215. }
  216. type ComposeRenderData struct {
  217. koushin.BaseRenderData
  218. Message *OutgoingMessage
  219. }
  220. func handleCompose(ectx echo.Context) error {
  221. ctx := ectx.(*koushin.Context)
  222. var msg OutgoingMessage
  223. if strings.ContainsRune(ctx.Session.Username(), '@') {
  224. msg.From = ctx.Session.Username()
  225. }
  226. msg.To = strings.Split(ctx.QueryParam("to"), ",")
  227. msg.Subject = ctx.QueryParam("subject")
  228. msg.Text = ctx.QueryParam("body")
  229. msg.InReplyTo = ctx.QueryParam("in-reply-to")
  230. if ctx.Request().Method == http.MethodGet && ctx.Param("uid") != "" {
  231. // This is a reply
  232. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  233. if err != nil {
  234. return echo.NewHTTPError(http.StatusBadRequest, err)
  235. }
  236. partPath, err := parsePartPath(ctx.QueryParam("part"))
  237. if err != nil {
  238. return echo.NewHTTPError(http.StatusBadRequest, err)
  239. }
  240. var inReplyTo *IMAPMessage
  241. var part *message.Entity
  242. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  243. var err error
  244. inReplyTo, part, err = getMessagePart(c, mboxName, uid, partPath)
  245. return err
  246. })
  247. if err != nil {
  248. return err
  249. }
  250. mimeType, _, err := part.Header.ContentType()
  251. if err != nil {
  252. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  253. }
  254. if !strings.HasPrefix(strings.ToLower(mimeType), "text/") {
  255. err := fmt.Errorf("cannot reply to \"%v\" part", mimeType)
  256. return echo.NewHTTPError(http.StatusBadRequest, err)
  257. }
  258. msg.Text, err = quote(part.Body)
  259. if err != nil {
  260. return err
  261. }
  262. msg.InReplyTo = inReplyTo.Envelope.MessageId
  263. // TODO: populate From from known user addresses and inReplyTo.Envelope.To
  264. replyTo := inReplyTo.Envelope.ReplyTo
  265. if len(replyTo) == 0 {
  266. replyTo = inReplyTo.Envelope.From
  267. }
  268. if len(replyTo) > 0 {
  269. msg.To = make([]string, len(replyTo))
  270. for i, to := range replyTo {
  271. msg.To[i] = to.Address()
  272. }
  273. }
  274. msg.Subject = inReplyTo.Envelope.Subject
  275. if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") {
  276. msg.Subject = "Re: " + msg.Subject
  277. }
  278. }
  279. if ctx.Request().Method == http.MethodPost {
  280. msg.From = ctx.FormValue("from")
  281. msg.To = parseAddressList(ctx.FormValue("to"))
  282. msg.Subject = ctx.FormValue("subject")
  283. msg.Text = ctx.FormValue("text")
  284. msg.InReplyTo = ctx.FormValue("in_reply_to")
  285. form, err := ctx.MultipartForm()
  286. if err != nil {
  287. return fmt.Errorf("failed to get multipart form: %v", err)
  288. }
  289. msg.Attachments = form.File["attachments"]
  290. err = ctx.Session.DoSMTP(func(c *smtp.Client) error {
  291. return sendMessage(c, &msg)
  292. })
  293. if err != nil {
  294. if _, ok := err.(koushin.AuthError); ok {
  295. return echo.NewHTTPError(http.StatusForbidden, err)
  296. }
  297. return err
  298. }
  299. // TODO: append to IMAP Sent mailbox
  300. // TODO: add \Answered flag to original IMAP message
  301. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  302. }
  303. return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{
  304. BaseRenderData: *koushin.NewBaseRenderData(ctx),
  305. Message: &msg,
  306. })
  307. }
  308. func handleMove(ectx echo.Context) error {
  309. ctx := ectx.(*koushin.Context)
  310. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  311. if err != nil {
  312. return echo.NewHTTPError(http.StatusBadRequest, err)
  313. }
  314. to := ctx.FormValue("to")
  315. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  316. mc := imapmove.NewClient(c)
  317. if err := ensureMailboxSelected(c, mboxName); err != nil {
  318. return err
  319. }
  320. var seqSet imap.SeqSet
  321. seqSet.AddNum(uid)
  322. if err := mc.UidMoveWithFallback(&seqSet, to); err != nil {
  323. return fmt.Errorf("failed to move message: %v", err)
  324. }
  325. // TODO: get the UID of the message in the destination mailbox with UIDPLUS
  326. return nil
  327. })
  328. if err != nil {
  329. return err
  330. }
  331. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(to)))
  332. }
  333. func handleDelete(ectx echo.Context) error {
  334. ctx := ectx.(*koushin.Context)
  335. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  336. if err != nil {
  337. return echo.NewHTTPError(http.StatusBadRequest, err)
  338. }
  339. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  340. if err := ensureMailboxSelected(c, mboxName); err != nil {
  341. return err
  342. }
  343. var seqSet imap.SeqSet
  344. seqSet.AddNum(uid)
  345. item := imap.FormatFlagsOp(imap.AddFlags, true)
  346. flags := []interface{}{imap.DeletedFlag}
  347. if err := c.UidStore(&seqSet, item, flags, nil); err != nil {
  348. return fmt.Errorf("failed to add deleted flag: %v", err)
  349. }
  350. if err := c.Expunge(nil); err != nil {
  351. return fmt.Errorf("failed to expunge mailbox: %v", err)
  352. }
  353. // Deleting a message invalidates our cached message count
  354. // TODO: listen to async updates instead
  355. if _, err := c.Select(mboxName, false); err != nil {
  356. return fmt.Errorf("failed to select mailbox: %v", err)
  357. }
  358. return nil
  359. })
  360. if err != nil {
  361. return err
  362. }
  363. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
  364. }
  365. func handleSetFlags(ectx echo.Context) error {
  366. ctx := ectx.(*koushin.Context)
  367. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  368. if err != nil {
  369. return echo.NewHTTPError(http.StatusBadRequest, err)
  370. }
  371. form, err := ctx.FormParams()
  372. if err != nil {
  373. return echo.NewHTTPError(http.StatusBadRequest, err)
  374. }
  375. flags, ok := form["flags"]
  376. if !ok {
  377. return echo.NewHTTPError(http.StatusBadRequest, "missing 'flags' form values")
  378. }
  379. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  380. if err := ensureMailboxSelected(c, mboxName); err != nil {
  381. return err
  382. }
  383. var seqSet imap.SeqSet
  384. seqSet.AddNum(uid)
  385. storeItems := make([]interface{}, len(flags))
  386. for i, f := range flags {
  387. storeItems[i] = f
  388. }
  389. item := imap.FormatFlagsOp(imap.SetFlags, true)
  390. if err := c.UidStore(&seqSet, item, storeItems, nil); err != nil {
  391. return fmt.Errorf("failed to add deleted flag: %v", err)
  392. }
  393. return nil
  394. })
  395. if err != nil {
  396. return err
  397. }
  398. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/message/%v/%v", url.PathEscape(mboxName), uid))
  399. }