A webmail client. Forked from https://git.sr.ht/~migadu/alps
Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 
 

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