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.
 
 
 
 

346 lines
7.4 KiB

  1. package koushin
  2. import (
  3. "fmt"
  4. "io/ioutil"
  5. "mime"
  6. "net/http"
  7. "net/url"
  8. "strings"
  9. "time"
  10. imapclient "github.com/emersion/go-imap/client"
  11. "github.com/emersion/go-sasl"
  12. "github.com/labstack/echo/v4"
  13. )
  14. const cookieName = "koushin_session"
  15. type Server struct {
  16. imap struct {
  17. host string
  18. tls bool
  19. insecure bool
  20. pool *ConnPool
  21. }
  22. smtp struct {
  23. host string
  24. tls bool
  25. insecure bool
  26. }
  27. }
  28. func (s *Server) parseIMAPURL(imapURL string) error {
  29. u, err := url.Parse(imapURL)
  30. if err != nil {
  31. return fmt.Errorf("failed to parse IMAP server URL: %v", err)
  32. }
  33. s.imap.host = u.Host
  34. switch u.Scheme {
  35. case "imap":
  36. // This space is intentionally left blank
  37. case "imaps":
  38. s.imap.tls = true
  39. case "imap+insecure":
  40. s.imap.insecure = true
  41. default:
  42. return fmt.Errorf("unrecognized IMAP URL scheme: %s", u.Scheme)
  43. }
  44. return nil
  45. }
  46. func (s *Server) parseSMTPURL(smtpURL string) error {
  47. u, err := url.Parse(smtpURL)
  48. if err != nil {
  49. return fmt.Errorf("failed to parse SMTP server URL: %v", err)
  50. }
  51. s.smtp.host = u.Host
  52. switch u.Scheme {
  53. case "smtp":
  54. // This space is intentionally left blank
  55. case "smtps":
  56. s.smtp.tls = true
  57. case "smtp+insecure":
  58. s.smtp.insecure = true
  59. default:
  60. return fmt.Errorf("unrecognized SMTP URL scheme: %s", u.Scheme)
  61. }
  62. return nil
  63. }
  64. func NewServer(imapURL, smtpURL string) (*Server, error) {
  65. s := &Server{}
  66. if err := s.parseIMAPURL(imapURL); err != nil {
  67. return nil, err
  68. }
  69. s.imap.pool = NewConnPool()
  70. if smtpURL != "" {
  71. if err := s.parseSMTPURL(smtpURL); err != nil {
  72. return nil, err
  73. }
  74. }
  75. return s, nil
  76. }
  77. type context struct {
  78. echo.Context
  79. server *Server
  80. session *Session
  81. conn *imapclient.Client
  82. }
  83. var aLongTimeAgo = time.Unix(233431200, 0)
  84. func (c *context) setToken(token string) {
  85. cookie := http.Cookie{
  86. Name: cookieName,
  87. Value: token,
  88. HttpOnly: true,
  89. // TODO: domain, secure
  90. }
  91. if token == "" {
  92. cookie.Expires = aLongTimeAgo // unset the cookie
  93. }
  94. c.SetCookie(&cookie)
  95. }
  96. func handleLogin(ectx echo.Context) error {
  97. ctx := ectx.(*context)
  98. username := ctx.FormValue("username")
  99. password := ctx.FormValue("password")
  100. if username != "" && password != "" {
  101. conn, err := ctx.server.connectIMAP()
  102. if err != nil {
  103. return err
  104. }
  105. if err := conn.Login(username, password); err != nil {
  106. conn.Logout()
  107. return ctx.Render(http.StatusOK, "login.html", nil)
  108. }
  109. token, err := ctx.server.imap.pool.Put(conn, username, password)
  110. if err != nil {
  111. return fmt.Errorf("failed to put connection in pool: %v", err)
  112. }
  113. ctx.setToken(token)
  114. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  115. }
  116. return ctx.Render(http.StatusOK, "login.html", nil)
  117. }
  118. func handleGetPart(ctx *context, raw bool) error {
  119. mboxName := ctx.Param("mbox")
  120. uid, err := parseUid(ctx.Param("uid"))
  121. if err != nil {
  122. return echo.NewHTTPError(http.StatusBadRequest, err)
  123. }
  124. partPathString := ctx.QueryParam("part")
  125. partPath, err := parsePartPath(partPathString)
  126. if err != nil {
  127. return echo.NewHTTPError(http.StatusBadRequest, err)
  128. }
  129. msg, part, err := getMessagePart(ctx.conn, mboxName, uid, partPath)
  130. if err != nil {
  131. return err
  132. }
  133. mimeType, _, err := part.Header.ContentType()
  134. if err != nil {
  135. return fmt.Errorf("failed to parse part Content-Type: %v", err)
  136. }
  137. if len(partPath) == 0 {
  138. mimeType = "message/rfc822"
  139. }
  140. if raw {
  141. disp, dispParams, _ := part.Header.ContentDisposition()
  142. filename := dispParams["filename"]
  143. // TODO: set Content-Length if possible
  144. if !strings.EqualFold(mimeType, "text/plain") || strings.EqualFold(disp, "attachment") {
  145. dispParams := make(map[string]string)
  146. if filename != "" {
  147. dispParams["filename"] = filename
  148. }
  149. disp := mime.FormatMediaType("attachment", dispParams)
  150. ctx.Response().Header().Set("Content-Disposition", disp)
  151. }
  152. return ctx.Stream(http.StatusOK, mimeType, part.Body)
  153. }
  154. var body string
  155. if strings.HasPrefix(strings.ToLower(mimeType), "text/") {
  156. b, err := ioutil.ReadAll(part.Body)
  157. if err != nil {
  158. return fmt.Errorf("failed to read part body: %v", err)
  159. }
  160. body = string(b)
  161. }
  162. return ctx.Render(http.StatusOK, "message.html", map[string]interface{}{
  163. "Mailbox": ctx.conn.Mailbox(),
  164. "Message": msg,
  165. "Body": body,
  166. "PartPath": partPathString,
  167. })
  168. }
  169. func handleCompose(ectx echo.Context) error {
  170. ctx := ectx.(*context)
  171. if ctx.Request().Method == http.MethodPost {
  172. // TODO: parse address lists
  173. from := ctx.FormValue("from")
  174. to := ctx.FormValue("to")
  175. subject := ctx.FormValue("subject")
  176. text := ctx.FormValue("text")
  177. c, err := ctx.server.connectSMTP()
  178. if err != nil {
  179. return err
  180. }
  181. defer c.Close()
  182. auth := sasl.NewPlainClient("", ctx.session.username, ctx.session.password)
  183. if err := c.Auth(auth); err != nil {
  184. return echo.NewHTTPError(http.StatusForbidden, err)
  185. }
  186. msg := OutgoingMessage{
  187. from: from,
  188. to: []string{to},
  189. subject: subject,
  190. text: text,
  191. }
  192. if err := sendMessage(c, &msg); err != nil {
  193. return err
  194. }
  195. if err := c.Quit(); err != nil {
  196. return fmt.Errorf("QUIT failed: %v", err)
  197. }
  198. // TODO: append to IMAP Sent mailbox
  199. return ctx.Redirect(http.StatusFound, "/mailbox/INBOX")
  200. }
  201. return ctx.Render(http.StatusOK, "compose.html", nil)
  202. }
  203. func New(imapURL, smtpURL string) *echo.Echo {
  204. e := echo.New()
  205. s, err := NewServer(imapURL, smtpURL)
  206. if err != nil {
  207. e.Logger.Fatal(err)
  208. }
  209. e.HTTPErrorHandler = func(err error, c echo.Context) {
  210. code := http.StatusInternalServerError
  211. if he, ok := err.(*echo.HTTPError); ok {
  212. code = he.Code
  213. } else {
  214. c.Logger().Error(err)
  215. }
  216. // TODO: hide internal errors
  217. c.String(code, err.Error())
  218. }
  219. e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
  220. return func(ectx echo.Context) error {
  221. ctx := &context{Context: ectx, server: s}
  222. cookie, err := ctx.Cookie(cookieName)
  223. if err == http.ErrNoCookie {
  224. // Require auth for all pages except /login
  225. if ctx.Path() == "/login" {
  226. return next(ctx)
  227. } else {
  228. return ctx.Redirect(http.StatusFound, "/login")
  229. }
  230. } else if err != nil {
  231. return err
  232. }
  233. ctx.session, err = ctx.server.imap.pool.Get(cookie.Value)
  234. if err == ErrSessionExpired {
  235. ctx.setToken("")
  236. return ctx.Redirect(http.StatusFound, "/login")
  237. } else if err != nil {
  238. return err
  239. }
  240. ctx.conn = ctx.session.imapConn
  241. return next(ctx)
  242. }
  243. })
  244. e.Renderer, err = loadTemplates()
  245. if err != nil {
  246. e.Logger.Fatal("Failed to load templates:", err)
  247. }
  248. e.GET("/mailbox/:mbox", func(ectx echo.Context) error {
  249. ctx := ectx.(*context)
  250. mailboxes, err := listMailboxes(ctx.conn)
  251. if err != nil {
  252. return err
  253. }
  254. msgs, err := listMessages(ctx.conn, ctx.Param("mbox"))
  255. if err != nil {
  256. return err
  257. }
  258. return ctx.Render(http.StatusOK, "mailbox.html", map[string]interface{}{
  259. "Mailbox": ctx.conn.Mailbox(),
  260. "Mailboxes": mailboxes,
  261. "Messages": msgs,
  262. })
  263. })
  264. e.GET("/message/:mbox/:uid", func(ectx echo.Context) error {
  265. ctx := ectx.(*context)
  266. return handleGetPart(ctx, false)
  267. })
  268. e.GET("/message/:mbox/:uid/raw", func(ectx echo.Context) error {
  269. ctx := ectx.(*context)
  270. return handleGetPart(ctx, true)
  271. })
  272. e.GET("/login", handleLogin)
  273. e.POST("/login", handleLogin)
  274. e.GET("/logout", func(ectx echo.Context) error {
  275. ctx := ectx.(*context)
  276. if err := ctx.conn.Logout(); err != nil {
  277. return fmt.Errorf("failed to logout: %v", err)
  278. }
  279. ctx.setToken("")
  280. return ctx.Redirect(http.StatusFound, "/login")
  281. })
  282. e.GET("/compose", handleCompose)
  283. e.POST("/compose", handleCompose)
  284. e.Static("/assets", "public/assets")
  285. return e
  286. }