A webmail client. Forked from https://git.sr.ht/~migadu/alps
Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

452 linhas
10 KiB

  1. package koushin
  2. import (
  3. "fmt"
  4. "io/ioutil"
  5. "mime"
  6. "net/http"
  7. "net/url"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "github.com/emersion/go-imap"
  12. imapclient "github.com/emersion/go-imap/client"
  13. "github.com/emersion/go-message"
  14. "github.com/emersion/go-sasl"
  15. "github.com/labstack/echo/v4"
  16. )
  17. const cookieName = "koushin_session"
  18. const messagesPerPage = 50
  19. type Server struct {
  20. imap struct {
  21. host string
  22. tls bool
  23. insecure bool
  24. pool *ConnPool
  25. }
  26. smtp struct {
  27. host string
  28. tls bool
  29. insecure bool
  30. }
  31. }
  32. func (s *Server) parseIMAPURL(imapURL string) error {
  33. u, err := url.Parse(imapURL)
  34. if err != nil {
  35. return fmt.Errorf("failed to parse IMAP server URL: %v", err)
  36. }
  37. s.imap.host = u.Host
  38. switch u.Scheme {
  39. case "imap":
  40. // This space is intentionally left blank
  41. case "imaps":
  42. s.imap.tls = true
  43. case "imap+insecure":
  44. s.imap.insecure = true
  45. default:
  46. return fmt.Errorf("unrecognized IMAP URL scheme: %s", u.Scheme)
  47. }
  48. return nil
  49. }
  50. func (s *Server) parseSMTPURL(smtpURL string) error {
  51. u, err := url.Parse(smtpURL)
  52. if err != nil {
  53. return fmt.Errorf("failed to parse SMTP server URL: %v", err)
  54. }
  55. s.smtp.host = u.Host
  56. switch u.Scheme {
  57. case "smtp":
  58. // This space is intentionally left blank
  59. case "smtps":
  60. s.smtp.tls = true
  61. case "smtp+insecure":
  62. s.smtp.insecure = true
  63. default:
  64. return fmt.Errorf("unrecognized SMTP URL scheme: %s", u.Scheme)
  65. }
  66. return nil
  67. }
  68. func NewServer(imapURL, smtpURL string) (*Server, error) {
  69. s := &Server{}
  70. if err := s.parseIMAPURL(imapURL); err != nil {
  71. return nil, err
  72. }
  73. s.imap.pool = NewConnPool()
  74. if smtpURL != "" {
  75. if err := s.parseSMTPURL(smtpURL); err != nil {
  76. return nil, err
  77. }
  78. }
  79. return s, nil
  80. }
  81. type context struct {
  82. echo.Context
  83. server *Server
  84. session *Session
  85. }
  86. var aLongTimeAgo = time.Unix(233431200, 0)
  87. func (c *context) setToken(token string) {
  88. cookie := http.Cookie{
  89. Name: cookieName,
  90. Value: token,
  91. HttpOnly: true,
  92. // TODO: domain, secure
  93. }
  94. if token == "" {
  95. cookie.Expires = aLongTimeAgo // unset the cookie
  96. }
  97. c.SetCookie(&cookie)
  98. }
  99. func handleLogin(ectx echo.Context) error {
  100. ctx := ectx.(*context)
  101. username := ctx.FormValue("username")
  102. password := ctx.FormValue("password")
  103. if username != "" && password != "" {
  104. conn, err := ctx.server.connectIMAP()
  105. if err != nil {
  106. return err
  107. }
  108. if err := conn.Login(username, password); err != nil {
  109. conn.Logout()
  110. return ctx.Render(http.StatusOK, "login.html", nil)
  111. }
  112. token, err := ctx.server.imap.pool.Put(conn, username, password)
  113. if err != nil {
  114. return fmt.Errorf("failed to put connection in pool: %v", err)
  115. }
  116. ctx.setToken(token)
  117. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  118. }
  119. return ctx.Render(http.StatusOK, "login.html", nil)
  120. }
  121. func handleGetPart(ctx *context, raw bool) error {
  122. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  123. if err != nil {
  124. return echo.NewHTTPError(http.StatusBadRequest, err)
  125. }
  126. partPathString := ctx.QueryParam("part")
  127. partPath, err := parsePartPath(partPathString)
  128. if err != nil {
  129. return echo.NewHTTPError(http.StatusBadRequest, err)
  130. }
  131. var msg *imapMessage
  132. var part *message.Entity
  133. var mbox *imap.MailboxStatus
  134. err = ctx.session.Do(func(c *imapclient.Client) error {
  135. var err error
  136. msg, part, err = getMessagePart(c, mboxName, uid, partPath)
  137. mbox = c.Mailbox()
  138. return err
  139. })
  140. if err != nil {
  141. return err
  142. }
  143. mimeType, _, err := part.Header.ContentType()
  144. if err != nil {
  145. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  146. }
  147. if len(partPath) == 0 {
  148. mimeType = "message/rfc822"
  149. }
  150. if raw {
  151. disp, dispParams, _ := part.Header.ContentDisposition()
  152. filename := dispParams["filename"]
  153. // TODO: set Content-Length if possible
  154. if !strings.EqualFold(mimeType, "text/plain") || strings.EqualFold(disp, "attachment") {
  155. dispParams := make(map[string]string)
  156. if filename != "" {
  157. dispParams["filename"] = filename
  158. }
  159. disp := mime.FormatMediaType("attachment", dispParams)
  160. ctx.Response().Header().Set("Content-Disposition", disp)
  161. }
  162. return ctx.Stream(http.StatusOK, mimeType, part.Body)
  163. }
  164. var body string
  165. if strings.HasPrefix(strings.ToLower(mimeType), "text/") {
  166. b, err := ioutil.ReadAll(part.Body)
  167. if err != nil {
  168. return fmt.Errorf("failed to read part body: %v", err)
  169. }
  170. body = string(b)
  171. }
  172. return ctx.Render(http.StatusOK, "message.html", map[string]interface{}{
  173. "Mailbox": mbox,
  174. "Message": msg,
  175. "Body": body,
  176. "PartPath": partPathString,
  177. "MailboxPage": (mbox.Messages - msg.SeqNum) / messagesPerPage,
  178. })
  179. }
  180. func handleCompose(ectx echo.Context) error {
  181. ctx := ectx.(*context)
  182. var msg OutgoingMessage
  183. if strings.ContainsRune(ctx.session.username, '@') {
  184. msg.From = ctx.session.username
  185. }
  186. if ctx.Request().Method == http.MethodGet && ctx.Param("uid") != "" {
  187. // This is a reply
  188. mboxName, uid, err := parseMboxAndUid(ctx.Param("mbox"), ctx.Param("uid"))
  189. if err != nil {
  190. return echo.NewHTTPError(http.StatusBadRequest, err)
  191. }
  192. partPath, err := parsePartPath(ctx.QueryParam("part"))
  193. if err != nil {
  194. return echo.NewHTTPError(http.StatusBadRequest, err)
  195. }
  196. var inReplyTo *imapMessage
  197. var part *message.Entity
  198. err = ctx.session.Do(func(c *imapclient.Client) error {
  199. var err error
  200. inReplyTo, part, err = getMessagePart(c, mboxName, uid, partPath)
  201. return err
  202. })
  203. if err != nil {
  204. return err
  205. }
  206. mimeType, _, err := part.Header.ContentType()
  207. if err != nil {
  208. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  209. }
  210. if !strings.HasPrefix(strings.ToLower(mimeType), "text/") {
  211. err := fmt.Errorf("cannot reply to \"%v\" part", mimeType)
  212. return echo.NewHTTPError(http.StatusBadRequest, err)
  213. }
  214. msg.Text, err = quote(part.Body)
  215. if err != nil {
  216. return err
  217. }
  218. msg.InReplyTo = inReplyTo.Envelope.MessageId
  219. // TODO: populate From from known user addresses and inReplyTo.Envelope.To
  220. replyTo := inReplyTo.Envelope.ReplyTo
  221. if len(replyTo) == 0 {
  222. replyTo = inReplyTo.Envelope.From
  223. }
  224. if len(replyTo) > 0 {
  225. msg.To = make([]string, len(replyTo))
  226. for i, to := range replyTo {
  227. msg.To[i] = to.MailboxName + "@" + to.HostName
  228. }
  229. }
  230. msg.Subject = inReplyTo.Envelope.Subject
  231. if !strings.HasPrefix(strings.ToLower(msg.Subject), "re:") {
  232. msg.Subject = "Re: " + msg.Subject
  233. }
  234. }
  235. if ctx.Request().Method == http.MethodPost {
  236. msg.From = ctx.FormValue("from")
  237. msg.To = parseAddressList(ctx.FormValue("to"))
  238. msg.Subject = ctx.FormValue("subject")
  239. msg.Text = ctx.FormValue("text")
  240. msg.InReplyTo = ctx.FormValue("in_reply_to")
  241. c, err := ctx.server.connectSMTP()
  242. if err != nil {
  243. return err
  244. }
  245. defer c.Close()
  246. auth := sasl.NewPlainClient("", ctx.session.username, ctx.session.password)
  247. if err := c.Auth(auth); err != nil {
  248. return echo.NewHTTPError(http.StatusForbidden, err)
  249. }
  250. if err := sendMessage(c, &msg); err != nil {
  251. return err
  252. }
  253. if err := c.Quit(); err != nil {
  254. return fmt.Errorf("QUIT failed: %v", err)
  255. }
  256. // TODO: append to IMAP Sent mailbox
  257. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  258. }
  259. return ctx.Render(http.StatusOK, "compose.html", map[string]interface{}{
  260. "Message": &msg,
  261. })
  262. }
  263. func New(imapURL, smtpURL string) *echo.Echo {
  264. e := echo.New()
  265. s, err := NewServer(imapURL, smtpURL)
  266. if err != nil {
  267. e.Logger.Fatal(err)
  268. }
  269. e.HTTPErrorHandler = func(err error, c echo.Context) {
  270. code := http.StatusInternalServerError
  271. if he, ok := err.(*echo.HTTPError); ok {
  272. code = he.Code
  273. } else {
  274. c.Logger().Error(err)
  275. }
  276. // TODO: hide internal errors
  277. c.String(code, err.Error())
  278. }
  279. e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
  280. return func(ectx echo.Context) error {
  281. ctx := &context{Context: ectx, server: s}
  282. cookie, err := ctx.Cookie(cookieName)
  283. if err == http.ErrNoCookie {
  284. // Require auth for all pages except /login
  285. if ctx.Path() == "/login" || strings.HasPrefix(ctx.Path(), "/assets/") {
  286. return next(ctx)
  287. } else {
  288. return ctx.Redirect(http.StatusFound, "/login")
  289. }
  290. } else if err != nil {
  291. return err
  292. }
  293. ctx.session, err = ctx.server.imap.pool.Get(cookie.Value)
  294. if err == ErrSessionExpired {
  295. ctx.setToken("")
  296. return ctx.Redirect(http.StatusFound, "/login")
  297. } else if err != nil {
  298. return err
  299. }
  300. return next(ctx)
  301. }
  302. })
  303. e.Renderer, err = loadTemplates()
  304. if err != nil {
  305. e.Logger.Fatal("Failed to load templates:", err)
  306. }
  307. e.GET("/mailbox/:mbox", func(ectx echo.Context) error {
  308. ctx := ectx.(*context)
  309. mboxName, err := url.PathUnescape(ctx.Param("mbox"))
  310. if err != nil {
  311. return echo.NewHTTPError(http.StatusBadRequest, err)
  312. }
  313. page := 0
  314. if pageStr := ctx.QueryParam("page"); pageStr != "" {
  315. var err error
  316. if page, err = strconv.Atoi(pageStr); err != nil || page < 0 {
  317. return echo.NewHTTPError(http.StatusBadRequest, "invalid page index")
  318. }
  319. }
  320. var mailboxes []*imap.MailboxInfo
  321. var msgs []imapMessage
  322. var mbox *imap.MailboxStatus
  323. err = ctx.session.Do(func(c *imapclient.Client) error {
  324. var err error
  325. if mailboxes, err = listMailboxes(c); err != nil {
  326. return err
  327. }
  328. if msgs, err = listMessages(c, mboxName, page); err != nil {
  329. return err
  330. }
  331. mbox = c.Mailbox()
  332. return nil
  333. })
  334. if err != nil {
  335. return err
  336. }
  337. prevPage, nextPage := -1, -1
  338. if page > 0 {
  339. prevPage = page - 1
  340. }
  341. if (page+1)*messagesPerPage < int(mbox.Messages) {
  342. nextPage = page + 1
  343. }
  344. return ctx.Render(http.StatusOK, "mailbox.html", map[string]interface{}{
  345. "Mailbox": mbox,
  346. "Mailboxes": mailboxes,
  347. "Messages": msgs,
  348. "PrevPage": prevPage,
  349. "NextPage": nextPage,
  350. })
  351. })
  352. e.GET("/message/:mbox/:uid", func(ectx echo.Context) error {
  353. ctx := ectx.(*context)
  354. return handleGetPart(ctx, false)
  355. })
  356. e.GET("/message/:mbox/:uid/raw", func(ectx echo.Context) error {
  357. ctx := ectx.(*context)
  358. return handleGetPart(ctx, true)
  359. })
  360. e.GET("/login", handleLogin)
  361. e.POST("/login", handleLogin)
  362. e.GET("/logout", func(ectx echo.Context) error {
  363. ctx := ectx.(*context)
  364. err := ctx.session.Do(func(c *imapclient.Client) error {
  365. return c.Logout()
  366. })
  367. if err != nil {
  368. return fmt.Errorf("failed to logout: %v", err)
  369. }
  370. ctx.setToken("")
  371. return ctx.Redirect(http.StatusFound, "/login")
  372. })
  373. e.GET("/compose", handleCompose)
  374. e.POST("/compose", handleCompose)
  375. e.GET("/message/:mbox/:uid/reply", handleCompose)
  376. e.POST("/message/:mbox/:uid/reply", handleCompose)
  377. e.Static("/assets", "public/assets")
  378. return e
  379. }