A webmail client. Forked from https://git.sr.ht/~migadu/alps
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

490 行
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 []byte
  205. if strings.HasPrefix(strings.ToLower(mimeType), "text/") {
  206. body, err = ioutil.ReadAll(part.Body)
  207. if err != nil {
  208. return fmt.Errorf("failed to read part body: %v", err)
  209. }
  210. }
  211. isHTML := false
  212. if strings.EqualFold(mimeType, "text/html") {
  213. body = sanitizeHTML(body)
  214. isHTML = true
  215. }
  216. flags := make(map[string]bool)
  217. for _, f := range mbox.PermanentFlags {
  218. f = imap.CanonicalFlag(f)
  219. if f == imap.TryCreateFlag {
  220. continue
  221. }
  222. flags[f] = msg.HasFlag(f)
  223. }
  224. return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{
  225. BaseRenderData: *koushin.NewBaseRenderData(ctx),
  226. Mailboxes: mailboxes,
  227. Mailbox: mbox,
  228. Message: msg,
  229. Body: string(body),
  230. IsHTML: isHTML,
  231. PartPath: partPathString,
  232. MailboxPage: int(mbox.Messages-msg.SeqNum) / messagesPerPage,
  233. Flags: flags,
  234. })
  235. }
  236. type ComposeRenderData struct {
  237. koushin.BaseRenderData
  238. Message *OutgoingMessage
  239. }
  240. func handleCompose(ctx *koushin.Context) error {
  241. var msg OutgoingMessage
  242. if strings.ContainsRune(ctx.Session.Username(), '@') {
  243. msg.From = ctx.Session.Username()
  244. }
  245. msg.To = strings.Split(ctx.QueryParam("to"), ",")
  246. msg.Subject = ctx.QueryParam("subject")
  247. msg.Text = ctx.QueryParam("body")
  248. msg.InReplyTo = ctx.QueryParam("in-reply-to")
  249. if ctx.Request().Method == http.MethodGet && ctx.Param("uid") != "" {
  250. // This is a reply
  251. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  252. if err != nil {
  253. return echo.NewHTTPError(http.StatusBadRequest, err)
  254. }
  255. partPath, err := parsePartPath(ctx.QueryParam("part"))
  256. if err != nil {
  257. return echo.NewHTTPError(http.StatusBadRequest, err)
  258. }
  259. var inReplyTo *IMAPMessage
  260. var part *message.Entity
  261. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  262. var err error
  263. inReplyTo, part, err = getMessagePart(c, mboxName, uid, partPath)
  264. return err
  265. })
  266. if err != nil {
  267. return err
  268. }
  269. mimeType, _, err := part.Header.ContentType()
  270. if err != nil {
  271. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  272. }
  273. if !strings.HasPrefix(strings.ToLower(mimeType), "text/") {
  274. err := fmt.Errorf("cannot reply to \"%v\" part", mimeType)
  275. return echo.NewHTTPError(http.StatusBadRequest, err)
  276. }
  277. msg.Text, err = quote(part.Body)
  278. if err != nil {
  279. return err
  280. }
  281. msg.InReplyTo = inReplyTo.Envelope.MessageId
  282. // TODO: populate From from known user addresses and inReplyTo.Envelope.To
  283. replyTo := inReplyTo.Envelope.ReplyTo
  284. if len(replyTo) == 0 {
  285. replyTo = inReplyTo.Envelope.From
  286. }
  287. if len(replyTo) > 0 {
  288. msg.To = make([]string, len(replyTo))
  289. for i, to := range replyTo {
  290. msg.To[i] = to.Address()
  291. }
  292. }
  293. msg.Subject = inReplyTo.Envelope.Subject
  294. if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") {
  295. msg.Subject = "Re: " + msg.Subject
  296. }
  297. }
  298. if ctx.Request().Method == http.MethodPost {
  299. msg.From = ctx.FormValue("from")
  300. msg.To = parseAddressList(ctx.FormValue("to"))
  301. msg.Subject = ctx.FormValue("subject")
  302. msg.Text = ctx.FormValue("text")
  303. msg.InReplyTo = ctx.FormValue("in_reply_to")
  304. form, err := ctx.MultipartForm()
  305. if err != nil {
  306. return fmt.Errorf("failed to get multipart form: %v", err)
  307. }
  308. msg.Attachments = form.File["attachments"]
  309. err = ctx.Session.DoSMTP(func(c *smtp.Client) error {
  310. return sendMessage(c, &msg)
  311. })
  312. if err != nil {
  313. if _, ok := err.(koushin.AuthError); ok {
  314. return echo.NewHTTPError(http.StatusForbidden, err)
  315. }
  316. return err
  317. }
  318. // TODO: append to IMAP Sent mailbox
  319. // TODO: add \Answered flag to original IMAP message
  320. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  321. }
  322. return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{
  323. BaseRenderData: *koushin.NewBaseRenderData(ctx),
  324. Message: &msg,
  325. })
  326. }
  327. func handleMove(ctx *koushin.Context) error {
  328. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  329. if err != nil {
  330. return echo.NewHTTPError(http.StatusBadRequest, err)
  331. }
  332. to := ctx.FormValue("to")
  333. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  334. mc := imapmove.NewClient(c)
  335. if err := ensureMailboxSelected(c, mboxName); err != nil {
  336. return err
  337. }
  338. var seqSet imap.SeqSet
  339. seqSet.AddNum(uid)
  340. if err := mc.UidMoveWithFallback(&seqSet, to); err != nil {
  341. return fmt.Errorf("failed to move message: %v", err)
  342. }
  343. // TODO: get the UID of the message in the destination mailbox with UIDPLUS
  344. return nil
  345. })
  346. if err != nil {
  347. return err
  348. }
  349. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(to)))
  350. }
  351. func handleDelete(ctx *koushin.Context) error {
  352. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  353. if err != nil {
  354. return echo.NewHTTPError(http.StatusBadRequest, err)
  355. }
  356. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  357. if err := ensureMailboxSelected(c, mboxName); err != nil {
  358. return err
  359. }
  360. var seqSet imap.SeqSet
  361. seqSet.AddNum(uid)
  362. item := imap.FormatFlagsOp(imap.AddFlags, true)
  363. flags := []interface{}{imap.DeletedFlag}
  364. if err := c.UidStore(&seqSet, item, flags, nil); err != nil {
  365. return fmt.Errorf("failed to add deleted flag: %v", err)
  366. }
  367. if err := c.Expunge(nil); err != nil {
  368. return fmt.Errorf("failed to expunge mailbox: %v", err)
  369. }
  370. // Deleting a message invalidates our cached message count
  371. // TODO: listen to async updates instead
  372. if _, err := c.Select(mboxName, false); err != nil {
  373. return fmt.Errorf("failed to select mailbox: %v", err)
  374. }
  375. return nil
  376. })
  377. if err != nil {
  378. return err
  379. }
  380. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/mailbox/%v", url.PathEscape(mboxName)))
  381. }
  382. func handleSetFlags(ctx *koushin.Context) error {
  383. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  384. if err != nil {
  385. return echo.NewHTTPError(http.StatusBadRequest, err)
  386. }
  387. form, err := ctx.FormParams()
  388. if err != nil {
  389. return echo.NewHTTPError(http.StatusBadRequest, err)
  390. }
  391. flags, ok := form["flags"]
  392. if !ok {
  393. return echo.NewHTTPError(http.StatusBadRequest, "missing 'flags' form values")
  394. }
  395. err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
  396. if err := ensureMailboxSelected(c, mboxName); err != nil {
  397. return err
  398. }
  399. var seqSet imap.SeqSet
  400. seqSet.AddNum(uid)
  401. storeItems := make([]interface{}, len(flags))
  402. for i, f := range flags {
  403. storeItems[i] = f
  404. }
  405. item := imap.FormatFlagsOp(imap.SetFlags, true)
  406. if err := c.UidStore(&seqSet, item, storeItems, nil); err != nil {
  407. return fmt.Errorf("failed to add deleted flag: %v", err)
  408. }
  409. return nil
  410. })
  411. if err != nil {
  412. return err
  413. }
  414. return ctx.Redirect(http.StatusFound, fmt.Sprintf("/message/%v/%v", url.PathEscape(mboxName), uid))
  415. }