diff --git a/build.sbt b/build.sbt
index 7284171c..ad809ec5 100644
--- a/build.sbt
+++ b/build.sbt
@@ -148,7 +148,8 @@ val store = project.in(file("modules/store")).
Dependencies.fs2 ++
Dependencies.databases ++
Dependencies.flyway ++
- Dependencies.loggingApi
+ Dependencies.loggingApi ++
+ Dependencies.emil
).dependsOn(common)
val text = project.in(file("modules/text")).
@@ -225,7 +226,8 @@ val backend = project.in(file("modules/backend")).
Dependencies.loggingApi ++
Dependencies.fs2 ++
Dependencies.bcrypt ++
- Dependencies.http4sClient
+ Dependencies.http4sClient ++
+ Dependencies.emil
).dependsOn(store)
val webapp = project.in(file("modules/webapp")).
diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala
index 05ed9347..59982761 100644
--- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala
+++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala
@@ -9,6 +9,7 @@ import docspell.store.ops.ONode
import docspell.store.queue.JobQueue
import scala.concurrent.ExecutionContext
+import emil.javamail.JavaMailEmil
trait BackendApp[F[_]] {
@@ -23,14 +24,16 @@ trait BackendApp[F[_]] {
def node: ONode[F]
def job: OJob[F]
def item: OItem[F]
+ def mail: OMail[F]
}
object BackendApp {
- def create[F[_]: ConcurrentEffect](
+ def create[F[_]: ConcurrentEffect: ContextShift](
cfg: Config,
store: Store[F],
- httpClientEc: ExecutionContext
+ httpClientEc: ExecutionContext,
+ blocker: Blocker
): Resource[F, BackendApp[F]] =
for {
queue <- JobQueue(store)
@@ -45,6 +48,7 @@ object BackendApp {
nodeImpl <- ONode(store)
jobImpl <- OJob(store, httpClientEc)
itemImpl <- OItem(store)
+ mailImpl <- OMail(store, JavaMailEmil(blocker))
} yield new BackendApp[F] {
val login: Login[F] = loginImpl
val signup: OSignup[F] = signupImpl
@@ -57,6 +61,7 @@ object BackendApp {
val node = nodeImpl
val job = jobImpl
val item = itemImpl
+ val mail = mailImpl
}
def apply[F[_]: ConcurrentEffect: ContextShift](
@@ -67,6 +72,6 @@ object BackendApp {
): Resource[F, BackendApp[F]] =
for {
store <- Store.create(cfg.jdbc, connectEC, blocker)
- backend <- create(cfg, store, httpClientEc)
+ backend <- create(cfg, store, httpClientEc, blocker)
} yield backend
}
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala
new file mode 100644
index 00000000..5fad31bd
--- /dev/null
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala
@@ -0,0 +1,215 @@
+package docspell.backend.ops
+
+import fs2.Stream
+import cats.effect._
+import cats.implicits._
+import cats.data.OptionT
+import emil._
+import emil.javamail.syntax._
+import bitpeace.{FileMeta, RangeDef}
+
+import docspell.common._
+import docspell.store._
+import docspell.store.records._
+import docspell.store.queries.QMails
+import OMail.{ItemMail, Sent, SmtpSettings}
+
+trait OMail[F[_]] {
+
+ def getSettings(accId: AccountId, nameQ: Option[String]): F[Vector[RUserEmail]]
+
+ def findSettings(accId: AccountId, name: Ident): OptionT[F, RUserEmail]
+
+ def createSettings(accId: AccountId, data: SmtpSettings): F[AddResult]
+
+ def updateSettings(accId: AccountId, name: Ident, data: OMail.SmtpSettings): F[Int]
+
+ def deleteSettings(accId: AccountId, name: Ident): F[Int]
+
+ def sendMail(accId: AccountId, name: Ident, m: ItemMail): F[SendResult]
+
+ def getSentMailsForItem(accId: AccountId, itemId: Ident): F[Vector[Sent]]
+
+ def getSentMail(accId: AccountId, mailId: Ident): OptionT[F, Sent]
+
+ def deleteSentMail(accId: AccountId, mailId: Ident): F[Int]
+}
+
+object OMail {
+
+ case class Sent(
+ id: Ident,
+ senderLogin: Ident,
+ connectionName: Ident,
+ recipients: List[MailAddress],
+ subject: String,
+ body: String,
+ created: Timestamp
+ )
+
+ object Sent {
+
+ def create(r: RSentMail, login: Ident): Sent =
+ Sent(r.id, login, r.connName, r.recipients, r.subject, r.body, r.created)
+ }
+
+ case class ItemMail(
+ item: Ident,
+ subject: String,
+ recipients: List[MailAddress],
+ body: String,
+ attach: AttachSelection
+ )
+
+ sealed trait AttachSelection {
+ def filter(v: Vector[(RAttachment, FileMeta)]): Vector[(RAttachment, FileMeta)]
+ }
+ object AttachSelection {
+ case object All extends AttachSelection {
+ def filter(v: Vector[(RAttachment, FileMeta)]): Vector[(RAttachment, FileMeta)] = v
+ }
+ case class Selected(ids: List[Ident]) extends AttachSelection {
+ def filter(v: Vector[(RAttachment, FileMeta)]): Vector[(RAttachment, FileMeta)] = {
+ val set = ids.toSet
+ v.filter(set contains _._1.id)
+ }
+ }
+ }
+
+ case class SmtpSettings(
+ name: Ident,
+ smtpHost: String,
+ smtpPort: Option[Int],
+ smtpUser: Option[String],
+ smtpPassword: Option[Password],
+ smtpSsl: SSLType,
+ smtpCertCheck: Boolean,
+ mailFrom: MailAddress,
+ mailReplyTo: Option[MailAddress]
+ ) {
+
+ def toRecord(accId: AccountId) =
+ RUserEmail.fromAccount(
+ accId,
+ name,
+ smtpHost,
+ smtpPort,
+ smtpUser,
+ smtpPassword,
+ smtpSsl,
+ smtpCertCheck,
+ mailFrom,
+ mailReplyTo
+ )
+ }
+
+ def apply[F[_]: Effect](store: Store[F], emil: Emil[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 findSettings(accId: AccountId, name: Ident): OptionT[F, RUserEmail] =
+ OptionT(store.transact(RUserEmail.getByName(accId, name)))
+
+ def createSettings(accId: AccountId, s: SmtpSettings): F[AddResult] =
+ (for {
+ ru <- OptionT(store.transact(s.toRecord(accId).value))
+ ins = RUserEmail.insert(ru)
+ exists = RUserEmail.exists(ru.uid, ru.name)
+ res <- OptionT.liftF(store.add(ins, exists))
+ } yield res).getOrElse(AddResult.Failure(new Exception("User not found")))
+
+ def updateSettings(accId: AccountId, name: Ident, data: SmtpSettings): F[Int] = {
+ val op = for {
+ um <- OptionT(RUserEmail.getByName(accId, name))
+ ru <- data.toRecord(accId)
+ n <- OptionT.liftF(RUserEmail.update(um.id, ru))
+ } yield n
+
+ store.transact(op.value).map(_.getOrElse(0))
+ }
+
+ def deleteSettings(accId: AccountId, name: Ident): F[Int] =
+ store.transact(RUserEmail.delete(accId, name))
+
+ def sendMail(accId: AccountId, name: Ident, m: ItemMail): F[SendResult] = {
+
+ val getSettings: OptionT[F, RUserEmail] =
+ OptionT(store.transact(RUserEmail.getByName(accId, name)))
+
+ def createMail(sett: RUserEmail): OptionT[F, Mail[F]] = {
+ import _root_.emil.builder._
+
+ for {
+ _ <- OptionT.liftF(store.transact(RItem.existsById(m.item))).filter(identity)
+ ras <- OptionT.liftF(
+ store.transact(RAttachment.findByItemAndCollectiveWithMeta(m.item, accId.collective))
+ )
+ } yield {
+ val addAttach = m.attach.filter(ras).map { a =>
+ Attach[F](Stream.emit(a._2).through(store.bitpeace.fetchData2(RangeDef.all)))
+ .withFilename(a._1.name)
+ .withLength(a._2.length)
+ .withMimeType(_root_.emil.MimeType.parse(a._2.mimetype.asString).toOption)
+ }
+ val fields: Seq[Trans[F]] = Seq(
+ From(sett.mailFrom),
+ Tos(m.recipients),
+ Subject(m.subject),
+ TextBody[F](m.body)
+ )
+
+ MailBuilder.fromSeq[F](fields).addAll(addAttach).build
+ }
+ }
+
+ def sendMail(cfg: MailConfig, mail: Mail[F]): F[Either[SendResult, String]] =
+ emil(cfg).send(mail).map(_.head).attempt.map(_.left.map(SendResult.SendFailure))
+
+ def storeMail(msgId: String, cfg: RUserEmail): F[Either[SendResult, Ident]] = {
+ val save = for {
+ data <- RSentMail.forItem(
+ m.item,
+ accId,
+ msgId,
+ cfg.mailFrom,
+ name,
+ m.subject,
+ m.recipients,
+ m.body
+ )
+ _ <- OptionT.liftF(RSentMail.insert(data._1))
+ _ <- OptionT.liftF(RSentMailItem.insert(data._2))
+ } yield data._1.id
+
+ store.transact(save.value).attempt.map {
+ case Right(Some(id)) => Right(id)
+ case Right(None) =>
+ Left(SendResult.StoreFailure(new Exception(s"Could not find user to save mail.")))
+ case Left(ex) => Left(SendResult.StoreFailure(ex))
+ }
+ }
+
+ (for {
+ mailCfg <- getSettings
+ mail <- createMail(mailCfg)
+ mid <- OptionT.liftF(sendMail(mailCfg.toMailConfig, mail))
+ res <- mid.traverse(id => OptionT.liftF(storeMail(id, mailCfg)))
+ conv = res.fold(identity, _.fold(identity, id => SendResult.Success(id)))
+ } yield conv).getOrElse(SendResult.NotFound)
+ }
+
+ def getSentMailsForItem(accId: AccountId, itemId: Ident): F[Vector[Sent]] =
+ store
+ .transact(QMails.findMails(accId.collective, itemId))
+ .map(_.map(t => Sent.create(t._1, t._2)))
+
+ def getSentMail(accId: AccountId, mailId: Ident): OptionT[F, Sent] =
+ OptionT(store.transact(QMails.findMail(accId.collective, mailId))).map(t =>
+ Sent.create(t._1, t._2)
+ )
+
+ def deleteSentMail(accId: AccountId, mailId: Ident): F[Int] =
+ store.transact(QMails.delete(accId.collective, mailId))
+ })
+}
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala b/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala
new file mode 100644
index 00000000..f64f48f6
--- /dev/null
+++ b/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala
@@ -0,0 +1,26 @@
+package docspell.backend.ops
+
+import docspell.common._
+
+sealed trait SendResult
+
+object SendResult {
+
+ /** Mail was successfully sent and stored to db.
+ */
+ case class Success(id: Ident) extends SendResult
+
+ /** There was a failure sending the mail. The mail is then not saved
+ * to db.
+ */
+ case class SendFailure(ex: Throwable) extends SendResult
+
+ /** The mail was successfully sent, but storing to db failed.
+ */
+ case class StoreFailure(ex: Throwable) extends SendResult
+
+ /** Something could not be found required for sending (mail configs,
+ * items etc).
+ */
+ case object NotFound extends SendResult
+}
diff --git a/modules/common/src/main/scala/docspell/common/Ident.scala b/modules/common/src/main/scala/docspell/common/Ident.scala
index 199bd225..6314dffc 100644
--- a/modules/common/src/main/scala/docspell/common/Ident.scala
+++ b/modules/common/src/main/scala/docspell/common/Ident.scala
@@ -15,7 +15,7 @@ object Ident {
implicit val identEq: Eq[Ident] =
Eq.by(_.id)
- val chars: Set[Char] = (('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_").toSet
+ val chars: Set[Char] = (('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.").toSet
def randomUUID[F[_]: Sync]: F[Ident] =
Sync[F].delay(unsafe(UUID.randomUUID.toString))
@@ -32,7 +32,7 @@ object Ident {
def fromString(s: String): Either[String, Ident] =
if (s.forall(chars.contains)) Right(new Ident(s))
- else Left(s"Invalid identifier: $s. Allowed chars: ${chars.mkString}")
+ else Left(s"Invalid identifier: '$s'. Allowed chars: ${chars.toList.sorted.mkString}")
def fromBytes(bytes: ByteVector): Ident =
unsafe(bytes.toBase58)
diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml
index 88eba465..2e9a69e3 100644
--- a/modules/restapi/src/main/resources/docspell-openapi.yml
+++ b/modules/restapi/src/main/resources/docspell-openapi.yml
@@ -125,6 +125,8 @@ paths:
The result shows all items that contains a file with the given
checksum.
+ security:
+ - authTokenHeader: []
parameters:
- $ref: "#/components/parameters/checksum"
responses:
@@ -159,6 +161,8 @@ paths:
* application/pdf
Support for more types might be added.
+ security:
+ - authTokenHeader: []
requestBody:
content:
multipart/form-data:
@@ -1188,6 +1192,8 @@ paths:
Get the current state of the job qeue. The job qeue contains
all processing tasks and other long-running operations. All
users/collectives share processing resources.
+ security:
+ - authTokenHeader: []
responses:
200:
description: Ok
@@ -1203,6 +1209,8 @@ paths:
Tries to cancel a job and remove it from the queue. If the job
is running, a cancel request is send to the corresponding joex
instance. Otherwise the job is removed from the queue.
+ security:
+ - authTokenHeader: []
parameters:
- $ref: "#/components/parameters/id"
responses:
@@ -1212,8 +1220,291 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
+ /sec/email/settings:
+ get:
+ tags: [ E-Mail ]
+ summary: List email settings for current user.
+ description: |
+ List all available e-mail settings for the current user.
+ E-Mail settings specify smtp connections that can be used to
+ sent e-mails.
+
+ Multiple e-mail settings can be specified, they are
+ distinguished by their `name`. The query `q` parameter does a
+ simple substring search in the connection name.
+ security:
+ - authTokenHeader: []
+ parameters:
+ - $ref: "#/components/parameters/q"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EmailSettingsList"
+ post:
+ tags: [ E-Mail ]
+ summary: Create new email settings
+ description: |
+ Create new e-mail settings.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EmailSettings"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+ /sec/email/settings/{name}:
+ parameters:
+ - $ref: "#/components/parameters/name"
+ get:
+ tags: [ E-Mail ]
+ summary: Return specific email settings by name.
+ description: |
+ Return the stored e-mail settings for the given connection
+ name.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EmailSettings"
+ put:
+ tags: [ E-Mail ]
+ summary: Change specific email settings.
+ description: |
+ Changes all settings for the connection with the given `name`.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EmailSettings"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+ delete:
+ tags: [ E-Mail ]
+ summary: Delete e-mail settings.
+ description: |
+ Deletes the e-mail settings with the specified `name`.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/email/send/{name}/{id}:
+ post:
+ tags: [ E-Mail ]
+ summary: Send an email.
+ description: |
+ Sends an email as specified in the body of the request.
+
+ The item's attachment are added to the mail if requested.
+ security:
+ - authTokenHeader: []
+ parameters:
+ - $ref: "#/components/parameters/name"
+ - $ref: "#/components/parameters/id"
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SimpleMail"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+ /sec/email/sent/item/{id}:
+ get:
+ tags: [ E-Mail ]
+ summary: Get sent mail related to an item
+ description: |
+ Return all mails that have been sent related to the item with
+ id `id`.
+ security:
+ - authTokenHeader: []
+ parameters:
+ - $ref: "#/components/parameters/id"
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SentMails"
+ /sec/email/sent/mail/{mailId}:
+ parameters:
+ - $ref: "#/components/parameters/mailId"
+ get:
+ tags: [ E-Mail ]
+ summary: Get sent single mail related to an item
+ description: |
+ Return one mail with the given id.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SentMail"
+ delete:
+ tags: [ E-Mail ]
+ summary: Delete a sent mail.
+ description: |
+ Delete a sent mail.
+ security:
+ - authTokenHeader: []
+ responses:
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
components:
schemas:
+ SentMails:
+ description: |
+ A list of sent mails.
+ required:
+ - items
+ properties:
+ items:
+ type: array
+ items:
+ $ref: "#/components/schemas/SentMail"
+ SentMail:
+ description: |
+ A mail that has been sent previously related to an item.
+ required:
+ - id
+ - sender
+ - connection
+ - recipients
+ - subject
+ - body
+ - created
+ properties:
+ id:
+ type: string
+ format: ident
+ sender:
+ type: string
+ format: ident
+ connection:
+ type: string
+ format: ident
+ recipients:
+ type: array
+ items:
+ type: string
+ subject:
+ type: string
+ body:
+ type: string
+ created:
+ type: integer
+ format: date-time
+ SimpleMail:
+ description: |
+ A simple e-mail related to an item.
+
+ The mail may contain the item attachments as mail attachments.
+ If all item attachments should be send, set
+ `addAllAttachments` to `true`. Otherwise set it to `false` and
+ specify a list of file-ids that you want to include. This list
+ is ignored, if `addAllAttachments` is set to `true`.
+ required:
+ - recipients
+ - subject
+ - body
+ - addAllAttachments
+ - attachmentIds
+ properties:
+ recipients:
+ type: array
+ items:
+ type: string
+ subject:
+ type: string
+ body:
+ type: string
+ addAllAttachments:
+ type: boolean
+ attachmentIds:
+ type: array
+ items:
+ type: string
+ format: ident
+ EmailSettingsList:
+ description: |
+ A list of user email settings.
+ required:
+ - items
+ properties:
+ items:
+ type: array
+ items:
+ $ref: "#/components/schemas/EmailSettings"
+ EmailSettings:
+ description: |
+ SMTP settings for sending mail.
+ required:
+ - name
+ - smtpHost
+ - from
+ - sslType
+ - ignoreCertificates
+ properties:
+ name:
+ type: string
+ format: ident
+ smtpHost:
+ type: string
+ smtpPort:
+ type: integer
+ format: int32
+ smtpUser:
+ type: string
+ smtpPassword:
+ type: string
+ format: password
+ from:
+ type: string
+ replyTo:
+ type: string
+ sslType:
+ type: string
+ ignoreCertificates:
+ type: boolean
CheckFileResult:
description: |
Results when searching for file checksums.
@@ -2157,7 +2448,7 @@ components:
id:
name: id
in: path
- description: A identifier
+ description: An identifier
required: true
schema:
type: string
@@ -2182,3 +2473,17 @@ components:
required: false
schema:
type: string
+ name:
+ name: name
+ in: path
+ description: An e-mail connection name
+ required: true
+ schema:
+ type: string
+ mailId:
+ name: mailId
+ in: path
+ description: The id of a sent mail.
+ required: true
+ schema:
+ type: string
diff --git a/modules/restserver/src/main/resources/logback.xml b/modules/restserver/src/main/resources/logback.xml
index c33ec1f7..f9b2d921 100644
--- a/modules/restserver/src/main/resources/logback.xml
+++ b/modules/restserver/src/main/resources/logback.xml
@@ -8,6 +8,8 @@
+
+
diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala
index c88dfa9b..160a92a7 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala
@@ -57,19 +57,22 @@ 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/send" -> MailSendRoutes(restApp.backend, token),
+ "email/settings" -> MailSettingsRoutes(restApp.backend, token),
+ "email/sent" -> SentMailRoutes(restApp.backend, token)
)
def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
index a35a69cc..7aee0ea2 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
@@ -96,15 +96,15 @@ object ItemRoutes {
case req @ POST -> Root / Ident(id) / "notes" =>
for {
text <- req.as[OptionalText]
- res <- backend.item.setNotes(id, text.text, user.account.collective)
- resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated"))
+ res <- backend.item.setNotes(id, text.text.notEmpty, user.account.collective)
+ resp <- Ok(Conversions.basicResult(res, "Notes updated"))
} yield resp
case req @ POST -> Root / Ident(id) / "name" =>
for {
text <- req.as[OptionalText]
- res <- backend.item.setName(id, text.text.getOrElse(""), user.account.collective)
- resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated"))
+ res <- backend.item.setName(id, text.text.notEmpty.getOrElse(""), user.account.collective)
+ resp <- Ok(Conversions.basicResult(res, "Name updated"))
} yield resp
case req @ POST -> Root / Ident(id) / "duedate" =>
@@ -138,4 +138,10 @@ object ItemRoutes {
} yield resp
}
}
+
+
+ final implicit class OptionString(opt: Option[String]) {
+ def notEmpty: Option[String] =
+ opt.map(_.trim).filter(_.nonEmpty)
+ }
}
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala
new file mode 100644
index 00000000..3d7a08e3
--- /dev/null
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala
@@ -0,0 +1,56 @@
+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.backend.ops.OMail.{AttachSelection, ItemMail}
+import docspell.backend.ops.SendResult
+import docspell.common._
+import docspell.restapi.model._
+import docspell.store.EmilUtil
+
+object MailSendRoutes {
+
+ def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = {
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+
+ HttpRoutes.of {
+ case req @ POST -> Root / Ident(name) / Ident(id) =>
+ for {
+ in <- req.as[SimpleMail]
+ mail = convertIn(id, in)
+ res <- mail.traverse(m => backend.mail.sendMail(user.account, name, m))
+ resp <- res.fold(
+ err => Ok(BasicResult(false, s"Invalid mail data: $err")),
+ res => Ok(convertOut(res))
+ )
+ } yield resp
+ }
+ }
+
+ def convertIn(item: Ident, s: SimpleMail): Either[String, ItemMail] =
+ for {
+ rec <- s.recipients.traverse(EmilUtil.readMailAddress)
+ fileIds <- s.attachmentIds.traverse(Ident.fromString)
+ sel = if (s.addAllAttachments) AttachSelection.All else AttachSelection.Selected(fileIds)
+ } yield ItemMail(item, s.subject, rec, s.body, sel)
+
+ def convertOut(res: SendResult): BasicResult =
+ res match {
+ case SendResult.Success(_) =>
+ BasicResult(true, "Mail sent.")
+ case SendResult.SendFailure(ex) =>
+ BasicResult(false, s"Mail sending failed: ${ex.getMessage}")
+ case SendResult.StoreFailure(ex) =>
+ BasicResult(false, s"Mail was sent, but could not be store to database: ${ex.getMessage}")
+ case SendResult.NotFound =>
+ BasicResult(false, s"There was no mail-connection or item found.")
+ }
+}
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/MailSettingsRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSettingsRoutes.scala
new file mode 100644
index 00000000..ffb26b49
--- /dev/null
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSettingsRoutes.scala
@@ -0,0 +1,123 @@
+package docspell.restserver.routes
+
+import cats.effect._
+import cats.implicits._
+import cats.data.OptionT
+import org.http4s._
+import org.http4s.dsl.Http4sDsl
+import org.http4s.circe.CirceEntityEncoder._
+import org.http4s.circe.CirceEntityDecoder._
+import emil.MailAddress
+
+import docspell.backend.BackendApp
+import docspell.backend.auth.AuthToken
+import docspell.backend.ops.OMail
+import docspell.common._
+import docspell.restapi.model._
+import docspell.store.records.RUserEmail
+import docspell.store.EmilUtil
+import docspell.restserver.conv.Conversions
+
+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 GET -> Root / Ident(name) =>
+ (for {
+ ems <- backend.mail.findSettings(user.account, name)
+ resp <- OptionT.liftF(Ok(convert(ems)))
+ } yield resp).getOrElseF(NotFound())
+
+ case req @ POST -> Root =>
+ (for {
+ in <- OptionT.liftF(req.as[EmailSettings])
+ ru = makeSettings(in)
+ up <- OptionT.liftF(ru.traverse(r => backend.mail.createSettings(user.account, r)))
+ resp <- OptionT.liftF(
+ Ok(
+ up.fold(
+ err => BasicResult(false, err),
+ ar => Conversions.basicResult(ar, "Mail settings stored.")
+ )
+ )
+ )
+ } yield resp).getOrElseF(NotFound())
+
+ case req @ PUT -> Root / Ident(name) =>
+ (for {
+ in <- OptionT.liftF(req.as[EmailSettings])
+ ru = makeSettings(in)
+ up <- OptionT.liftF(ru.traverse(r => backend.mail.updateSettings(user.account, name, r)))
+ resp <- OptionT.liftF(
+ Ok(
+ up.fold(
+ err => BasicResult(false, err),
+ n =>
+ if (n > 0) BasicResult(true, "Mail settings stored.")
+ else BasicResult(false, "Mail settings could not be saved")
+ )
+ )
+ )
+ } yield resp).getOrElseF(NotFound())
+
+ case DELETE -> Root / Ident(name) =>
+ for {
+ n <- backend.mail.deleteSettings(user.account, name)
+ resp <- Ok(
+ if (n > 0) BasicResult(true, "Mail settings removed")
+ else BasicResult(false, "Mail settings could not be removed")
+ )
+ } 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
+ )
+
+ def makeSettings(ems: EmailSettings): Either[String, OMail.SmtpSettings] = {
+ def readMail(str: String): Either[String, MailAddress] =
+ EmilUtil.readMailAddress(str).left.map(err => s"E-Mail address '$str' invalid: $err")
+
+ def readMailOpt(str: Option[String]): Either[String, Option[MailAddress]] =
+ str.traverse(readMail)
+
+ for {
+ from <- readMail(ems.from)
+ repl <- readMailOpt(ems.replyTo)
+ sslt <- EmilUtil.readSSLType(ems.sslType)
+ } yield OMail.SmtpSettings(
+ ems.name,
+ ems.smtpHost,
+ ems.smtpPort,
+ ems.smtpUser,
+ ems.smtpPassword,
+ sslt,
+ !ems.ignoreCertificates,
+ from,
+ repl
+ )
+
+ }
+}
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/SentMailRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/SentMailRoutes.scala
new file mode 100644
index 00000000..01f22c45
--- /dev/null
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/SentMailRoutes.scala
@@ -0,0 +1,54 @@
+package docspell.restserver.routes
+
+import cats.effect._
+import cats.implicits._
+import cats.data.OptionT
+import org.http4s._
+import org.http4s.dsl.Http4sDsl
+import org.http4s.circe.CirceEntityEncoder._
+
+import docspell.backend.BackendApp
+import docspell.backend.auth.AuthToken
+import docspell.backend.ops.OMail.Sent
+import docspell.common._
+import docspell.restapi.model._
+import docspell.store.EmilUtil
+
+object SentMailRoutes {
+
+ def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = {
+ val dsl = new Http4sDsl[F] {}
+ import dsl._
+
+ HttpRoutes.of {
+ case GET -> Root / "item" / Ident(id) =>
+ for {
+ all <- backend.mail.getSentMailsForItem(user.account, id)
+ resp <- Ok(SentMails(all.map(convert).toList))
+ } yield resp
+
+ case GET -> Root / "mail" / Ident(mailId) =>
+ (for {
+ mail <- backend.mail.getSentMail(user.account, mailId)
+ resp <- OptionT.liftF(Ok(convert(mail)))
+ } yield resp).getOrElseF(NotFound())
+
+ case DELETE -> Root / "mail" / Ident(mailId) =>
+ for {
+ n <- backend.mail.deleteSentMail(user.account, mailId)
+ resp <- Ok(BasicResult(n > 0, s"Mails deleted: $n"))
+ } yield resp
+ }
+ }
+
+ def convert(s: Sent): SentMail =
+ SentMail(
+ s.id,
+ s.senderLogin,
+ s.connectionName,
+ s.recipients.map(EmilUtil.mailAddressString),
+ s.subject,
+ s.body,
+ s.created
+ )
+}
diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.1.0__useremail.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.1.0__useremail.sql
new file mode 100644
index 00000000..75812938
--- /dev/null
+++ b/modules/store/src/main/resources/db/migration/postgresql/V1.1.0__useremail.sql
@@ -0,0 +1,39 @@
+CREATE TABLE "useremail" (
+ "id" varchar(254) not null primary key,
+ "uid" varchar(254) not null,
+ "name" varchar(254) not null,
+ "smtp_host" varchar(254) not null,
+ "smtp_port" int,
+ "smtp_user" varchar(254),
+ "smtp_password" varchar(254),
+ "smtp_ssl" varchar(254) not null,
+ "smtp_certcheck" boolean not null,
+ "mail_from" varchar(254) not null,
+ "mail_replyto" varchar(254),
+ "created" timestamp not null,
+ unique ("uid", "name"),
+ foreign key ("uid") references "user_"("uid")
+);
+
+CREATE TABLE "sentmail" (
+ "id" varchar(254) not null primary key,
+ "uid" varchar(254) not null,
+ "message_id" varchar(254) not null,
+ "sender" varchar(254) not null,
+ "conn_name" varchar(254) not null,
+ "subject" varchar(254) not null,
+ "recipients" varchar(254) not null,
+ "body" text not null,
+ "created" timestamp not null,
+ foreign key("uid") references "user_"("uid")
+);
+
+CREATE TABLE "sentmailitem" (
+ "id" varchar(254) not null primary key,
+ "item_id" varchar(254) not null,
+ "sentmail_id" varchar(254) not null,
+ "created" timestamp not null,
+ unique ("item_id", "sentmail_id"),
+ foreign key("item_id") references "item"("itemid"),
+ foreign key("sentmail_id") references "sentmail"("id")
+);
diff --git a/modules/store/src/main/scala/docspell/store/EmilUtil.scala b/modules/store/src/main/scala/docspell/store/EmilUtil.scala
new file mode 100644
index 00000000..749a041a
--- /dev/null
+++ b/modules/store/src/main/scala/docspell/store/EmilUtil.scala
@@ -0,0 +1,38 @@
+package docspell.store
+
+import cats.implicits._
+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 readMultipleAddresses(str: String): Either[String, List[MailAddress]] =
+ str.split(',').toList.map(_.trim).traverse(readMailAddress)
+
+ def mailAddressString(ma: MailAddress): String =
+ ma.asUnicodeString
+}
diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala
index b731406e..62f058cd 100644
--- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala
+++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala
@@ -2,16 +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 emil.{MailAddress, SSLType}
+
+import docspell.common._
import docspell.common.syntax.all._
+import docspell.store.EmilUtil
trait DoobieMeta {
@@ -88,9 +87,21 @@ trait DoobieMeta {
implicit val metaLanguage: Meta[Language] =
Meta[String].imap(Language.unsafe)(_.iso3)
+
+ implicit val sslType: Meta[SSLType] =
+ Meta[String].imap(EmilUtil.unsafeReadSSLType)(EmilUtil.sslTypeString)
+
+ implicit val mailAddress: Meta[MailAddress] =
+ Meta[String].imap(EmilUtil.unsafeReadMailAddress)(EmilUtil.mailAddressString)
+
+ implicit def mailAddressList: Meta[List[MailAddress]] =
+ Meta[String].imap(str => str.split(',').toList.map(_.trim).map(EmilUtil.unsafeReadMailAddress))(
+ lma => lma.map(EmilUtil.mailAddressString).mkString(",")
+ )
}
object DoobieMeta extends DoobieMeta {
import org.log4s._
private val logger = getLogger
+
}
diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieSyntax.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieSyntax.scala
index 47e61345..4eef507f 100644
--- a/modules/store/src/main/scala/docspell/store/impl/DoobieSyntax.scala
+++ b/modules/store/src/main/scala/docspell/store/impl/DoobieSyntax.scala
@@ -65,12 +65,6 @@ trait DoobieSyntax {
Fragment.const("SELECT DISTINCT(") ++ commas(cols.map(_.f)) ++
Fragment.const(") FROM ") ++ table ++ this.where(where)
-// def selectJoinCollective(cols: Seq[Column], fkCid: Column, table: Fragment, wh: Fragment): Fragment =
-// selectSimple(cols.map(_.prefix("a"))
-// , table ++ fr"a," ++ RCollective.table ++ fr"b"
-// , if (isEmpty(wh)) fkCid.prefix("a") is RCollective.Columns.id.prefix("b")
-// else and(wh, fkCid.prefix("a") is RCollective.Columns.id.prefix("b")))
-
def selectCount(col: Column, table: Fragment, where: Fragment): Fragment =
Fragment.const("SELECT COUNT(") ++ col.f ++ Fragment.const(") FROM ") ++ table ++ this.where(
where
diff --git a/modules/store/src/main/scala/docspell/store/queries/QMails.scala b/modules/store/src/main/scala/docspell/store/queries/QMails.scala
new file mode 100644
index 00000000..6053df10
--- /dev/null
+++ b/modules/store/src/main/scala/docspell/store/queries/QMails.scala
@@ -0,0 +1,64 @@
+package docspell.store.queries
+
+import cats.data.OptionT
+import doobie._
+import doobie.implicits._
+
+import docspell.common._
+import docspell.store.impl.Column
+import docspell.store.impl.Implicits._
+import docspell.store.records.{RItem, RSentMail, RSentMailItem, RUser}
+
+object QMails {
+
+ def delete(coll: Ident, mailId: Ident): ConnectionIO[Int] =
+ (for {
+ m <- OptionT(findMail(coll, mailId))
+ k <- OptionT.liftF(RSentMailItem.deleteMail(mailId))
+ n <- OptionT.liftF(RSentMail.delete(m._1.id))
+ } yield k + n).getOrElse(0)
+
+ def findMail(coll: Ident, mailId: Ident): ConnectionIO[Option[(RSentMail, Ident)]] = {
+ val iColl = RItem.Columns.cid.prefix("i")
+ val mId = RSentMail.Columns.id.prefix("m")
+
+ val (cols, from) = partialFind
+
+ val cond = Seq(mId.is(mailId), iColl.is(coll))
+
+ selectSimple(cols, from, and(cond)).query[(RSentMail, Ident)].option
+ }
+
+ def findMails(coll: Ident, itemId: Ident): ConnectionIO[Vector[(RSentMail, Ident)]] = {
+ val iColl = RItem.Columns.cid.prefix("i")
+ val tItem = RSentMailItem.Columns.itemId.prefix("t")
+ val mCreated = RSentMail.Columns.created.prefix("m")
+
+ val (cols, from) = partialFind
+
+ val cond = Seq(tItem.is(itemId), iColl.is(coll))
+
+ (selectSimple(cols, from, and(cond)) ++ orderBy(mCreated.f) ++ fr"DESC")
+ .query[(RSentMail, Ident)]
+ .to[Vector]
+ }
+
+ private def partialFind: (Seq[Column], Fragment) = {
+ val iId = RItem.Columns.id.prefix("i")
+ val tItem = RSentMailItem.Columns.itemId.prefix("t")
+ val tMail = RSentMailItem.Columns.sentMailId.prefix("t")
+ val mId = RSentMail.Columns.id.prefix("m")
+ val mUser = RSentMail.Columns.uid.prefix("m")
+ val uId = RUser.Columns.uid.prefix("u")
+ val uLogin = RUser.Columns.login.prefix("u")
+
+ val cols = RSentMail.Columns.all.map(_.prefix("m")) :+ uLogin
+ val from = RSentMail.table ++ fr"m INNER JOIN" ++
+ RSentMailItem.table ++ fr"t ON" ++ tMail.is(mId) ++
+ fr"INNER JOIN" ++ RItem.table ++ fr"i ON" ++ tItem.is(iId) ++
+ fr"INNER JOIN" ++ RUser.table ++ fr"u ON" ++ uId.is(mUser)
+
+ (cols, from)
+ }
+
+}
diff --git a/modules/store/src/main/scala/docspell/store/records/RAttachment.scala b/modules/store/src/main/scala/docspell/store/records/RAttachment.scala
index 52f71d30..ee193e69 100644
--- a/modules/store/src/main/scala/docspell/store/records/RAttachment.scala
+++ b/modules/store/src/main/scala/docspell/store/records/RAttachment.scala
@@ -64,6 +64,26 @@ object RAttachment {
q.query[RAttachment].to[Vector]
}
+ def findByItemAndCollectiveWithMeta(
+ id: Ident,
+ coll: Ident
+ ): ConnectionIO[Vector[(RAttachment, FileMeta)]] = {
+ import bitpeace.sql._
+
+ val cols = all.map(_.prefix("a")) ++ RFileMeta.Columns.all.map(_.prefix("m"))
+ val afileMeta = fileId.prefix("a")
+ val aItem = itemId.prefix("a")
+ val mId = RFileMeta.Columns.id.prefix("m")
+ val iId = RItem.Columns.id.prefix("i")
+ val iColl = RItem.Columns.cid.prefix("i")
+
+ val from = table ++ fr"a INNER JOIN" ++ RFileMeta.table ++ fr"m ON" ++ afileMeta.is(mId) ++
+ fr"INNER JOIN" ++ RItem.table ++ fr"i ON" ++ aItem.is(iId)
+ val cond = Seq(aItem.is(id), iColl.is(coll))
+
+ selectSimple(cols, from, and(cond)).query[(RAttachment, FileMeta)].to[Vector]
+ }
+
def findByItemWithMeta(id: Ident): ConnectionIO[Vector[(RAttachment, FileMeta)]] = {
import bitpeace.sql._
diff --git a/modules/store/src/main/scala/docspell/store/records/RFileMeta.scala b/modules/store/src/main/scala/docspell/store/records/RFileMeta.scala
new file mode 100644
index 00000000..e6f206e5
--- /dev/null
+++ b/modules/store/src/main/scala/docspell/store/records/RFileMeta.scala
@@ -0,0 +1,22 @@
+package docspell.store.records
+
+import doobie.implicits._
+import docspell.store.impl._
+
+object RFileMeta {
+
+ val table = fr"filemeta"
+
+ object Columns {
+ val id = Column("id")
+ val timestamp = Column("timestamp")
+ val mimetype = Column("mimetype")
+ val length = Column("length")
+ val checksum = Column("checksum")
+ val chunks = Column("chunks")
+ val chunksize = Column("chunksize")
+
+ val all = List(id, timestamp, mimetype, length, checksum, chunks, chunksize)
+
+ }
+}
diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala
index 7d9dafda..bf447317 100644
--- a/modules/store/src/main/scala/docspell/store/records/RItem.scala
+++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala
@@ -262,4 +262,7 @@ object RItem {
def deleteByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Int] =
deleteFrom(table, and(id.is(itemId), cid.is(coll))).update.run
+
+ def existsById(itemId: Ident): ConnectionIO[Boolean] =
+ selectCount(id, table, id.is(itemId)).query[Int].unique.map(_ > 0)
}
diff --git a/modules/store/src/main/scala/docspell/store/records/RSentMail.scala b/modules/store/src/main/scala/docspell/store/records/RSentMail.scala
new file mode 100644
index 00000000..a0679b20
--- /dev/null
+++ b/modules/store/src/main/scala/docspell/store/records/RSentMail.scala
@@ -0,0 +1,100 @@
+package docspell.store.records
+
+import fs2.Stream
+import cats.effect._
+import cats.implicits._
+import doobie._
+import doobie.implicits._
+import docspell.common._
+import docspell.store.impl.Column
+import docspell.store.impl.Implicits._
+import emil.MailAddress
+import cats.data.OptionT
+
+case class RSentMail(
+ id: Ident,
+ uid: Ident,
+ messageId: String,
+ sender: MailAddress,
+ connName: Ident,
+ subject: String,
+ recipients: List[MailAddress],
+ body: String,
+ created: Timestamp
+) {}
+
+object RSentMail {
+
+ def apply[F[_]: Sync](
+ uid: Ident,
+ messageId: String,
+ sender: MailAddress,
+ connName: Ident,
+ subject: String,
+ recipients: List[MailAddress],
+ body: String
+ ): F[RSentMail] =
+ for {
+ id <- Ident.randomId[F]
+ now <- Timestamp.current[F]
+ } yield RSentMail(id, uid, messageId, sender, connName, subject, recipients, body, now)
+
+ def forItem(
+ itemId: Ident,
+ accId: AccountId,
+ messageId: String,
+ sender: MailAddress,
+ connName: Ident,
+ subject: String,
+ recipients: List[MailAddress],
+ body: String
+ ): OptionT[ConnectionIO, (RSentMail, RSentMailItem)] =
+ for {
+ user <- OptionT(RUser.findByAccount(accId))
+ sm <- OptionT.liftF(
+ RSentMail[ConnectionIO](user.uid, messageId, sender, connName, subject, recipients, body)
+ )
+ si <- OptionT.liftF(RSentMailItem[ConnectionIO](itemId, sm.id, Some(sm.created)))
+ } yield (sm, si)
+
+ val table = fr"sentmail"
+
+ object Columns {
+ val id = Column("id")
+ val uid = Column("uid")
+ val messageId = Column("message_id")
+ val sender = Column("sender")
+ val connName = Column("conn_name")
+ val subject = Column("subject")
+ val recipients = Column("recipients")
+ val body = Column("body")
+ val created = Column("created")
+
+ val all = List(
+ id,
+ uid,
+ messageId,
+ sender,
+ connName,
+ subject,
+ recipients,
+ body,
+ created
+ )
+ }
+
+ import Columns._
+
+ def insert(v: RSentMail): ConnectionIO[Int] =
+ insertRow(
+ table,
+ all,
+ sql"${v.id},${v.uid},${v.messageId},${v.sender},${v.connName},${v.subject},${v.recipients},${v.body},${v.created}"
+ ).update.run
+
+ def findByUser(userId: Ident): Stream[ConnectionIO, RSentMail] =
+ selectSimple(all, table, uid.is(userId)).query[RSentMail].stream
+
+ def delete(mailId: Ident): ConnectionIO[Int] =
+ deleteFrom(table, id.is(mailId)).update.run
+}
diff --git a/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala b/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala
new file mode 100644
index 00000000..5f796c6e
--- /dev/null
+++ b/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala
@@ -0,0 +1,57 @@
+package docspell.store.records
+
+import cats.effect._
+import cats.implicits._
+import doobie._
+import doobie.implicits._
+import docspell.common._
+import docspell.store.impl.Column
+import docspell.store.impl.Implicits._
+
+case class RSentMailItem(
+ id: Ident,
+ itemId: Ident,
+ sentMailId: Ident,
+ created: Timestamp
+) {}
+
+object RSentMailItem {
+
+ def apply[F[_]: Sync](
+ itemId: Ident,
+ sentmailId: Ident,
+ created: Option[Timestamp] = None
+ ): F[RSentMailItem] =
+ for {
+ id <- Ident.randomId[F]
+ now <- created.map(_.pure[F]).getOrElse(Timestamp.current[F])
+ } yield RSentMailItem(id, itemId, sentmailId, now)
+
+ val table = fr"sentmailitem"
+
+ object Columns {
+ val id = Column("id")
+ val itemId = Column("item_id")
+ val sentMailId = Column("sentmail_id")
+ val created = Column("created")
+
+ val all = List(
+ id,
+ itemId,
+ sentMailId,
+ created
+ )
+ }
+
+ import Columns._
+
+ def insert(v: RSentMailItem): ConnectionIO[Int] =
+ insertRow(
+ table,
+ all,
+ sql"${v.id},${v.itemId},${v.sentMailId},${v.created}"
+ ).update.run
+
+ def deleteMail(mailId: Ident): ConnectionIO[Int] =
+ deleteFrom(table, sentMailId.is(mailId)).update.run
+}
diff --git a/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala
new file mode 100644
index 00000000..e8fcc0b7
--- /dev/null
+++ b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala
@@ -0,0 +1,212 @@
+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._
+import emil.{MailAddress, MailConfig, SSLType}
+
+case class RUserEmail(
+ id: Ident,
+ uid: Ident,
+ name: Ident,
+ smtpHost: String,
+ smtpPort: Option[Int],
+ smtpUser: Option[String],
+ smtpPassword: Option[Password],
+ smtpSsl: SSLType,
+ smtpCertCheck: Boolean,
+ mailFrom: MailAddress,
+ mailReplyTo: Option[MailAddress],
+ created: Timestamp
+) {
+
+ def toMailConfig: MailConfig = {
+ val port = smtpPort.map(p => s":$p").getOrElse("")
+ MailConfig(
+ s"smtp://$smtpHost$port",
+ smtpUser.getOrElse(""),
+ smtpPassword.map(_.pass).getOrElse(""),
+ smtpSsl,
+ !smtpCertCheck
+ )
+ }
+}
+
+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 fromAccount(
+ 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 {
+ val id = Column("id")
+ val uid = Column("uid")
+ val name = Column("name")
+ val smtpHost = Column("smtp_host")
+ val smtpPort = Column("smtp_port")
+ val smtpUser = Column("smtp_user")
+ val smtpPass = Column("smtp_password")
+ val smtpSsl = Column("smtp_ssl")
+ val smtpCertCheck = Column("smtp_certcheck")
+ val mailFrom = Column("mail_from")
+ val mailReplyTo = Column("mail_replyto")
+ val created = Column("created")
+
+ val all = List(
+ id,
+ uid,
+ name,
+ smtpHost,
+ smtpPort,
+ smtpUser,
+ smtpPass,
+ smtpSsl,
+ smtpCertCheck,
+ mailFrom,
+ mailReplyTo,
+ created
+ )
+ }
+
+ import Columns._
+
+ def insert(v: RUserEmail): ConnectionIO[Int] =
+ insertRow(
+ table,
+ all,
+ 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.map(_.prefix("m")), from, and(cond)) ++ orderBy(mName.f)).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 delete(accId: AccountId, connName: Ident): ConnectionIO[Int] = {
+ val uId = RUser.Columns.uid
+ val uColl = RUser.Columns.cid
+ val uLogin = RUser.Columns.login
+ val cond = Seq(uColl.is(accId.collective), uLogin.is(accId.user))
+
+ deleteFrom(
+ table,
+ fr"uid in (" ++ selectSimple(Seq(uId), RUser.table, and(cond)) ++ fr") AND" ++ name.is(
+ connName
+ )
+ ).update.run
+ }
+
+ 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)
+}
diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm
index 31bb7eff..fe364cff 100644
--- a/modules/webapp/src/main/elm/Api.elm
+++ b/modules/webapp/src/main/elm/Api.elm
@@ -1,8 +1,10 @@
module Api exposing
( cancelJob
, changePassword
+ , createMailSettings
, deleteEquip
, deleteItem
+ , deleteMailSettings
, deleteOrg
, deletePerson
, deleteSource
@@ -15,10 +17,12 @@ module Api exposing
, getItemProposals
, getJobQueueState
, getJobQueueStateIn
+ , getMailSettings
, getOrgLight
, getOrganizations
, getPersons
, getPersonsLight
+ , getSentMails
, getSources
, getTags
, getUsers
@@ -37,6 +41,7 @@ module Api exposing
, putUser
, refreshSession
, register
+ , sendMail
, setCollectiveSettings
, setConcEquip
, setConcPerson
@@ -60,6 +65,8 @@ import Api.Model.BasicResult exposing (BasicResult)
import Api.Model.Collective exposing (Collective)
import Api.Model.CollectiveSettings exposing (CollectiveSettings)
import Api.Model.DirectionValue exposing (DirectionValue)
+import Api.Model.EmailSettings exposing (EmailSettings)
+import Api.Model.EmailSettingsList exposing (EmailSettingsList)
import Api.Model.Equipment exposing (Equipment)
import Api.Model.EquipmentList exposing (EquipmentList)
import Api.Model.GenInvite exposing (GenInvite)
@@ -81,6 +88,8 @@ import Api.Model.Person exposing (Person)
import Api.Model.PersonList exposing (PersonList)
import Api.Model.ReferenceList exposing (ReferenceList)
import Api.Model.Registration exposing (Registration)
+import Api.Model.SentMails exposing (SentMails)
+import Api.Model.SimpleMail exposing (SimpleMail)
import Api.Model.Source exposing (Source)
import Api.Model.SourceList exposing (SourceList)
import Api.Model.Tag exposing (Tag)
@@ -99,6 +108,92 @@ import Util.File
import Util.Http as Http2
+
+--- Get Sent Mails
+
+
+getSentMails :
+ Flags
+ -> String
+ -> (Result Http.Error SentMails -> msg)
+ -> Cmd msg
+getSentMails flags item receive =
+ Http2.authGet
+ { url = flags.config.baseUrl ++ "/api/v1/sec/email/sent/item/" ++ item
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.SentMails.decoder
+ }
+
+
+
+--- Mail Send
+
+
+sendMail :
+ Flags
+ -> { conn : String, item : String, mail : SimpleMail }
+ -> (Result Http.Error BasicResult -> msg)
+ -> Cmd msg
+sendMail flags opts receive =
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v1/sec/email/send/" ++ opts.conn ++ "/" ++ opts.item
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.SimpleMail.encode opts.mail)
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+
+--- Mail Settings
+
+
+deleteMailSettings : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+deleteMailSettings flags name receive =
+ Http2.authDelete
+ { url = flags.config.baseUrl ++ "/api/v1/sec/email/settings/" ++ name
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+getMailSettings : Flags -> String -> (Result Http.Error EmailSettingsList -> msg) -> Cmd msg
+getMailSettings flags query receive =
+ Http2.authGet
+ { url = flags.config.baseUrl ++ "/api/v1/sec/email/settings?q=" ++ Url.percentEncode query
+ , account = getAccount flags
+ , expect = Http.expectJson receive Api.Model.EmailSettingsList.decoder
+ }
+
+
+createMailSettings :
+ Flags
+ -> Maybe String
+ -> EmailSettings
+ -> (Result Http.Error BasicResult -> msg)
+ -> Cmd msg
+createMailSettings flags mname ems receive =
+ case mname of
+ Just en ->
+ Http2.authPut
+ { url = flags.config.baseUrl ++ "/api/v1/sec/email/settings/" ++ en
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.EmailSettings.encode ems)
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+ Nothing ->
+ Http2.authPost
+ { url = flags.config.baseUrl ++ "/api/v1/sec/email/settings"
+ , account = getAccount flags
+ , body = Http.jsonBody (Api.Model.EmailSettings.encode ems)
+ , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+ }
+
+
+
+--- Upload
+
+
upload : Flags -> Maybe String -> ItemUploadMeta -> List File -> (String -> Result Http.Error BasicResult -> msg) -> List (Cmd msg)
upload flags sourceId meta files receive =
let
diff --git a/modules/webapp/src/main/elm/Comp/Dropdown.elm b/modules/webapp/src/main/elm/Comp/Dropdown.elm
index 99f44933..3ab318e9 100644
--- a/modules/webapp/src/main/elm/Comp/Dropdown.elm
+++ b/modules/webapp/src/main/elm/Comp/Dropdown.elm
@@ -395,7 +395,9 @@ viewSingle model =
]
renderDefault =
- [ List.head model.selected |> Maybe.map renderClosed |> Maybe.withDefault (renderPlaceholder model)
+ [ List.head model.selected
+ |> Maybe.map renderClosed
+ |> Maybe.withDefault (renderPlaceholder model)
, renderMenu model
]
diff --git a/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm b/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm
new file mode 100644
index 00000000..fa85576e
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm
@@ -0,0 +1,264 @@
+module Comp.EmailSettingsForm exposing
+ ( Model
+ , Msg
+ , emptyModel
+ , getSettings
+ , init
+ , isValid
+ , 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 (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
+ }
+
+
+emptyModel : Model
+emptyModel =
+ { settings = Api.Model.EmailSettings.empty
+ , name = ""
+ , host = ""
+ , portField = Comp.IntField.init (Just 0) Nothing True "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
+ }
+
+
+init : EmailSettings -> Model
+init ems =
+ { settings = ems
+ , name = ems.name
+ , host = ems.smtpHost
+ , portField = Comp.IntField.init (Just 0) Nothing True "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
+ |> Maybe.withDefault Data.SSLType.None
+ |> Just
+ }
+ , 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 =
+ model.host /= "" && model.name /= ""
+
+
+update : Msg -> Model -> ( Model, Cmd Msg )
+update msg model =
+ 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
+view model =
+ div
+ [ classList
+ [ ( "ui form", True )
+ , ( "error", not (isValid model) )
+ , ( "success", isValid model )
+ ]
+ ]
+ [ div [ class "required field" ]
+ [ label [] [ text "Name" ]
+ , input
+ [ type_ "text"
+ , value model.name
+ , onInput SetName
+ , 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 "thirteen 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
+ "three wide field"
+ 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)
+ ]
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm b/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm
new file mode 100644
index 00000000..9e3d55b8
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm
@@ -0,0 +1,290 @@
+module Comp.EmailSettingsManage exposing
+ ( Model
+ , Msg
+ , emptyModel
+ , init
+ , update
+ , view
+ )
+
+import Api
+import Api.Model.BasicResult exposing (BasicResult)
+import Api.Model.EmailSettings exposing (EmailSettings)
+import Api.Model.EmailSettingsList exposing (EmailSettingsList)
+import Comp.EmailSettingsForm
+import Comp.EmailSettingsTable
+import Comp.YesNoDimmer
+import Data.Flags exposing (Flags)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick, onInput)
+import Http
+import Util.Http
+
+
+type alias Model =
+ { tableModel : Comp.EmailSettingsTable.Model
+ , formModel : Comp.EmailSettingsForm.Model
+ , viewMode : ViewMode
+ , formError : Maybe String
+ , loading : Bool
+ , query : String
+ , deleteConfirm : Comp.YesNoDimmer.Model
+ }
+
+
+emptyModel : Model
+emptyModel =
+ { tableModel = Comp.EmailSettingsTable.emptyModel
+ , formModel = Comp.EmailSettingsForm.emptyModel
+ , viewMode = Table
+ , formError = Nothing
+ , loading = False
+ , query = ""
+ , deleteConfirm = Comp.YesNoDimmer.emptyModel
+ }
+
+
+init : Flags -> ( Model, Cmd Msg )
+init flags =
+ ( emptyModel, Api.getMailSettings flags "" MailSettingsResp )
+
+
+type ViewMode
+ = Table
+ | Form
+
+
+type Msg
+ = TableMsg Comp.EmailSettingsTable.Msg
+ | FormMsg Comp.EmailSettingsForm.Msg
+ | SetQuery String
+ | InitNew
+ | YesNoMsg Comp.YesNoDimmer.Msg
+ | RequestDelete
+ | SetViewMode ViewMode
+ | Submit
+ | SubmitResp (Result Http.Error BasicResult)
+ | LoadSettings
+ | MailSettingsResp (Result Http.Error EmailSettingsList)
+
+
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update flags msg model =
+ 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
+
+ m2 =
+ { model
+ | tableModel = tm
+ , viewMode = Maybe.map (\_ -> Form) tm.selected |> Maybe.withDefault Table
+ , formError =
+ if tm.selected /= Nothing then
+ Nothing
+
+ else
+ model.formError
+ , formModel =
+ case tm.selected of
+ Just ems ->
+ Comp.EmailSettingsForm.init ems
+
+ Nothing ->
+ model.formModel
+ }
+ in
+ ( m2, 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 ->
+ let
+ m =
+ { model | query = str }
+ in
+ ( m, Api.getMailSettings flags str MailSettingsResp )
+
+ YesNoMsg m ->
+ let
+ ( dm, flag ) =
+ Comp.YesNoDimmer.update m model.deleteConfirm
+
+ ( mid, _ ) =
+ Comp.EmailSettingsForm.getSettings model.formModel
+
+ cmd =
+ case ( flag, mid ) of
+ ( True, Just name ) ->
+ Api.deleteMailSettings flags name SubmitResp
+
+ _ ->
+ Cmd.none
+ in
+ ( { model | deleteConfirm = dm }, cmd )
+
+ RequestDelete ->
+ update flags (YesNoMsg Comp.YesNoDimmer.activate) model
+
+ SetViewMode m ->
+ ( { model | viewMode = m }, Cmd.none )
+
+ Submit ->
+ let
+ ( mid, ems ) =
+ Comp.EmailSettingsForm.getSettings model.formModel
+
+ valid =
+ Comp.EmailSettingsForm.isValid model.formModel
+ in
+ if valid then
+ ( { model | loading = True }, Api.createMailSettings flags mid ems SubmitResp )
+
+ else
+ ( { model | formError = Just "Please fill required fields." }, Cmd.none )
+
+ LoadSettings ->
+ ( { model | loading = True }, Api.getMailSettings flags model.query MailSettingsResp )
+
+ SubmitResp (Ok res) ->
+ if res.success then
+ let
+ ( m2, c2 ) =
+ update flags (SetViewMode Table) model
+
+ ( m3, c3 ) =
+ update flags LoadSettings m2
+ in
+ ( { m3 | loading = False }, Cmd.batch [ c2, c3 ] )
+
+ else
+ ( { model | formError = Just res.message, loading = False }, Cmd.none )
+
+ SubmitResp (Err err) ->
+ ( { model | formError = Just (Util.Http.errorToString err), loading = False }, Cmd.none )
+
+ MailSettingsResp (Ok ems) ->
+ let
+ m2 =
+ { model
+ | viewMode = Table
+ , loading = False
+ , tableModel = Comp.EmailSettingsTable.init ems.items
+ }
+ in
+ ( m2, Cmd.none )
+
+ MailSettingsResp (Err _) ->
+ ( { model | loading = False }, Cmd.none )
+
+
+view : Model -> Html Msg
+view model =
+ case model.viewMode of
+ Table ->
+ viewTable model
+
+ Form ->
+ 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"
+ , onClick Submit
+ , href "#"
+ ]
+ [ 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" ] []
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/EmailSettingsTable.elm b/modules/webapp/src/main/elm/Comp/EmailSettingsTable.elm
new file mode 100644
index 00000000..bfa3a388
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/EmailSettingsTable.elm
@@ -0,0 +1,76 @@
+module Comp.EmailSettingsTable exposing
+ ( Model
+ , Msg
+ , emptyModel
+ , init
+ , update
+ , view
+ )
+
+import Api.Model.EmailSettings exposing (EmailSettings)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+
+
+type alias Model =
+ { emailSettings : List EmailSettings
+ , selected : Maybe EmailSettings
+ }
+
+
+emptyModel : Model
+emptyModel =
+ init []
+
+
+init : List EmailSettings -> Model
+init ems =
+ { emailSettings = ems
+ , selected = Nothing
+ }
+
+
+type Msg
+ = Select EmailSettings
+
+
+update : Msg -> Model -> ( Model, Cmd Msg )
+update msg model =
+ case msg of
+ Select ems ->
+ ( { model | selected = Just ems }, Cmd.none )
+
+
+view : Model -> Html Msg
+view model =
+ table [ class "ui selectable pointer table" ]
+ [ thead []
+ [ th [ class "collapsible" ] [ text "Name" ]
+ , th [] [ text "Host/Port" ]
+ , th [] [ text "From" ]
+ ]
+ , tbody []
+ (List.map (renderLine model) model.emailSettings)
+ ]
+
+
+renderLine : Model -> EmailSettings -> Html Msg
+renderLine model ems =
+ let
+ hostport =
+ case ems.smtpPort of
+ Just p ->
+ ems.smtpHost ++ ":" ++ String.fromInt p
+
+ Nothing ->
+ ems.smtpHost
+ in
+ tr
+ [ classList [ ( "active", model.selected == Just ems ) ]
+ , onClick (Select ems)
+ ]
+ [ td [ class "collapsible" ] [ text ems.name ]
+ , td [] [ text hostport ]
+ , td [] [ text ems.from ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/IntField.elm b/modules/webapp/src/main/elm/Comp/IntField.elm
new file mode 100644
index 00000000..498a8ec8
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/IntField.elm
@@ -0,0 +1,114 @@
+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
+ , optional : Bool
+ }
+
+
+type Msg
+ = SetValue String
+
+
+init : Maybe Int -> Maybe Int -> Bool -> String -> Model
+init min max opt label =
+ { min = min
+ , max = max
+ , label = label
+ , error = Nothing
+ , lastInput = ""
+ , optional = opt
+ }
+
+
+tooLow : Model -> Int -> Bool
+tooLow model n =
+ Maybe.map ((<) n) model.min
+ |> Maybe.withDefault (not model.optional)
+
+
+tooHigh : Model -> Int -> Bool
+tooHigh model n =
+ Maybe.map ((>) n) model.max
+ |> Maybe.withDefault (not model.optional)
+
+
+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 ->
+ if model.optional && String.trim str == "" then
+ ( { m | error = Nothing }, Nothing )
+
+ else
+ ( { m | error = Just ("'" ++ str ++ "' is not a valid number!") }
+ , Nothing
+ )
+
+
+view : Maybe Int -> String -> Model -> Html Msg
+view nval classes model =
+ div
+ [ classList
+ [ ( classes, 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
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Comp/ItemDetail.elm
index 182ca64e..2559821e 100644
--- a/modules/webapp/src/main/elm/Comp/ItemDetail.elm
+++ b/modules/webapp/src/main/elm/Comp/ItemDetail.elm
@@ -17,11 +17,14 @@ import Api.Model.OptionalDate exposing (OptionalDate)
import Api.Model.OptionalId exposing (OptionalId)
import Api.Model.OptionalText exposing (OptionalText)
import Api.Model.ReferenceList exposing (ReferenceList)
+import Api.Model.SentMails exposing (SentMails)
import Api.Model.Tag exposing (Tag)
import Api.Model.TagList exposing (TagList)
import Browser.Navigation as Nav
import Comp.DatePicker
import Comp.Dropdown exposing (isDropdownChangeMsg)
+import Comp.ItemMail
+import Comp.SentMails
import Comp.YesNoDimmer
import Data.Direction exposing (Direction)
import Data.Flags exposing (Flags)
@@ -32,6 +35,7 @@ import Html.Events exposing (onClick, onInput)
import Http
import Markdown
import Page exposing (Page(..))
+import Util.Http
import Util.Maybe
import Util.Size
import Util.String
@@ -57,6 +61,11 @@ type alias Model =
, itemProposals : ItemProposals
, dueDate : Maybe Int
, dueDatePicker : DatePicker
+ , itemMail : Comp.ItemMail.Model
+ , mailOpen : Bool
+ , mailSendResult : Maybe BasicResult
+ , sentMails : Comp.SentMails.Model
+ , sentMailsOpen : Bool
}
@@ -116,6 +125,11 @@ emptyModel =
, itemProposals = Api.Model.ItemProposals.empty
, dueDate = Nothing
, dueDatePicker = Comp.DatePicker.emptyModel
+ , itemMail = Comp.ItemMail.emptyModel
+ , mailOpen = False
+ , mailSendResult = Nothing
+ , sentMails = Comp.SentMails.init
+ , sentMailsOpen = False
}
@@ -158,6 +172,12 @@ type Msg
| GetProposalResp (Result Http.Error ItemProposals)
| RemoveDueDate
| RemoveDate
+ | ItemMailMsg Comp.ItemMail.Msg
+ | ToggleMail
+ | SendMailResp (Result Http.Error BasicResult)
+ | SentMailsMsg Comp.SentMails.Msg
+ | ToggleSentMails
+ | SentMailsResp (Result Http.Error SentMails)
@@ -258,11 +278,7 @@ setNotes flags model =
text =
OptionalText model.notesModel
in
- if model.notesModel == Nothing then
- Cmd.none
-
- else
- Api.setItemNotes flags model.item.id text SaveResp
+ Api.setItemNotes flags model.item.id text SaveResp
setDate : Flags -> Model -> Maybe Int -> Cmd Msg
@@ -282,12 +298,17 @@ update key flags next msg model =
let
( dp, dpc ) =
Comp.DatePicker.init
+
+ ( im, ic ) =
+ Comp.ItemMail.init flags
in
- ( { model | itemDatePicker = dp, dueDatePicker = dp }
+ ( { model | itemDatePicker = dp, dueDatePicker = dp, itemMail = im }
, Cmd.batch
[ getOptions flags
, Cmd.map ItemDatePickerMsg dpc
, Cmd.map DueDatePickerMsg dpc
+ , Cmd.map ItemMailMsg ic
+ , Api.getSentMails flags model.item.id SentMailsResp
]
)
@@ -366,11 +387,20 @@ update key flags next msg model =
, itemDate = item.itemDate
, dueDate = item.dueDate
}
- , Cmd.batch [ c1, c2, c3, c4, c5, getOptions flags, proposalCmd ]
+ , Cmd.batch
+ [ c1
+ , c2
+ , c3
+ , c4
+ , c5
+ , getOptions flags
+ , proposalCmd
+ , Api.getSentMails flags item.id SentMailsResp
+ ]
)
SetActiveAttachment pos ->
- ( { model | visibleAttach = pos }, Cmd.none )
+ ( { model | visibleAttach = pos, sentMailsOpen = False }, Cmd.none )
ToggleMenu ->
( { model | menuOpen = not model.menuOpen }, Cmd.none )
@@ -503,14 +533,7 @@ update key flags next msg model =
( model, setName flags model )
SetNotes str ->
- ( { model
- | notesModel =
- if str == "" then
- Nothing
-
- else
- Just str
- }
+ ( { model | notesModel = Util.Maybe.fromString str }
, Cmd.none
)
@@ -690,6 +713,86 @@ update key flags next msg model =
GetProposalResp (Err _) ->
( model, Cmd.none )
+ ItemMailMsg m ->
+ let
+ ( im, fa ) =
+ Comp.ItemMail.update m model.itemMail
+ in
+ case fa of
+ Comp.ItemMail.FormNone ->
+ ( { model | itemMail = im }, Cmd.none )
+
+ Comp.ItemMail.FormCancel ->
+ ( { model
+ | itemMail = Comp.ItemMail.clear im
+ , mailOpen = False
+ , mailSendResult = Nothing
+ }
+ , Cmd.none
+ )
+
+ Comp.ItemMail.FormSend sm ->
+ let
+ mail =
+ { item = model.item.id
+ , mail = sm.mail
+ , conn = sm.conn
+ }
+ in
+ ( model, Api.sendMail flags mail SendMailResp )
+
+ ToggleMail ->
+ ( { model | mailOpen = not model.mailOpen }, Cmd.none )
+
+ SendMailResp (Ok br) ->
+ let
+ mm =
+ if br.success then
+ Comp.ItemMail.clear model.itemMail
+
+ else
+ model.itemMail
+ in
+ ( { model
+ | itemMail = mm
+ , mailSendResult = Just br
+ }
+ , if br.success then
+ Api.itemDetail flags model.item.id GetItemResp
+
+ else
+ Cmd.none
+ )
+
+ SendMailResp (Err err) ->
+ let
+ errmsg =
+ Util.Http.errorToString err
+ in
+ ( { model | mailSendResult = Just (BasicResult False errmsg) }
+ , Cmd.none
+ )
+
+ SentMailsMsg m ->
+ let
+ sm =
+ Comp.SentMails.update m model.sentMails
+ in
+ ( { model | sentMails = sm }, Cmd.none )
+
+ ToggleSentMails ->
+ ( { model | sentMailsOpen = not model.sentMailsOpen, visibleAttach = -1 }, Cmd.none )
+
+ SentMailsResp (Ok list) ->
+ let
+ sm =
+ Comp.SentMails.initMails list.items
+ in
+ ( { model | sentMails = sm }, Cmd.none )
+
+ SentMailsResp (Err err) ->
+ ( model, Cmd.none )
+
-- view
@@ -711,6 +814,7 @@ view inav model =
, div
[ classList
[ ( "ui ablue-comp menu", True )
+ , ( "top attached", model.mailOpen )
]
]
[ a [ class "item", Page.href HomePage ]
@@ -743,13 +847,25 @@ view inav model =
[ ( "toggle item", True )
, ( "active", model.menuOpen )
]
- , title "Expand Menu"
+ , title "Edit item"
, onClick ToggleMenu
, href ""
]
[ i [ class "edit icon" ] []
]
+ , a
+ [ classList
+ [ ( "toggle item", True )
+ , ( "active", model.mailOpen )
+ ]
+ , title "Send Mail"
+ , onClick ToggleMail
+ , href "#"
+ ]
+ [ i [ class "mail outline icon" ] []
+ ]
]
+ , renderMailForm model
, div [ class "ui grid" ]
[ Html.map YesNoMsg (Comp.YesNoDimmer.view model.deleteConfirm)
, div
@@ -827,7 +943,7 @@ renderNotes model =
, onClick ToggleNotes
, href "#"
]
- [ i [ class "delete icon" ] []
+ [ i [ class "eye slash icon" ] []
]
]
]
@@ -835,6 +951,23 @@ renderNotes model =
renderAttachmentsTabMenu : Model -> Html Msg
renderAttachmentsTabMenu model =
+ let
+ mailTab =
+ if Comp.SentMails.isEmpty model.sentMails then
+ []
+
+ else
+ [ div
+ [ classList
+ [ ( "right item", True )
+ , ( "active", model.sentMailsOpen )
+ ]
+ , onClick ToggleSentMails
+ ]
+ [ text "E-Mails"
+ ]
+ ]
+ in
div [ class "ui top attached tabular menu" ]
(List.indexedMap
(\pos ->
@@ -853,11 +986,31 @@ renderAttachmentsTabMenu model =
]
)
model.item.attachments
+ ++ mailTab
)
renderAttachmentsTabBody : Model -> List (Html Msg)
renderAttachmentsTabBody model =
+ let
+ mailTab =
+ if Comp.SentMails.isEmpty model.sentMails then
+ []
+
+ else
+ [ div
+ [ classList
+ [ ( "ui attached tab segment", True )
+ , ( "active", model.sentMailsOpen )
+ ]
+ ]
+ [ h3 [ class "ui header" ]
+ [ text "Sent E-Mails"
+ ]
+ , Html.map SentMailsMsg (Comp.SentMails.view model.sentMails)
+ ]
+ ]
+ in
List.indexedMap
(\pos ->
\a ->
@@ -874,6 +1027,7 @@ renderAttachmentsTabBody model =
]
)
model.item.attachments
+ ++ mailTab
renderItemInfo : Model -> Html Msg
@@ -1197,3 +1351,37 @@ renderDueDateSuggestions model =
Util.Time.formatDate
(List.take 5 model.itemProposals.dueDate)
SetDueDateSuggestion
+
+
+renderMailForm : Model -> Html Msg
+renderMailForm model =
+ div
+ [ classList
+ [ ( "ui bottom attached segment", True )
+ , ( "invisible hidden", not model.mailOpen )
+ ]
+ ]
+ [ h4 [ class "ui header" ]
+ [ text "Send this item via E-Mail"
+ ]
+ , Html.map ItemMailMsg (Comp.ItemMail.view model.itemMail)
+ , div
+ [ classList
+ [ ( "ui message", True )
+ , ( "error"
+ , Maybe.map .success model.mailSendResult
+ |> Maybe.map not
+ |> Maybe.withDefault False
+ )
+ , ( "success"
+ , Maybe.map .success model.mailSendResult
+ |> Maybe.withDefault False
+ )
+ , ( "invisible hidden", model.mailSendResult == Nothing )
+ ]
+ ]
+ [ Maybe.map .message model.mailSendResult
+ |> Maybe.withDefault ""
+ |> text
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/ItemMail.elm b/modules/webapp/src/main/elm/Comp/ItemMail.elm
new file mode 100644
index 00000000..960d60ee
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/ItemMail.elm
@@ -0,0 +1,236 @@
+module Comp.ItemMail exposing
+ ( FormAction(..)
+ , Model
+ , Msg
+ , clear
+ , emptyModel
+ , init
+ , update
+ , view
+ )
+
+import Api
+import Api.Model.EmailSettingsList exposing (EmailSettingsList)
+import Api.Model.SimpleMail exposing (SimpleMail)
+import Comp.Dropdown
+import Data.Flags exposing (Flags)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onCheck, onClick, onInput)
+import Http
+import Util.Http
+
+
+type alias Model =
+ { connectionModel : Comp.Dropdown.Model String
+ , subject : String
+ , receiver : String
+ , body : String
+ , attachAll : Bool
+ , formError : Maybe String
+ }
+
+
+type Msg
+ = SetSubject String
+ | SetReceiver String
+ | SetBody String
+ | ConnMsg (Comp.Dropdown.Msg String)
+ | ConnResp (Result Http.Error EmailSettingsList)
+ | ToggleAttachAll
+ | Cancel
+ | Send
+
+
+type alias MailInfo =
+ { conn : String
+ , mail : SimpleMail
+ }
+
+
+type FormAction
+ = FormSend MailInfo
+ | FormCancel
+ | FormNone
+
+
+emptyModel : Model
+emptyModel =
+ { connectionModel =
+ Comp.Dropdown.makeSingle
+ { makeOption = \a -> { value = a, text = a }
+ , placeholder = "Select connection..."
+ }
+ , subject = ""
+ , receiver = ""
+ , body = ""
+ , attachAll = True
+ , formError = Nothing
+ }
+
+
+init : Flags -> ( Model, Cmd Msg )
+init flags =
+ ( emptyModel, Api.getMailSettings flags "" ConnResp )
+
+
+clear : Model -> Model
+clear model =
+ { model
+ | subject = ""
+ , receiver = ""
+ , body = ""
+ }
+
+
+update : Msg -> Model -> ( Model, FormAction )
+update msg model =
+ case msg of
+ SetSubject str ->
+ ( { model | subject = str }, FormNone )
+
+ SetReceiver str ->
+ ( { model | receiver = str }, FormNone )
+
+ SetBody str ->
+ ( { model | body = str }, FormNone )
+
+ ConnMsg m ->
+ let
+ ( cm, _ ) =
+ --TODO dropdown doesn't use cmd!!
+ Comp.Dropdown.update m model.connectionModel
+ in
+ ( { model | connectionModel = cm }, FormNone )
+
+ ToggleAttachAll ->
+ ( { model | attachAll = not model.attachAll }, FormNone )
+
+ ConnResp (Ok list) ->
+ let
+ names =
+ List.map .name list.items
+
+ cm =
+ Comp.Dropdown.makeSingleList
+ { makeOption = \a -> { value = a, text = a }
+ , placeholder = "Select Connection..."
+ , options = names
+ , selected = List.head names
+ }
+ in
+ ( { model
+ | connectionModel = cm
+ , formError =
+ if names == [] then
+ Just "No E-Mail connections configured. Goto user settings to add one."
+
+ else
+ Nothing
+ }
+ , FormNone
+ )
+
+ ConnResp (Err err) ->
+ ( { model | formError = Just (Util.Http.errorToString err) }, FormNone )
+
+ Cancel ->
+ ( model, FormCancel )
+
+ Send ->
+ case ( model.formError, Comp.Dropdown.getSelected model.connectionModel ) of
+ ( Nothing, conn :: [] ) ->
+ let
+ rec =
+ String.split "," model.receiver
+
+ sm =
+ SimpleMail rec model.subject model.body model.attachAll []
+ in
+ ( model, FormSend { conn = conn, mail = sm } )
+
+ _ ->
+ ( model, FormNone )
+
+
+isValid : Model -> Bool
+isValid model =
+ model.receiver
+ /= ""
+ && model.subject
+ /= ""
+ && model.body
+ /= ""
+ && model.formError
+ == Nothing
+
+
+view : Model -> Html Msg
+view model =
+ div
+ [ classList
+ [ ( "ui form", True )
+ , ( "error", model.formError /= Nothing )
+ ]
+ ]
+ [ div [ class "field" ]
+ [ label [] [ text "Send via" ]
+ , Html.map ConnMsg (Comp.Dropdown.view model.connectionModel)
+ ]
+ , div [ class "ui error message" ]
+ [ Maybe.withDefault "" model.formError |> text
+ ]
+ , div [ class "field" ]
+ [ label []
+ [ text "Receiver(s)"
+ , span [ class "muted" ]
+ [ text "Separate multiple recipients by comma" ]
+ ]
+ , input
+ [ type_ "text"
+ , onInput SetReceiver
+ , value model.receiver
+ ]
+ []
+ ]
+ , div [ class "field" ]
+ [ label [] [ text "Subject" ]
+ , input
+ [ type_ "text"
+ , onInput SetSubject
+ , value model.subject
+ ]
+ []
+ ]
+ , div [ class "field" ]
+ [ label [] [ text "Body" ]
+ , textarea [ onInput SetBody ]
+ [ text model.body ]
+ ]
+ , div [ class "inline field" ]
+ [ div [ class "ui checkbox" ]
+ [ input
+ [ type_ "checkbox"
+ , checked model.attachAll
+ , onCheck (\_ -> ToggleAttachAll)
+ ]
+ []
+ , label [] [ text "Include all item attachments" ]
+ ]
+ ]
+ , button
+ [ classList
+ [ ( "ui primary button", True )
+ , ( "disabled", not (isValid model) )
+ ]
+ , onClick Send
+ ]
+ [ text "Send"
+ ]
+ , button
+ [ class "ui secondary button"
+ , onClick Cancel
+ ]
+ [ text "Cancel"
+ ]
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/PasswordInput.elm b/modules/webapp/src/main/elm/Comp/PasswordInput.elm
new file mode 100644
index 00000000..06f8fc94
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/PasswordInput.elm
@@ -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
+ ]
+ []
+ ]
diff --git a/modules/webapp/src/main/elm/Comp/SentMails.elm b/modules/webapp/src/main/elm/Comp/SentMails.elm
new file mode 100644
index 00000000..7e4b3af0
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/SentMails.elm
@@ -0,0 +1,121 @@
+module Comp.SentMails exposing (..)
+
+import Api.Model.SentMail exposing (SentMail)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+import Util.Time
+
+
+type alias Model =
+ { mails : List SentMail
+ , selected : Maybe SentMail
+ }
+
+
+init : Model
+init =
+ { mails = []
+ , selected = Nothing
+ }
+
+
+initMails : List SentMail -> Model
+initMails mails =
+ { init | mails = mails }
+
+
+isEmpty : Model -> Bool
+isEmpty model =
+ List.isEmpty model.mails
+
+
+type Msg
+ = Show SentMail
+ | Hide
+
+
+update : Msg -> Model -> Model
+update msg model =
+ case msg of
+ Hide ->
+ { model | selected = Nothing }
+
+ Show m ->
+ { model | selected = Just m }
+
+
+view : Model -> Html Msg
+view model =
+ case model.selected of
+ Just mail ->
+ div [ class "ui blue basic segment" ]
+ [ div [ class "ui list" ]
+ [ div [ class "item" ]
+ [ text "From"
+ , div [ class "header" ]
+ [ text mail.sender
+ , text " ("
+ , text mail.connection
+ , text ")"
+ ]
+ ]
+ , div [ class "item" ]
+ [ text "Date"
+ , div [ class "header" ]
+ [ Util.Time.formatDateTime mail.created |> text
+ ]
+ ]
+ , div [ class "item" ]
+ [ text "Recipients"
+ , div [ class "header" ]
+ [ String.join ", " mail.recipients |> text
+ ]
+ ]
+ , div [ class "item" ]
+ [ text "Subject"
+ , div [ class "header" ]
+ [ text mail.subject
+ ]
+ ]
+ ]
+ , div [ class "ui horizontal divider" ] []
+ , div [ class "mail-body" ]
+ [ text mail.body
+ ]
+ , a
+ [ class "ui right corner label"
+ , onClick Hide
+ , href "#"
+ ]
+ [ i [ class "close icon" ] []
+ ]
+ ]
+
+ Nothing ->
+ table [ class "ui selectable pointer very basic table" ]
+ [ thead []
+ [ th [ class "collapsing" ] [ text "Recipients" ]
+ , th [] [ text "Subject" ]
+ , th [ class "collapsible" ] [ text "Sent" ]
+ , th [ class "collapsible" ] [ text "Sender" ]
+ ]
+ , tbody [] <|
+ List.map
+ renderLine
+ model.mails
+ ]
+
+
+renderLine : SentMail -> Html Msg
+renderLine mail =
+ tr [ onClick (Show mail) ]
+ [ td [ class "collapsing" ]
+ [ String.join ", " mail.recipients |> text
+ ]
+ , td [] [ text mail.subject ]
+ , td [ class "collapsing" ]
+ [ Util.Time.formatDateTime mail.created |> text
+ ]
+ , td [ class "collapsing" ] [ text mail.sender ]
+ ]
diff --git a/modules/webapp/src/main/elm/Data/SSLType.elm b/modules/webapp/src/main/elm/Data/SSLType.elm
new file mode 100644
index 00000000..35327f56
--- /dev/null
+++ b/modules/webapp/src/main/elm/Data/SSLType.elm
@@ -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"
diff --git a/modules/webapp/src/main/elm/Page/UserSettings/Data.elm b/modules/webapp/src/main/elm/Page/UserSettings/Data.elm
index e56608a7..905c6125 100644
--- a/modules/webapp/src/main/elm/Page/UserSettings/Data.elm
+++ b/modules/webapp/src/main/elm/Page/UserSettings/Data.elm
@@ -6,11 +6,13 @@ module Page.UserSettings.Data exposing
)
import Comp.ChangePasswordForm
+import Comp.EmailSettingsManage
type alias Model =
{ currentTab : Maybe Tab
, changePassModel : Comp.ChangePasswordForm.Model
+ , emailSettingsModel : Comp.EmailSettingsManage.Model
}
@@ -18,13 +20,16 @@ emptyModel : Model
emptyModel =
{ currentTab = Nothing
, changePassModel = Comp.ChangePasswordForm.emptyModel
+ , emailSettingsModel = Comp.EmailSettingsManage.emptyModel
}
type Tab
= ChangePassTab
+ | EmailSettingsTab
type Msg
= SetTab Tab
| ChangePassMsg Comp.ChangePasswordForm.Msg
+ | EmailSettingsMsg Comp.EmailSettingsManage.Msg
diff --git a/modules/webapp/src/main/elm/Page/UserSettings/Update.elm b/modules/webapp/src/main/elm/Page/UserSettings/Update.elm
index 40e34e18..8241f44c 100644
--- a/modules/webapp/src/main/elm/Page/UserSettings/Update.elm
+++ b/modules/webapp/src/main/elm/Page/UserSettings/Update.elm
@@ -1,6 +1,7 @@
module Page.UserSettings.Update exposing (update)
import Comp.ChangePasswordForm
+import Comp.EmailSettingsManage
import Data.Flags exposing (Flags)
import Page.UserSettings.Data exposing (..)
@@ -12,8 +13,20 @@ update flags msg model =
let
m =
{ model | currentTab = Just t }
+
+ ( m2, cmd ) =
+ case t of
+ EmailSettingsTab ->
+ let
+ ( em, c ) =
+ Comp.EmailSettingsManage.init flags
+ in
+ ( { m | emailSettingsModel = em }, Cmd.map EmailSettingsMsg c )
+
+ ChangePassTab ->
+ ( m, Cmd.none )
in
- ( m, Cmd.none )
+ ( m2, cmd )
ChangePassMsg m ->
let
@@ -21,3 +34,10 @@ update flags msg model =
Comp.ChangePasswordForm.update flags m model.changePassModel
in
( { model | changePassModel = m2 }, Cmd.map ChangePassMsg c2 )
+
+ EmailSettingsMsg m ->
+ let
+ ( m2, c2 ) =
+ Comp.EmailSettingsManage.update flags m model.emailSettingsModel
+ in
+ ( { model | emailSettingsModel = m2 }, Cmd.map EmailSettingsMsg c2 )
diff --git a/modules/webapp/src/main/elm/Page/UserSettings/View.elm b/modules/webapp/src/main/elm/Page/UserSettings/View.elm
index 6e1aee04..d89d1daa 100644
--- a/modules/webapp/src/main/elm/Page/UserSettings/View.elm
+++ b/modules/webapp/src/main/elm/Page/UserSettings/View.elm
@@ -1,6 +1,7 @@
module Page.UserSettings.View exposing (view)
import Comp.ChangePasswordForm
+import Comp.EmailSettingsManage
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick)
@@ -17,13 +18,22 @@ view model =
]
, div [ class "ui attached fluid segment" ]
[ div [ class "ui fluid vertical secondary menu" ]
- [ div
+ [ a
[ classActive (model.currentTab == Just ChangePassTab) "link icon item"
, onClick (SetTab ChangePassTab)
+ , href "#"
]
[ i [ class "user secret icon" ] []
, text "Change Password"
]
+ , a
+ [ classActive (model.currentTab == Just EmailSettingsTab) "link icon item"
+ , onClick (SetTab EmailSettingsTab)
+ , href "#"
+ ]
+ [ i [ class "mail icon" ] []
+ , text "E-Mail Settings"
+ ]
]
]
]
@@ -33,6 +43,9 @@ view model =
Just ChangePassTab ->
viewChangePassword model
+ Just EmailSettingsTab ->
+ viewEmailSettings model
+
Nothing ->
[]
)
@@ -40,6 +53,18 @@ view model =
]
+viewEmailSettings : Model -> List (Html Msg)
+viewEmailSettings model =
+ [ h2 [ class "ui header" ]
+ [ i [ class "mail icon" ] []
+ , div [ class "content" ]
+ [ text "E-Mail Settings"
+ ]
+ ]
+ , Html.map EmailSettingsMsg (Comp.EmailSettingsManage.view model.emailSettingsModel)
+ ]
+
+
viewChangePassword : Model -> List (Html Msg)
viewChangePassword model =
[ h2 [ class "ui header" ]
diff --git a/modules/webapp/src/main/elm/Util/Maybe.elm b/modules/webapp/src/main/elm/Util/Maybe.elm
index 00de8c40..8515fdf7 100644
--- a/modules/webapp/src/main/elm/Util/Maybe.elm
+++ b/modules/webapp/src/main/elm/Util/Maybe.elm
@@ -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
diff --git a/modules/webapp/src/main/webjar/docspell.css b/modules/webapp/src/main/webjar/docspell.css
index 24205b67..39decf23 100644
--- a/modules/webapp/src/main/webjar/docspell.css
+++ b/modules/webapp/src/main/webjar/docspell.css
@@ -65,6 +65,12 @@
padding-right: 1em;
}
+label span.muted {
+ font-size: smaller;
+ color: rgba(0,0,0,0.6);
+ margin-left: 0.5em;
+}
+
.ui.search.dropdown.open {
z-index: 20;
}
@@ -88,6 +94,10 @@
background-color: #d8dfe5;
}
+.ui.selectable.pointer.table tr {
+ cursor: pointer;
+}
+
span.small-info {
font-size: smaller;
color: rgba(0,0,0,0.6);
@@ -97,6 +107,10 @@ span.small-info {
color: rgba(0,0,0,0.4);
}
+.mail-body {
+ white-space: pre;
+}
+
.login-layout, .register-layout, .newinvite-layout {
background: #708090;
height: 101vh;
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 197cb751..2433e8a0 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -9,6 +9,7 @@ object Dependencies {
val BitpeaceVersion = "0.4.2"
val CirceVersion = "0.12.3"
val DoobieVersion = "0.8.8"
+ val EmilVersion = "0.2.0"
val FastparseVersion = "2.1.3"
val FlywayVersion = "6.1.4"
val Fs2Version = "2.1.0"
@@ -26,6 +27,11 @@ object Dependencies {
val TikaVersion = "1.23"
val YamuscaVersion = "0.6.1"
+ val emil = Seq(
+ "com.github.eikek" %% "emil-common" % EmilVersion,
+ "com.github.eikek" %% "emil-javamail" % EmilVersion
+ )
+
val stanfordNlpCore = Seq(
"edu.stanford.nlp" % "stanford-corenlp" % StanfordNlpVersion excludeAll(
ExclusionRule("com.io7m.xom", "xom"),