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.
 
 
 
 

289 lines
7.0 KiB

  1. package koushin
  2. import (
  3. "fmt"
  4. "io/ioutil"
  5. "mime"
  6. "net/http"
  7. "net/url"
  8. "strconv"
  9. "strings"
  10. "github.com/emersion/go-imap"
  11. imapclient "github.com/emersion/go-imap/client"
  12. "github.com/emersion/go-message"
  13. "github.com/emersion/go-sasl"
  14. "github.com/labstack/echo/v4"
  15. )
  16. type MailboxRenderData struct {
  17. RenderData
  18. Mailbox *imap.MailboxStatus
  19. Mailboxes []*imap.MailboxInfo
  20. Messages []imapMessage
  21. PrevPage, NextPage int
  22. }
  23. func handleGetMailbox(ectx echo.Context) error {
  24. ctx := ectx.(*context)
  25. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  26. if err != nil {
  27. return echo.NewHTTPError(http.StatusBadRequest, err)
  28. }
  29. page := 0
  30. if pageStr := ctx.QueryParam("page"); pageStr != "" {
  31. var err error
  32. if page, err = strconv.Atoi(pageStr); err != nil || page < 0 {
  33. return echo.NewHTTPError(http.StatusBadRequest, "invalid page index")
  34. }
  35. }
  36. var mailboxes []*imap.MailboxInfo
  37. var msgs []imapMessage
  38. var mbox *imap.MailboxStatus
  39. err = ctx.session.Do(func(c *imapclient.Client) error {
  40. var err error
  41. if mailboxes, err = listMailboxes(c); err != nil {
  42. return err
  43. }
  44. if msgs, err = listMessages(c, mboxName, page); err != nil {
  45. return err
  46. }
  47. mbox = c.Mailbox()
  48. return nil
  49. })
  50. if err != nil {
  51. return err
  52. }
  53. prevPage, nextPage := -1, -1
  54. if page > 0 {
  55. prevPage = page - 1
  56. }
  57. if (page+1)*messagesPerPage < int(mbox.Messages) {
  58. nextPage = page + 1
  59. }
  60. return ctx.Render(http.StatusOK, "mailbox.html", &MailboxRenderData{
  61. RenderData: *NewRenderData(ctx),
  62. Mailbox: mbox,
  63. Mailboxes: mailboxes,
  64. Messages: msgs,
  65. PrevPage: prevPage,
  66. NextPage: nextPage,
  67. })
  68. }
  69. func handleLogin(ectx echo.Context) error {
  70. ctx := ectx.(*context)
  71. username := ctx.FormValue("username")
  72. password := ctx.FormValue("password")
  73. if username != "" && password != "" {
  74. s, err := ctx.server.sessions.Put(username, password)
  75. if err != nil {
  76. if _, ok := err.(AuthError); ok {
  77. return ctx.Render(http.StatusOK, "login.html", nil)
  78. }
  79. return fmt.Errorf("failed to put connection in pool: %v", err)
  80. }
  81. ctx.setToken(s.Token)
  82. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  83. }
  84. return ctx.Render(http.StatusOK, "login.html", NewRenderData(ctx))
  85. }
  86. func handleLogout(ectx echo.Context) error {
  87. ctx := ectx.(*context)
  88. ctx.session.Close()
  89. ctx.setToken("")
  90. return ctx.Redirect(http.StatusFound, "/login")
  91. }
  92. type MessageRenderData struct {
  93. RenderData
  94. Mailbox *imap.MailboxStatus
  95. Message *imapMessage
  96. Body string
  97. PartPath string
  98. MailboxPage int
  99. }
  100. func handleGetPart(ctx *context, raw bool) error {
  101. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  102. if err != nil {
  103. return echo.NewHTTPError(http.StatusBadRequest, err)
  104. }
  105. partPathString := ctx.QueryParam("part")
  106. partPath, err := parsePartPath(partPathString)
  107. if err != nil {
  108. return echo.NewHTTPError(http.StatusBadRequest, err)
  109. }
  110. var msg *imapMessage
  111. var part *message.Entity
  112. var mbox *imap.MailboxStatus
  113. err = ctx.session.Do(func(c *imapclient.Client) error {
  114. var err error
  115. msg, part, err = getMessagePart(c, mboxName, uid, partPath)
  116. mbox = c.Mailbox()
  117. return err
  118. })
  119. if err != nil {
  120. return err
  121. }
  122. mimeType, _, err := part.Header.ContentType()
  123. if err != nil {
  124. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  125. }
  126. if len(partPath) == 0 {
  127. mimeType = "message/rfc822"
  128. }
  129. if raw {
  130. disp, dispParams, _ := part.Header.ContentDisposition()
  131. filename := dispParams["filename"]
  132. // TODO: set Content-Length if possible
  133. if !strings.EqualFold(mimeType, "text/plain") || strings.EqualFold(disp, "attachment") {
  134. dispParams := make(map[string]string)
  135. if filename != "" {
  136. dispParams["filename"] = filename
  137. }
  138. disp := mime.FormatMediaType("attachment", dispParams)
  139. ctx.Response().Header().Set("Content-Disposition", disp)
  140. }
  141. return ctx.Stream(http.StatusOK, mimeType, part.Body)
  142. }
  143. var body string
  144. if strings.HasPrefix(strings.ToLower(mimeType), "text/") {
  145. b, err := ioutil.ReadAll(part.Body)
  146. if err != nil {
  147. return fmt.Errorf("failed to read part body: %v", err)
  148. }
  149. body = string(b)
  150. }
  151. return ctx.Render(http.StatusOK, "message.html", &MessageRenderData{
  152. RenderData: *NewRenderData(ctx),
  153. Mailbox: mbox,
  154. Message: msg,
  155. Body: body,
  156. PartPath: partPathString,
  157. MailboxPage: int(mbox.Messages-msg.SeqNum) / messagesPerPage,
  158. })
  159. }
  160. type ComposeRenderData struct {
  161. RenderData
  162. Message *OutgoingMessage
  163. }
  164. func handleCompose(ectx echo.Context) error {
  165. ctx := ectx.(*context)
  166. var msg OutgoingMessage
  167. if strings.ContainsRune(ctx.session.username, '@') {
  168. msg.From = ctx.session.username
  169. }
  170. if ctx.Request().Method == http.MethodGet && ctx.Param("uid") != "" {
  171. // This is a reply
  172. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  173. if err != nil {
  174. return echo.NewHTTPError(http.StatusBadRequest, err)
  175. }
  176. partPath, err := parsePartPath(ctx.QueryParam("part"))
  177. if err != nil {
  178. return echo.NewHTTPError(http.StatusBadRequest, err)
  179. }
  180. var inReplyTo *imapMessage
  181. var part *message.Entity
  182. err = ctx.session.Do(func(c *imapclient.Client) error {
  183. var err error
  184. inReplyTo, part, err = getMessagePart(c, mboxName, uid, partPath)
  185. return err
  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 !strings.HasPrefix(strings.ToLower(mimeType), "text/") {
  195. err := fmt.Errorf("cannot reply to \"%v\" part", mimeType)
  196. return echo.NewHTTPError(http.StatusBadRequest, err)
  197. }
  198. msg.Text, err = quote(part.Body)
  199. if err != nil {
  200. return err
  201. }
  202. msg.InReplyTo = inReplyTo.Envelope.MessageId
  203. // TODO: populate From from known user addresses and inReplyTo.Envelope.To
  204. replyTo := inReplyTo.Envelope.ReplyTo
  205. if len(replyTo) == 0 {
  206. replyTo = inReplyTo.Envelope.From
  207. }
  208. if len(replyTo) > 0 {
  209. msg.To = make([]string, len(replyTo))
  210. for i, to := range replyTo {
  211. msg.To[i] = to.Address()
  212. }
  213. }
  214. msg.Subject = inReplyTo.Envelope.Subject
  215. if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") {
  216. msg.Subject = "Re: " + msg.Subject
  217. }
  218. }
  219. if ctx.Request().Method == http.MethodPost {
  220. msg.From = ctx.FormValue("from")
  221. msg.To = parseAddressList(ctx.FormValue("to"))
  222. msg.Subject = ctx.FormValue("subject")
  223. msg.Text = ctx.FormValue("text")
  224. msg.InReplyTo = ctx.FormValue("in_reply_to")
  225. c, err := ctx.server.connectSMTP()
  226. if err != nil {
  227. return err
  228. }
  229. defer c.Close()
  230. auth := sasl.NewPlainClient("", ctx.session.username, ctx.session.password)
  231. if err := c.Auth(auth); err != nil {
  232. return echo.NewHTTPError(http.StatusForbidden, err)
  233. }
  234. if err := sendMessage(c, &msg); err != nil {
  235. return err
  236. }
  237. if err := c.Quit(); err != nil {
  238. return fmt.Errorf("QUIT failed: %v", err)
  239. }
  240. // TODO: append to IMAP Sent mailbox
  241. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  242. }
  243. return ctx.Render(http.StatusOK, "compose.html", &ComposeRenderData{
  244. RenderData: *NewRenderData(ctx),
  245. Message: &msg,
  246. })
  247. }