mirror of
				https://github.com/TheAnachronism/docspell.git
				synced 2025-10-30 21:40:12 +00:00 
			
		
		
		
	Starting with mail functionality
This commit is contained in:
		| @@ -23,6 +23,7 @@ trait BackendApp[F[_]] { | ||||
|   def node: ONode[F] | ||||
|   def job: OJob[F] | ||||
|   def item: OItem[F] | ||||
|   def mail: OMail[F] | ||||
| } | ||||
|  | ||||
| object BackendApp { | ||||
| @@ -45,6 +46,7 @@ object BackendApp { | ||||
|       nodeImpl   <- ONode(store) | ||||
|       jobImpl    <- OJob(store, httpClientEc) | ||||
|       itemImpl   <- OItem(store) | ||||
|       mailImpl   <- OMail(store) | ||||
|     } yield new BackendApp[F] { | ||||
|       val login: Login[F]            = loginImpl | ||||
|       val signup: OSignup[F]         = signupImpl | ||||
| @@ -57,6 +59,7 @@ object BackendApp { | ||||
|       val node                       = nodeImpl | ||||
|       val job                        = jobImpl | ||||
|       val item                       = itemImpl | ||||
|       val mail                       = mailImpl | ||||
|     } | ||||
|  | ||||
|   def apply[F[_]: ConcurrentEffect: ContextShift]( | ||||
|   | ||||
| @@ -0,0 +1,43 @@ | ||||
| package docspell.backend.ops | ||||
|  | ||||
| import cats.effect._ | ||||
| import cats.implicits._ | ||||
| import cats.data.OptionT | ||||
| import docspell.common._ | ||||
| import docspell.store._ | ||||
| import docspell.store.records.RUserEmail | ||||
|  | ||||
| trait OMail[F[_]] { | ||||
|  | ||||
|   def getSettings(accId: AccountId, nameQ: Option[String]): F[Vector[RUserEmail]] | ||||
|  | ||||
|   def createSettings(data: F[RUserEmail]): F[AddResult] | ||||
|  | ||||
|   def updateSettings(accId: AccountId, name: Ident, data: RUserEmail): F[Int] | ||||
| } | ||||
|  | ||||
| object OMail { | ||||
|  | ||||
|   def apply[F[_]: Effect](store: Store[F]): Resource[F, OMail[F]] = | ||||
|     Resource.pure(new OMail[F] { | ||||
|       def getSettings(accId: AccountId, nameQ: Option[String]): F[Vector[RUserEmail]] = | ||||
|         store.transact(RUserEmail.findByAccount(accId, nameQ)) | ||||
|  | ||||
|       def createSettings(data: F[RUserEmail]): F[AddResult] = | ||||
|         for { | ||||
|           ru <- data | ||||
|           ins = RUserEmail.insert(ru) | ||||
|           exists = RUserEmail.exists(ru.uid, ru.name) | ||||
|           ar <- store.add(ins, exists) | ||||
|         } yield ar | ||||
|  | ||||
|       def updateSettings(accId: AccountId, name: Ident, data: RUserEmail): F[Int] = { | ||||
|         val op = for { | ||||
|           um <- OptionT(RUserEmail.getByName(accId, name)) | ||||
|           n  <- OptionT.liftF(RUserEmail.update(um.id, data)) | ||||
|         } yield n | ||||
|  | ||||
|         store.transact(op.value).map(_.getOrElse(0)) | ||||
|       } | ||||
|     }) | ||||
| } | ||||
| @@ -1222,7 +1222,10 @@ paths: | ||||
|         sent e-mails. | ||||
|  | ||||
|         Multiple e-mail settings can be specified, they are | ||||
|         distinguished by their `name`. | ||||
|         distinguished by their `name`. The query `q` parameter does a | ||||
|         simple substring search in the connection name. | ||||
|       parameters: | ||||
|         - $ref: "#/components/parameters/q" | ||||
|       responses: | ||||
|         200: | ||||
|           description: Ok | ||||
| @@ -1352,12 +1355,9 @@ components: | ||||
|       required: | ||||
|         - name | ||||
|         - smtpHost | ||||
|         - smtpPort | ||||
|         - smtpUser | ||||
|         - smtpPassword | ||||
|         - from | ||||
|         - sslType | ||||
|         - certificateCheck | ||||
|         - ignoreCertificates | ||||
|       properties: | ||||
|         name: | ||||
|           type: string | ||||
| @@ -1374,9 +1374,11 @@ components: | ||||
|           format: password | ||||
|         from: | ||||
|           type: string | ||||
|         replyTo: | ||||
|           type: string | ||||
|         sslType: | ||||
|           type: string | ||||
|         certificateCheck: | ||||
|         ignoreCertificates: | ||||
|           type: boolean | ||||
|     CheckFileResult: | ||||
|       description: | | ||||
|   | ||||
| @@ -57,19 +57,20 @@ object RestServer { | ||||
|       token: AuthToken | ||||
|   ): HttpRoutes[F] = | ||||
|     Router( | ||||
|       "auth"         -> LoginRoutes.session(restApp.backend.login, cfg), | ||||
|       "tag"          -> TagRoutes(restApp.backend, token), | ||||
|       "equipment"    -> EquipmentRoutes(restApp.backend, token), | ||||
|       "organization" -> OrganizationRoutes(restApp.backend, token), | ||||
|       "person"       -> PersonRoutes(restApp.backend, token), | ||||
|       "source"       -> SourceRoutes(restApp.backend, token), | ||||
|       "user"         -> UserRoutes(restApp.backend, token), | ||||
|       "collective"   -> CollectiveRoutes(restApp.backend, token), | ||||
|       "queue"        -> JobQueueRoutes(restApp.backend, token), | ||||
|       "item"         -> ItemRoutes(restApp.backend, token), | ||||
|       "attachment"   -> AttachmentRoutes(restApp.backend, token), | ||||
|       "upload"       -> UploadRoutes.secured(restApp.backend, cfg, token), | ||||
|       "checkfile"    -> CheckFileRoutes.secured(restApp.backend, token) | ||||
|       "auth"           -> LoginRoutes.session(restApp.backend.login, cfg), | ||||
|       "tag"            -> TagRoutes(restApp.backend, token), | ||||
|       "equipment"      -> EquipmentRoutes(restApp.backend, token), | ||||
|       "organization"   -> OrganizationRoutes(restApp.backend, token), | ||||
|       "person"         -> PersonRoutes(restApp.backend, token), | ||||
|       "source"         -> SourceRoutes(restApp.backend, token), | ||||
|       "user"           -> UserRoutes(restApp.backend, token), | ||||
|       "collective"     -> CollectiveRoutes(restApp.backend, token), | ||||
|       "queue"          -> JobQueueRoutes(restApp.backend, token), | ||||
|       "item"           -> ItemRoutes(restApp.backend, token), | ||||
|       "attachment"     -> AttachmentRoutes(restApp.backend, token), | ||||
|       "upload"         -> UploadRoutes.secured(restApp.backend, cfg, token), | ||||
|       "checkfile"      -> CheckFileRoutes.secured(restApp.backend, token), | ||||
|       "email/settings" -> MailSettingsRoutes(restApp.backend, token) | ||||
|     ) | ||||
|  | ||||
|   def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = | ||||
|   | ||||
| @@ -0,0 +1,53 @@ | ||||
| package docspell.restserver.routes | ||||
|  | ||||
| import cats.effect._ | ||||
| import cats.implicits._ | ||||
| import org.http4s._ | ||||
| import org.http4s.dsl.Http4sDsl | ||||
| import org.http4s.circe.CirceEntityEncoder._ | ||||
| import org.http4s.circe.CirceEntityDecoder._ | ||||
|  | ||||
| import docspell.backend.BackendApp | ||||
| import docspell.backend.auth.AuthToken | ||||
| import docspell.common._ | ||||
| import docspell.restapi.model._ | ||||
| import docspell.store.records.RUserEmail | ||||
| import docspell.store.EmilUtil | ||||
|  | ||||
| object MailSettingsRoutes { | ||||
|  | ||||
|   def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = { | ||||
|     val dsl = new Http4sDsl[F] {} | ||||
|     import dsl._ | ||||
|  | ||||
|     HttpRoutes.of { | ||||
|       case req @ GET -> Root => | ||||
|         val q = req.params.get("q").map(_.trim).filter(_.nonEmpty) | ||||
|         for { | ||||
|           list <- backend.mail.getSettings(user.account, q) | ||||
|           res = list.map(convert) | ||||
|           resp <- Ok(EmailSettingsList(res.toList)) | ||||
|         } yield resp | ||||
|  | ||||
|       case req @ POST -> Root => | ||||
|         for { | ||||
|           in   <- req.as[EmailSettings] | ||||
|           resp <- Ok(BasicResult(false, "not implemented")) | ||||
|         } yield resp | ||||
|     } | ||||
|  | ||||
|   } | ||||
|  | ||||
|   def convert(ru: RUserEmail): EmailSettings = | ||||
|     EmailSettings( | ||||
|       ru.name, | ||||
|       ru.smtpHost, | ||||
|       ru.smtpPort, | ||||
|       ru.smtpUser, | ||||
|       ru.smtpPassword, | ||||
|       EmilUtil.mailAddressString(ru.mailFrom), | ||||
|       ru.mailReplyTo.map(EmilUtil.mailAddressString _), | ||||
|       EmilUtil.sslTypeString(ru.smtpSsl), | ||||
|       !ru.smtpCertCheck | ||||
|     ) | ||||
| } | ||||
							
								
								
									
										34
									
								
								modules/store/src/main/scala/docspell/store/EmilUtil.scala
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								modules/store/src/main/scala/docspell/store/EmilUtil.scala
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| package docspell.store | ||||
|  | ||||
| import emil._ | ||||
| import emil.javamail.syntax._ | ||||
|  | ||||
| object EmilUtil { | ||||
|  | ||||
|   def readSSLType(str: String): Either[String, SSLType] = | ||||
|     str.toLowerCase match { | ||||
|       case "ssl"      => Right(SSLType.SSL) | ||||
|       case "starttls" => Right(SSLType.StartTLS) | ||||
|       case "none"     => Right(SSLType.NoEncryption) | ||||
|       case _          => Left(s"Invalid ssl-type: $str") | ||||
|     } | ||||
|  | ||||
|   def unsafeReadSSLType(str: String): SSLType = | ||||
|     readSSLType(str).fold(sys.error, identity) | ||||
|  | ||||
|   def sslTypeString(st: SSLType): String = | ||||
|     st match { | ||||
|       case SSLType.SSL          => "ssl" | ||||
|       case SSLType.StartTLS     => "starttls" | ||||
|       case SSLType.NoEncryption => "none" | ||||
|     } | ||||
|  | ||||
|   def readMailAddress(str: String): Either[String, MailAddress] = | ||||
|     MailAddress.parse(str) | ||||
|  | ||||
|   def unsafeReadMailAddress(str: String): MailAddress = | ||||
|     readMailAddress(str).fold(sys.error, identity) | ||||
|  | ||||
|   def mailAddressString(ma: MailAddress): String = | ||||
|     ma.asUnicodeString | ||||
| } | ||||
| @@ -2,18 +2,15 @@ package docspell.store.impl | ||||
|  | ||||
| import java.time.format.DateTimeFormatter | ||||
| import java.time.{Instant, LocalDate} | ||||
|  | ||||
| import docspell.common.Timestamp | ||||
|  | ||||
| import docspell.common._ | ||||
| import io.circe.{Decoder, Encoder} | ||||
| import doobie._ | ||||
| //import doobie.implicits.javatime._ | ||||
| import doobie.implicits.legacy.instant._ | ||||
| import doobie.util.log.Success | ||||
| import io.circe.{Decoder, Encoder} | ||||
| import docspell.common.syntax.all._ | ||||
| import emil.{MailAddress, SSLType} | ||||
| import emil.javamail.syntax._ | ||||
|  | ||||
| import docspell.common._ | ||||
| import docspell.common.syntax.all._ | ||||
| import docspell.store.EmilUtil | ||||
|  | ||||
| trait DoobieMeta { | ||||
|  | ||||
| @@ -92,29 +89,14 @@ trait DoobieMeta { | ||||
|     Meta[String].imap(Language.unsafe)(_.iso3) | ||||
|  | ||||
|   implicit val sslType: Meta[SSLType] = | ||||
|     Meta[String].imap(DoobieMeta.readSSLType)(DoobieMeta.sslTypeString) | ||||
|     Meta[String].imap(EmilUtil.unsafeReadSSLType)(EmilUtil.sslTypeString) | ||||
|  | ||||
|   implicit val mailAddress: Meta[MailAddress] = | ||||
|     Meta[String].imap(str => MailAddress.parse(str).fold(sys.error, identity))(_.asUnicodeString) | ||||
|     Meta[String].imap(EmilUtil.unsafeReadMailAddress)(EmilUtil.mailAddressString) | ||||
| } | ||||
|  | ||||
| object DoobieMeta extends DoobieMeta { | ||||
|   import org.log4s._ | ||||
|   private val logger = getLogger | ||||
|  | ||||
|   private def readSSLType(str: String): SSLType = | ||||
|     str.toLowerCase match { | ||||
|       case "ssl"      => SSLType.SSL | ||||
|       case "starttls" => SSLType.StartTLS | ||||
|       case "none"     => SSLType.NoEncryption | ||||
|       case _          => sys.error(s"Invalid ssl-type: $str") | ||||
|     } | ||||
|  | ||||
|   private def sslTypeString(st: SSLType): String = | ||||
|     st match { | ||||
|       case SSLType.SSL          => "ssl" | ||||
|       case SSLType.StartTLS     => "starttls" | ||||
|       case SSLType.NoEncryption => "none" | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -2,6 +2,9 @@ package docspell.store.records | ||||
|  | ||||
| import doobie._ | ||||
| import doobie.implicits._ | ||||
| import cats.effect._ | ||||
| import cats.implicits._ | ||||
| import cats.data.OptionT | ||||
| import docspell.common._ | ||||
| import docspell.store.impl.Column | ||||
| import docspell.store.impl.Implicits._ | ||||
| @@ -10,11 +13,11 @@ import emil.{MailAddress, SSLType} | ||||
| case class RUserEmail( | ||||
|     id: Ident, | ||||
|     uid: Ident, | ||||
|     name: String, | ||||
|     name: Ident, | ||||
|     smtpHost: String, | ||||
|     smtpPort: Int, | ||||
|     smtpUser: String, | ||||
|     smtpPassword: Password, | ||||
|     smtpPort: Option[Int], | ||||
|     smtpUser: Option[String], | ||||
|     smtpPassword: Option[Password], | ||||
|     smtpSsl: SSLType, | ||||
|     smtpCertCheck: Boolean, | ||||
|     mailFrom: MailAddress, | ||||
| @@ -24,6 +27,67 @@ case class RUserEmail( | ||||
|  | ||||
| object RUserEmail { | ||||
|  | ||||
|   def apply[F[_]: Sync]( | ||||
|       uid: Ident, | ||||
|       name: Ident, | ||||
|       smtpHost: String, | ||||
|       smtpPort: Option[Int], | ||||
|       smtpUser: Option[String], | ||||
|       smtpPassword: Option[Password], | ||||
|       smtpSsl: SSLType, | ||||
|       smtpCertCheck: Boolean, | ||||
|       mailFrom: MailAddress, | ||||
|       mailReplyTo: Option[MailAddress] | ||||
|   ): F[RUserEmail] = | ||||
|     for { | ||||
|       now <- Timestamp.current[F] | ||||
|       id  <- Ident.randomId[F] | ||||
|     } yield RUserEmail( | ||||
|       id, | ||||
|       uid, | ||||
|       name, | ||||
|       smtpHost, | ||||
|       smtpPort, | ||||
|       smtpUser, | ||||
|       smtpPassword, | ||||
|       smtpSsl, | ||||
|       smtpCertCheck, | ||||
|       mailFrom, | ||||
|       mailReplyTo, | ||||
|       now | ||||
|     ) | ||||
|  | ||||
|   def apply( | ||||
|       accId: AccountId, | ||||
|       name: Ident, | ||||
|       smtpHost: String, | ||||
|       smtpPort: Option[Int], | ||||
|       smtpUser: Option[String], | ||||
|       smtpPassword: Option[Password], | ||||
|       smtpSsl: SSLType, | ||||
|       smtpCertCheck: Boolean, | ||||
|       mailFrom: MailAddress, | ||||
|       mailReplyTo: Option[MailAddress] | ||||
|   ): OptionT[ConnectionIO, RUserEmail] = | ||||
|     for { | ||||
|       now  <- OptionT.liftF(Timestamp.current[ConnectionIO]) | ||||
|       id   <- OptionT.liftF(Ident.randomId[ConnectionIO]) | ||||
|       user <- OptionT(RUser.findByAccount(accId)) | ||||
|     } yield RUserEmail( | ||||
|       id, | ||||
|       user.uid, | ||||
|       name, | ||||
|       smtpHost, | ||||
|       smtpPort, | ||||
|       smtpUser, | ||||
|       smtpPassword, | ||||
|       smtpSsl, | ||||
|       smtpCertCheck, | ||||
|       mailFrom, | ||||
|       mailReplyTo, | ||||
|       now | ||||
|     ) | ||||
|  | ||||
|   val table = fr"useremail" | ||||
|  | ||||
|   object Columns { | ||||
| @@ -65,7 +129,54 @@ object RUserEmail { | ||||
|       sql"${v.id},${v.uid},${v.name},${v.smtpHost},${v.smtpPort},${v.smtpUser},${v.smtpPassword},${v.smtpSsl},${v.smtpCertCheck},${v.mailFrom},${v.mailReplyTo},${v.created}" | ||||
|     ).update.run | ||||
|  | ||||
|   def update(eId: Ident, v: RUserEmail): ConnectionIO[Int] = | ||||
|     updateRow(table, id.is(eId), commas( | ||||
|       name.setTo(v.name), | ||||
|       smtpHost.setTo(v.smtpHost), | ||||
|       smtpPort.setTo(v.smtpPort), | ||||
|       smtpUser.setTo(v.smtpUser), | ||||
|       smtpPass.setTo(v.smtpPassword), | ||||
|       smtpSsl.setTo(v.smtpSsl), | ||||
|       smtpCertCheck.setTo(v.smtpCertCheck), | ||||
|       mailFrom.setTo(v.mailFrom), | ||||
|       mailReplyTo.setTo(v.mailReplyTo) | ||||
|     )).update.run | ||||
|  | ||||
|   def findByUser(userId: Ident): ConnectionIO[Vector[RUserEmail]] = | ||||
|     selectSimple(all, table, uid.is(userId)).query[RUserEmail].to[Vector] | ||||
|  | ||||
|   private def findByAccount0( | ||||
|       accId: AccountId, | ||||
|       nameQ: Option[String], | ||||
|       exact: Boolean | ||||
|   ): Query0[RUserEmail] = { | ||||
|     val mUid   = uid.prefix("m") | ||||
|     val mName  = name.prefix("m") | ||||
|     val uId    = RUser.Columns.uid.prefix("u") | ||||
|     val uColl  = RUser.Columns.cid.prefix("u") | ||||
|     val uLogin = RUser.Columns.login.prefix("u") | ||||
|     val from   = table ++ fr"m INNER JOIN" ++ RUser.table ++ fr"u ON" ++ mUid.is(uId) | ||||
|     val cond = Seq(uColl.is(accId.collective), uLogin.is(accId.user)) ++ (nameQ match { | ||||
|       case Some(str) if exact  => Seq(mName.is(str)) | ||||
|       case Some(str) if !exact => Seq(mName.lowerLike(s"%${str.toLowerCase}%")) | ||||
|       case None                => Seq.empty | ||||
|     }) | ||||
|  | ||||
|     selectSimple(all, from, and(cond)).query[RUserEmail] | ||||
|   } | ||||
|  | ||||
|   def findByAccount( | ||||
|       accId: AccountId, | ||||
|       nameQ: Option[String] | ||||
|   ): ConnectionIO[Vector[RUserEmail]] = | ||||
|     findByAccount0(accId, nameQ, false).to[Vector] | ||||
|  | ||||
|   def getByName(accId: AccountId, name: Ident): ConnectionIO[Option[RUserEmail]] = | ||||
|     findByAccount0(accId, Some(name.id), true).option | ||||
|  | ||||
|   def exists(accId: AccountId, name: Ident): ConnectionIO[Boolean] = | ||||
|     getByName(accId, name).map(_.isDefined) | ||||
|  | ||||
|   def exists(userId: Ident, connName: Ident): ConnectionIO[Boolean] = | ||||
|     selectCount(id, table, and(uid.is(userId), name.is(connName))).query[Int].unique.map(_ > 0) | ||||
| } | ||||
|   | ||||
| @@ -2,19 +2,36 @@ module Comp.EmailSettingsForm exposing | ||||
|     ( Model | ||||
|     , Msg | ||||
|     , emptyModel | ||||
|     , getSettings | ||||
|     , init | ||||
|     , update | ||||
|     , view | ||||
|     ) | ||||
|  | ||||
| import Api.Model.EmailSettings exposing (EmailSettings) | ||||
| import Comp.Dropdown | ||||
| import Comp.IntField | ||||
| import Comp.PasswordInput | ||||
| import Data.SSLType exposing (SSLType) | ||||
| import Html exposing (..) | ||||
| import Html.Attributes exposing (..) | ||||
| import Html.Events exposing (onInput) | ||||
| import Html.Events exposing (onCheck, onInput) | ||||
| import Util.Maybe | ||||
|  | ||||
|  | ||||
| type alias Model = | ||||
|     { settings : EmailSettings | ||||
|     , name : String | ||||
|     , host : String | ||||
|     , portField : Comp.IntField.Model | ||||
|     , portNum : Maybe Int | ||||
|     , user : Maybe String | ||||
|     , passField : Comp.PasswordInput.Model | ||||
|     , password : Maybe String | ||||
|     , from : String | ||||
|     , replyTo : Maybe String | ||||
|     , sslType : Comp.Dropdown.Model SSLType | ||||
|     , ignoreCertificates : Bool | ||||
|     } | ||||
|  | ||||
|  | ||||
| @@ -22,6 +39,22 @@ emptyModel : Model | ||||
| emptyModel = | ||||
|     { settings = Api.Model.EmailSettings.empty | ||||
|     , name = "" | ||||
|     , host = "" | ||||
|     , portField = Comp.IntField.init (Just 0) Nothing "SMTP Port" | ||||
|     , portNum = Nothing | ||||
|     , user = Nothing | ||||
|     , passField = Comp.PasswordInput.init | ||||
|     , password = Nothing | ||||
|     , from = "" | ||||
|     , replyTo = Nothing | ||||
|     , sslType = | ||||
|         Comp.Dropdown.makeSingleList | ||||
|             { makeOption = \s -> { value = Data.SSLType.toString s, text = Data.SSLType.label s } | ||||
|             , placeholder = "" | ||||
|             , options = Data.SSLType.all | ||||
|             , selected = Just Data.SSLType.None | ||||
|             } | ||||
|     , ignoreCertificates = False | ||||
|     } | ||||
|  | ||||
|  | ||||
| @@ -29,21 +62,103 @@ init : EmailSettings -> Model | ||||
| init ems = | ||||
|     { settings = ems | ||||
|     , name = ems.name | ||||
|     , host = ems.smtpHost | ||||
|     , portField = Comp.IntField.init (Just 0) Nothing "SMTP Port" | ||||
|     , portNum = ems.smtpPort | ||||
|     , user = ems.smtpUser | ||||
|     , passField = Comp.PasswordInput.init | ||||
|     , password = ems.smtpPassword | ||||
|     , from = ems.from | ||||
|     , replyTo = ems.replyTo | ||||
|     , sslType = | ||||
|         Comp.Dropdown.makeSingleList | ||||
|             { makeOption = \s -> { value = Data.SSLType.toString s, text = Data.SSLType.label s } | ||||
|             , placeholder = "" | ||||
|             , options = Data.SSLType.all | ||||
|             , selected = Data.SSLType.fromString ems.sslType | ||||
|             } | ||||
|     , ignoreCertificates = ems.ignoreCertificates | ||||
|     } | ||||
|  | ||||
|  | ||||
| getSettings : Model -> ( Maybe String, EmailSettings ) | ||||
| getSettings model = | ||||
|     ( Util.Maybe.fromString model.settings.name | ||||
|     , { name = model.name | ||||
|       , smtpHost = model.host | ||||
|       , smtpUser = model.user | ||||
|       , smtpPort = model.portNum | ||||
|       , smtpPassword = model.password | ||||
|       , from = model.from | ||||
|       , replyTo = model.replyTo | ||||
|       , sslType = | ||||
|             Comp.Dropdown.getSelected model.sslType | ||||
|                 |> List.head | ||||
|                 |> Maybe.withDefault Data.SSLType.None | ||||
|                 |> Data.SSLType.toString | ||||
|       , ignoreCertificates = model.ignoreCertificates | ||||
|       } | ||||
|     ) | ||||
|  | ||||
|  | ||||
| type Msg | ||||
|     = SetName String | ||||
|     | SetHost String | ||||
|     | PortMsg Comp.IntField.Msg | ||||
|     | SetUser String | ||||
|     | PassMsg Comp.PasswordInput.Msg | ||||
|     | SSLTypeMsg (Comp.Dropdown.Msg SSLType) | ||||
|     | SetFrom String | ||||
|     | SetReplyTo String | ||||
|     | ToggleCheckCert | ||||
|  | ||||
|  | ||||
| isValid : Model -> Bool | ||||
| isValid model = | ||||
|     True | ||||
|     model.host /= "" && model.name /= "" | ||||
|  | ||||
|  | ||||
| update : Msg -> Model -> ( Model, Cmd Msg ) | ||||
| update msg model = | ||||
|     ( model, Cmd.none ) | ||||
|     case msg of | ||||
|         SetName str -> | ||||
|             ( { model | name = str }, Cmd.none ) | ||||
|  | ||||
|         SetHost str -> | ||||
|             ( { model | host = str }, Cmd.none ) | ||||
|  | ||||
|         PortMsg m -> | ||||
|             let | ||||
|                 ( pm, val ) = | ||||
|                     Comp.IntField.update m model.portField | ||||
|             in | ||||
|             ( { model | portField = pm, portNum = val }, Cmd.none ) | ||||
|  | ||||
|         SetUser str -> | ||||
|             ( { model | user = Util.Maybe.fromString str }, Cmd.none ) | ||||
|  | ||||
|         PassMsg m -> | ||||
|             let | ||||
|                 ( pm, val ) = | ||||
|                     Comp.PasswordInput.update m model.passField | ||||
|             in | ||||
|             ( { model | passField = pm, password = val }, Cmd.none ) | ||||
|  | ||||
|         SSLTypeMsg m -> | ||||
|             let | ||||
|                 ( sm, sc ) = | ||||
|                     Comp.Dropdown.update m model.sslType | ||||
|             in | ||||
|             ( { model | sslType = sm }, Cmd.map SSLTypeMsg sc ) | ||||
|  | ||||
|         SetFrom str -> | ||||
|             ( { model | from = str }, Cmd.none ) | ||||
|  | ||||
|         SetReplyTo str -> | ||||
|             ( { model | replyTo = Util.Maybe.fromString str }, Cmd.none ) | ||||
|  | ||||
|         ToggleCheckCert -> | ||||
|             ( { model | ignoreCertificates = not model.ignoreCertificates }, Cmd.none ) | ||||
|  | ||||
|  | ||||
| view : Model -> Html Msg | ||||
| @@ -61,8 +176,81 @@ view model = | ||||
|                 [ type_ "text" | ||||
|                 , value model.name | ||||
|                 , onInput SetName | ||||
|                 , placeholder "Connection name" | ||||
|                 , placeholder "Connection name, e.g. 'gmail.com'" | ||||
|                 ] | ||||
|                 [] | ||||
|             , div [ class "ui info message" ] | ||||
|                 [ text "The connection name must not contain whitespace or special characters." | ||||
|                 ] | ||||
|             ] | ||||
|         , div [ class "fields" ] | ||||
|             [ div [ class "fifteen wide required field" ] | ||||
|                 [ label [] [ text "SMTP Host" ] | ||||
|                 , input | ||||
|                     [ type_ "text" | ||||
|                     , placeholder "SMTP host name, e.g. 'mail.gmail.com'" | ||||
|                     , value model.host | ||||
|                     , onInput SetHost | ||||
|                     ] | ||||
|                     [] | ||||
|                 ] | ||||
|             , Html.map PortMsg (Comp.IntField.view model.portNum model.portField) | ||||
|             ] | ||||
|         , div [ class "two fields" ] | ||||
|             [ div [ class "field" ] | ||||
|                 [ label [] [ text "SMTP User" ] | ||||
|                 , input | ||||
|                     [ type_ "text" | ||||
|                     , placeholder "SMTP Username, e.g. 'your.name@gmail.com'" | ||||
|                     , Maybe.withDefault "" model.user |> value | ||||
|                     , onInput SetUser | ||||
|                     ] | ||||
|                     [] | ||||
|                 ] | ||||
|             , div [ class "field" ] | ||||
|                 [ label [] [ text "SMTP Password" ] | ||||
|                 , Html.map PassMsg (Comp.PasswordInput.view model.password model.passField) | ||||
|                 ] | ||||
|             ] | ||||
|         , div [ class "two fields" ] | ||||
|             [ div [ class "required field" ] | ||||
|                 [ label [] [ text "From Address" ] | ||||
|                 , input | ||||
|                     [ type_ "text" | ||||
|                     , placeholder "Sender E-Mail address" | ||||
|                     , value model.from | ||||
|                     , onInput SetFrom | ||||
|                     ] | ||||
|                     [] | ||||
|                 ] | ||||
|             , div [ class "field" ] | ||||
|                 [ label [] [ text "Reply-To" ] | ||||
|                 , input | ||||
|                     [ type_ "text" | ||||
|                     , placeholder "Optional reply-to E-Mail address" | ||||
|                     , Maybe.withDefault "" model.replyTo |> value | ||||
|                     , onInput SetReplyTo | ||||
|                     ] | ||||
|                     [] | ||||
|                 ] | ||||
|             ] | ||||
|         , div [ class "two fields" ] | ||||
|             [ div [ class "inline field" ] | ||||
|                 [ div [ class "ui checkbox" ] | ||||
|                     [ input | ||||
|                         [ type_ "checkbox" | ||||
|                         , checked model.ignoreCertificates | ||||
|                         , onCheck (\_ -> ToggleCheckCert) | ||||
|                         ] | ||||
|                         [] | ||||
|                     , label [] [ text "Ignore certificate check" ] | ||||
|                     ] | ||||
|                 ] | ||||
|             ] | ||||
|         , div [ class "two fields" ] | ||||
|             [ div [ class "field" ] | ||||
|                 [ label [] [ text "SSL" ] | ||||
|                 , Html.map SSLTypeMsg (Comp.Dropdown.view model.sslType) | ||||
|                 ] | ||||
|             ] | ||||
|         ] | ||||
|   | ||||
| @@ -17,6 +17,7 @@ import Comp.YesNoDimmer | ||||
| import Data.Flags exposing (Flags) | ||||
| import Html exposing (..) | ||||
| import Html.Attributes exposing (..) | ||||
| import Html.Events exposing (onClick, onInput) | ||||
|  | ||||
|  | ||||
| type alias Model = | ||||
| @@ -25,6 +26,7 @@ type alias Model = | ||||
|     , viewMode : ViewMode | ||||
|     , formError : Maybe String | ||||
|     , loading : Bool | ||||
|     , query : String | ||||
|     , deleteConfirm : Comp.YesNoDimmer.Model | ||||
|     } | ||||
|  | ||||
| @@ -36,6 +38,7 @@ emptyModel = | ||||
|     , viewMode = Table | ||||
|     , formError = Nothing | ||||
|     , loading = False | ||||
|     , query = "" | ||||
|     , deleteConfirm = Comp.YesNoDimmer.emptyModel | ||||
|     } | ||||
|  | ||||
| @@ -53,18 +56,139 @@ type ViewMode | ||||
| type Msg | ||||
|     = TableMsg Comp.EmailSettingsTable.Msg | ||||
|     | FormMsg Comp.EmailSettingsForm.Msg | ||||
|     | SetQuery String | ||||
|     | InitNew | ||||
|     | YesNoMsg Comp.YesNoDimmer.Msg | ||||
|     | RequestDelete | ||||
|     | SetViewMode ViewMode | ||||
|  | ||||
|  | ||||
| update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) | ||||
| update flags msg model = | ||||
|     ( model, Cmd.none ) | ||||
|     case msg of | ||||
|         InitNew -> | ||||
|             let | ||||
|                 ems = | ||||
|                     Api.Model.EmailSettings.empty | ||||
|  | ||||
|                 nm = | ||||
|                     { model | ||||
|                         | viewMode = Form | ||||
|                         , formError = Nothing | ||||
|                         , formModel = Comp.EmailSettingsForm.init ems | ||||
|                     } | ||||
|             in | ||||
|             ( nm, Cmd.none ) | ||||
|  | ||||
|         TableMsg m -> | ||||
|             let | ||||
|                 ( tm, tc ) = | ||||
|                     Comp.EmailSettingsTable.update m model.tableModel | ||||
|             in | ||||
|             ( { model | tableModel = tm }, Cmd.map TableMsg tc ) | ||||
|  | ||||
|         FormMsg m -> | ||||
|             let | ||||
|                 ( fm, fc ) = | ||||
|                     Comp.EmailSettingsForm.update m model.formModel | ||||
|             in | ||||
|             ( { model | formModel = fm }, Cmd.map FormMsg fc ) | ||||
|  | ||||
|         SetQuery str -> | ||||
|             ( { model | query = str }, Cmd.none ) | ||||
|  | ||||
|         YesNoMsg m -> | ||||
|             let | ||||
|                 ( dm, flag ) = | ||||
|                     Comp.YesNoDimmer.update m model.deleteConfirm | ||||
|             in | ||||
|             ( { model | deleteConfirm = dm }, Cmd.none ) | ||||
|  | ||||
|         RequestDelete -> | ||||
|             ( model, Cmd.none ) | ||||
|  | ||||
|         SetViewMode m -> | ||||
|             ( { model | viewMode = m }, Cmd.none ) | ||||
|  | ||||
|  | ||||
| view : Model -> Html Msg | ||||
| view model = | ||||
|     case model.viewMode of | ||||
|         Table -> | ||||
|             Html.map TableMsg (Comp.EmailSettingsTable.view model.tableModel) | ||||
|             viewTable model | ||||
|  | ||||
|         Form -> | ||||
|             Html.map FormMsg (Comp.EmailSettingsForm.view model.formModel) | ||||
|             viewForm model | ||||
|  | ||||
|  | ||||
| viewTable : Model -> Html Msg | ||||
| viewTable model = | ||||
|     div [] | ||||
|         [ div [ class "ui secondary menu container" ] | ||||
|             [ div [ class "ui container" ] | ||||
|                 [ div [ class "fitted-item" ] | ||||
|                     [ div [ class "ui icon input" ] | ||||
|                         [ input | ||||
|                             [ type_ "text" | ||||
|                             , onInput SetQuery | ||||
|                             , value model.query | ||||
|                             , placeholder "Search…" | ||||
|                             ] | ||||
|                             [] | ||||
|                         , i [ class "ui search icon" ] | ||||
|                             [] | ||||
|                         ] | ||||
|                     ] | ||||
|                 , div [ class "right menu" ] | ||||
|                     [ div [ class "fitted-item" ] | ||||
|                         [ a | ||||
|                             [ class "ui primary button" | ||||
|                             , href "#" | ||||
|                             , onClick InitNew | ||||
|                             ] | ||||
|                             [ i [ class "plus icon" ] [] | ||||
|                             , text "New Settings" | ||||
|                             ] | ||||
|                         ] | ||||
|                     ] | ||||
|                 ] | ||||
|             ] | ||||
|         , Html.map TableMsg (Comp.EmailSettingsTable.view model.tableModel) | ||||
|         ] | ||||
|  | ||||
|  | ||||
| viewForm : Model -> Html Msg | ||||
| viewForm model = | ||||
|     div [ class "ui segment" ] | ||||
|         [ Html.map YesNoMsg (Comp.YesNoDimmer.view model.deleteConfirm) | ||||
|         , Html.map FormMsg (Comp.EmailSettingsForm.view model.formModel) | ||||
|         , div | ||||
|             [ classList | ||||
|                 [ ( "ui error message", True ) | ||||
|                 , ( "invisible", model.formError == Nothing ) | ||||
|                 ] | ||||
|             ] | ||||
|             [ Maybe.withDefault "" model.formError |> text | ||||
|             ] | ||||
|         , div [ class "ui divider" ] [] | ||||
|         , button [ class "ui primary button" ] | ||||
|             [ text "Submit" | ||||
|             ] | ||||
|         , a [ class "ui secondary button", onClick (SetViewMode Table), href "" ] | ||||
|             [ text "Cancel" | ||||
|             ] | ||||
|         , if model.formModel.settings.name /= "" then | ||||
|             a [ class "ui right floated red button", href "", onClick RequestDelete ] | ||||
|                 [ text "Delete" ] | ||||
|  | ||||
|           else | ||||
|             span [] [] | ||||
|         , div | ||||
|             [ classList | ||||
|                 [ ( "ui dimmer", True ) | ||||
|                 , ( "active", model.loading ) | ||||
|                 ] | ||||
|             ] | ||||
|             [ div [ class "ui loader" ] [] | ||||
|             ] | ||||
|         ] | ||||
|   | ||||
							
								
								
									
										108
									
								
								modules/webapp/src/main/elm/Comp/IntField.elm
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								modules/webapp/src/main/elm/Comp/IntField.elm
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | ||||
| module Comp.IntField exposing (Model, Msg, init, update, view) | ||||
|  | ||||
| import Html exposing (..) | ||||
| import Html.Attributes exposing (..) | ||||
| import Html.Events exposing (onInput) | ||||
|  | ||||
|  | ||||
| type alias Model = | ||||
|     { min : Maybe Int | ||||
|     , max : Maybe Int | ||||
|     , label : String | ||||
|     , error : Maybe String | ||||
|     , lastInput : String | ||||
|     } | ||||
|  | ||||
|  | ||||
| type Msg | ||||
|     = SetValue String | ||||
|  | ||||
|  | ||||
| init : Maybe Int -> Maybe Int -> String -> Model | ||||
| init min max label = | ||||
|     { min = min | ||||
|     , max = max | ||||
|     , label = label | ||||
|     , error = Nothing | ||||
|     , lastInput = "" | ||||
|     } | ||||
|  | ||||
|  | ||||
| tooLow : Model -> Int -> Bool | ||||
| tooLow model n = | ||||
|     Maybe.map ((<) n) model.min | ||||
|         |> Maybe.withDefault False | ||||
|  | ||||
|  | ||||
| tooHigh : Model -> Int -> Bool | ||||
| tooHigh model n = | ||||
|     Maybe.map ((>) n) model.max | ||||
|         |> Maybe.withDefault False | ||||
|  | ||||
|  | ||||
| update : Msg -> Model -> ( Model, Maybe Int ) | ||||
| update msg model = | ||||
|     let | ||||
|         tooHighError = | ||||
|             Maybe.withDefault 0 model.max | ||||
|                 |> String.fromInt | ||||
|                 |> (++) "Number must be <= " | ||||
|  | ||||
|         tooLowError = | ||||
|             Maybe.withDefault 0 model.min | ||||
|                 |> String.fromInt | ||||
|                 |> (++) "Number must be >= " | ||||
|     in | ||||
|     case msg of | ||||
|         SetValue str -> | ||||
|             let | ||||
|                 m = | ||||
|                     { model | lastInput = str } | ||||
|             in | ||||
|             case String.toInt str of | ||||
|                 Just n -> | ||||
|                     if tooLow model n then | ||||
|                         ( { m | error = Just tooLowError } | ||||
|                         , Nothing | ||||
|                         ) | ||||
|  | ||||
|                     else if tooHigh model n then | ||||
|                         ( { m | error = Just tooHighError } | ||||
|                         , Nothing | ||||
|                         ) | ||||
|  | ||||
|                     else | ||||
|                         ( { m | error = Nothing }, Just n ) | ||||
|  | ||||
|                 Nothing -> | ||||
|                     ( { m | error = Just ("'" ++ str ++ "' is not a valid number!") } | ||||
|                     , Nothing | ||||
|                     ) | ||||
|  | ||||
|  | ||||
| view : Maybe Int -> Model -> Html Msg | ||||
| view nval model = | ||||
|     div | ||||
|         [ classList | ||||
|             [ ( "field", True ) | ||||
|             , ( "error", model.error /= Nothing ) | ||||
|             ] | ||||
|         ] | ||||
|         [ label [] [ text model.label ] | ||||
|         , input | ||||
|             [ type_ "text" | ||||
|             , Maybe.map String.fromInt nval | ||||
|                 |> Maybe.withDefault model.lastInput | ||||
|                 |> value | ||||
|             , onInput SetValue | ||||
|             ] | ||||
|             [] | ||||
|         , div | ||||
|             [ classList | ||||
|                 [ ( "ui pointing red basic label", True ) | ||||
|                 , ( "hidden", model.error == Nothing ) | ||||
|                 ] | ||||
|             ] | ||||
|             [ Maybe.withDefault "" model.error |> text | ||||
|             ] | ||||
|         ] | ||||
							
								
								
									
										74
									
								
								modules/webapp/src/main/elm/Comp/PasswordInput.elm
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								modules/webapp/src/main/elm/Comp/PasswordInput.elm
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,74 @@ | ||||
| module Comp.PasswordInput exposing | ||||
|     ( Model | ||||
|     , Msg | ||||
|     , init | ||||
|     , update | ||||
|     , view | ||||
|     ) | ||||
|  | ||||
| import Html exposing (..) | ||||
| import Html.Attributes exposing (..) | ||||
| import Html.Events exposing (onClick, onInput) | ||||
| import Util.Maybe | ||||
|  | ||||
|  | ||||
| type alias Model = | ||||
|     { show : Bool | ||||
|     } | ||||
|  | ||||
|  | ||||
| init : Model | ||||
| init = | ||||
|     { show = False | ||||
|     } | ||||
|  | ||||
|  | ||||
| type Msg | ||||
|     = ToggleShow (Maybe String) | ||||
|     | SetPassword String | ||||
|  | ||||
|  | ||||
| update : Msg -> Model -> ( Model, Maybe String ) | ||||
| update msg model = | ||||
|     case msg of | ||||
|         ToggleShow pw -> | ||||
|             ( { model | show = not model.show } | ||||
|             , pw | ||||
|             ) | ||||
|  | ||||
|         SetPassword str -> | ||||
|             let | ||||
|                 pw = | ||||
|                     Util.Maybe.fromString str | ||||
|             in | ||||
|             ( model, pw ) | ||||
|  | ||||
|  | ||||
| view : Maybe String -> Model -> Html Msg | ||||
| view pw model = | ||||
|     div [ class "ui  left action input" ] | ||||
|         [ button | ||||
|             [ class "ui icon button" | ||||
|             , type_ "button" | ||||
|             , onClick (ToggleShow pw) | ||||
|             ] | ||||
|             [ i | ||||
|                 [ classList | ||||
|                     [ ( "ui eye icon", True ) | ||||
|                     , ( "slash", model.show ) | ||||
|                     ] | ||||
|                 ] | ||||
|                 [] | ||||
|             ] | ||||
|         , input | ||||
|             [ type_ <| | ||||
|                 if model.show then | ||||
|                     "text" | ||||
|  | ||||
|                 else | ||||
|                     "password" | ||||
|             , onInput SetPassword | ||||
|             , Maybe.withDefault "" pw |> value | ||||
|             ] | ||||
|             [] | ||||
|         ] | ||||
							
								
								
									
										60
									
								
								modules/webapp/src/main/elm/Data/SSLType.elm
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								modules/webapp/src/main/elm/Data/SSLType.elm
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| module Data.SSLType exposing | ||||
|     ( SSLType(..) | ||||
|     , all | ||||
|     , fromString | ||||
|     , label | ||||
|     , toString | ||||
|     ) | ||||
|  | ||||
|  | ||||
| type SSLType | ||||
|     = None | ||||
|     | SSL | ||||
|     | StartTLS | ||||
|  | ||||
|  | ||||
| all : List SSLType | ||||
| all = | ||||
|     [ None, SSL, StartTLS ] | ||||
|  | ||||
|  | ||||
| toString : SSLType -> String | ||||
| toString st = | ||||
|     case st of | ||||
|         None -> | ||||
|             "none" | ||||
|  | ||||
|         SSL -> | ||||
|             "ssl" | ||||
|  | ||||
|         StartTLS -> | ||||
|             "starttls" | ||||
|  | ||||
|  | ||||
| fromString : String -> Maybe SSLType | ||||
| fromString str = | ||||
|     case String.toLower str of | ||||
|         "none" -> | ||||
|             Just None | ||||
|  | ||||
|         "ssl" -> | ||||
|             Just SSL | ||||
|  | ||||
|         "starttls" -> | ||||
|             Just StartTLS | ||||
|  | ||||
|         _ -> | ||||
|             Nothing | ||||
|  | ||||
|  | ||||
| label : SSLType -> String | ||||
| label st = | ||||
|     case st of | ||||
|         None -> | ||||
|             "None" | ||||
|  | ||||
|         SSL -> | ||||
|             "SSL/TLS" | ||||
|  | ||||
|         StartTLS -> | ||||
|             "StartTLS" | ||||
| @@ -1,5 +1,6 @@ | ||||
| module Util.Maybe exposing | ||||
|     ( isEmpty | ||||
|     ( fromString | ||||
|     , isEmpty | ||||
|     , nonEmpty | ||||
|     , or | ||||
|     , withDefault | ||||
| @@ -38,3 +39,16 @@ or listma = | ||||
|  | ||||
|                 Nothing -> | ||||
|                     or els | ||||
|  | ||||
|  | ||||
| fromString : String -> Maybe String | ||||
| fromString str = | ||||
|     let | ||||
|         s = | ||||
|             String.trim str | ||||
|     in | ||||
|     if s == "" then | ||||
|         Nothing | ||||
|  | ||||
|     else | ||||
|         Just str | ||||
|   | ||||
		Reference in New Issue
	
	Block a user