A webmail client. Forked from https://git.sr.ht/~migadu/alps
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 

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