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.
 
 
 
 

222 lines
4.6 KiB

  1. package koushin
  2. import (
  3. "fmt"
  4. "net/http"
  5. "net/url"
  6. "strings"
  7. "time"
  8. "github.com/labstack/echo/v4"
  9. )
  10. const cookieName = "koushin_session"
  11. const messagesPerPage = 50
  12. // Server holds all the koushin server state.
  13. type Server struct {
  14. Sessions *SessionManager
  15. Plugins []Plugin
  16. imap struct {
  17. host string
  18. tls bool
  19. insecure bool
  20. }
  21. smtp struct {
  22. host string
  23. tls bool
  24. insecure bool
  25. }
  26. }
  27. func (s *Server) parseIMAPURL(imapURL string) error {
  28. u, err := url.Parse(imapURL)
  29. if err != nil {
  30. return fmt.Errorf("failed to parse IMAP server URL: %v", err)
  31. }
  32. s.imap.host = u.Host
  33. switch u.Scheme {
  34. case "imap":
  35. // This space is intentionally left blank
  36. case "imaps":
  37. s.imap.tls = true
  38. case "imap+insecure":
  39. s.imap.insecure = true
  40. default:
  41. return fmt.Errorf("unrecognized IMAP URL scheme: %s", u.Scheme)
  42. }
  43. return nil
  44. }
  45. func (s *Server) parseSMTPURL(smtpURL string) error {
  46. u, err := url.Parse(smtpURL)
  47. if err != nil {
  48. return fmt.Errorf("failed to parse SMTP server URL: %v", err)
  49. }
  50. s.smtp.host = u.Host
  51. switch u.Scheme {
  52. case "smtp":
  53. // This space is intentionally left blank
  54. case "smtps":
  55. s.smtp.tls = true
  56. case "smtp+insecure":
  57. s.smtp.insecure = true
  58. default:
  59. return fmt.Errorf("unrecognized SMTP URL scheme: %s", u.Scheme)
  60. }
  61. return nil
  62. }
  63. func newServer(imapURL, smtpURL string) (*Server, error) {
  64. s := &Server{}
  65. s.Sessions = newSessionManager(s.connectIMAP)
  66. if err := s.parseIMAPURL(imapURL); err != nil {
  67. return nil, err
  68. }
  69. if smtpURL != "" {
  70. if err := s.parseSMTPURL(smtpURL); err != nil {
  71. return nil, err
  72. }
  73. }
  74. return s, nil
  75. }
  76. // Context is the context used by HTTP handlers.
  77. //
  78. // Use a type assertion to get it from a echo.Context:
  79. //
  80. // ctx := ectx.(*koushin.Context)
  81. type Context struct {
  82. echo.Context
  83. Server *Server
  84. Session *Session // nil if user isn't logged in
  85. }
  86. var aLongTimeAgo = time.Unix(233431200, 0)
  87. // SetSession sets a cookie for the provided session. Passing a nil session
  88. // unsets the cookie.
  89. func (ctx *Context) SetSession(s *Session) {
  90. cookie := http.Cookie{
  91. Name: cookieName,
  92. HttpOnly: true,
  93. // TODO: domain, secure
  94. }
  95. if s != nil {
  96. cookie.Value = s.token
  97. } else {
  98. cookie.Expires = aLongTimeAgo // unset the cookie
  99. }
  100. ctx.SetCookie(&cookie)
  101. }
  102. func isPublic(path string) bool {
  103. return path == "/login" || strings.HasPrefix(path, "/assets/") ||
  104. strings.HasPrefix(path, "/themes/")
  105. }
  106. type Options struct {
  107. IMAPURL, SMTPURL string
  108. Theme string
  109. }
  110. // New creates a new server.
  111. func New(e *echo.Echo, options *Options) error {
  112. s, err := newServer(options.IMAPURL, options.SMTPURL)
  113. if err != nil {
  114. return err
  115. }
  116. s.Plugins, err = loadAllLuaPlugins(e.Logger)
  117. if err != nil {
  118. return fmt.Errorf("failed to load plugins: %v", err)
  119. }
  120. e.Renderer, err = loadTemplates(e.Logger, options.Theme, s.Plugins)
  121. if err != nil {
  122. return fmt.Errorf("failed to load templates: %v", err)
  123. }
  124. e.HTTPErrorHandler = func(err error, c echo.Context) {
  125. code := http.StatusInternalServerError
  126. if he, ok := err.(*echo.HTTPError); ok {
  127. code = he.Code
  128. } else {
  129. c.Logger().Error(err)
  130. }
  131. // TODO: hide internal errors
  132. c.String(code, err.Error())
  133. }
  134. e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
  135. return func(ectx echo.Context) error {
  136. ctx := &Context{Context: ectx, Server: s}
  137. ctx.Set("context", ctx)
  138. cookie, err := ctx.Cookie(cookieName)
  139. if err == http.ErrNoCookie {
  140. // Require auth for all pages except /login
  141. if isPublic(ctx.Path()) {
  142. return next(ctx)
  143. } else {
  144. return ctx.Redirect(http.StatusFound, "/login")
  145. }
  146. } else if err != nil {
  147. return err
  148. }
  149. ctx.Session, err = ctx.Server.Sessions.get(cookie.Value)
  150. if err == ErrSessionExpired {
  151. ctx.SetSession(nil)
  152. return ctx.Redirect(http.StatusFound, "/login")
  153. } else if err != nil {
  154. return err
  155. }
  156. ctx.Session.ping()
  157. return next(ctx)
  158. }
  159. })
  160. e.GET("/mailbox/:mbox", handleGetMailbox)
  161. e.GET("/message/:mbox/:uid", func(ectx echo.Context) error {
  162. ctx := ectx.(*Context)
  163. return handleGetPart(ctx, false)
  164. })
  165. e.GET("/message/:mbox/:uid/raw", func(ectx echo.Context) error {
  166. ctx := ectx.(*Context)
  167. return handleGetPart(ctx, true)
  168. })
  169. e.GET("/login", handleLogin)
  170. e.POST("/login", handleLogin)
  171. e.GET("/logout", handleLogout)
  172. e.GET("/compose", handleCompose)
  173. e.POST("/compose", handleCompose)
  174. e.GET("/message/:mbox/:uid/reply", handleCompose)
  175. e.POST("/message/:mbox/:uid/reply", handleCompose)
  176. e.Static("/assets", "public/assets")
  177. e.Static("/themes", "public/themes")
  178. for _, p := range s.Plugins {
  179. p.SetRoutes(e.Group(""))
  180. }
  181. return nil
  182. }