Merge pull request #5 from eikek/send-mail

Send items via mail
This commit is contained in:
Eike Kettner 2020-01-11 18:54:57 +01:00 committed by GitHub
commit 36075fbaaf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 3020 additions and 59 deletions

View File

@ -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")).

View File

@ -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
}

View File

@ -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))
})
}

View File

@ -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
}

View File

@ -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)

View File

@ -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

View File

@ -8,6 +8,8 @@
</appender>
<logger name="docspell" level="debug" />
<logger name="emil" level="debug"/>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>

View File

@ -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] =

View File

@ -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)
}
}

View File

@ -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.")
}
}

View File

@ -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
)
}
}

View File

@ -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
)
}

View File

@ -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")
);

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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._

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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

View File

@ -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
]

View File

@ -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)
]
]
]

View File

@ -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" ] []
]
]

View File

@ -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 ]
]

View File

@ -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
]
]

View File

@ -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
]
]

View File

@ -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"
]
]

View File

@ -0,0 +1,74 @@
module Comp.PasswordInput exposing
( Model
, Msg
, init
, update
, view
)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick, onInput)
import Util.Maybe
type alias Model =
{ show : Bool
}
init : Model
init =
{ show = False
}
type Msg
= ToggleShow (Maybe String)
| SetPassword String
update : Msg -> Model -> ( Model, Maybe String )
update msg model =
case msg of
ToggleShow pw ->
( { model | show = not model.show }
, pw
)
SetPassword str ->
let
pw =
Util.Maybe.fromString str
in
( model, pw )
view : Maybe String -> Model -> Html Msg
view pw model =
div [ class "ui left action input" ]
[ button
[ class "ui icon button"
, type_ "button"
, onClick (ToggleShow pw)
]
[ i
[ classList
[ ( "ui eye icon", True )
, ( "slash", model.show )
]
]
[]
]
, input
[ type_ <|
if model.show then
"text"
else
"password"
, onInput SetPassword
, Maybe.withDefault "" pw |> value
]
[]
]

View File

@ -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 ]
]

View File

@ -0,0 +1,60 @@
module Data.SSLType exposing
( SSLType(..)
, all
, fromString
, label
, toString
)
type SSLType
= None
| SSL
| StartTLS
all : List SSLType
all =
[ None, SSL, StartTLS ]
toString : SSLType -> String
toString st =
case st of
None ->
"none"
SSL ->
"ssl"
StartTLS ->
"starttls"
fromString : String -> Maybe SSLType
fromString str =
case String.toLower str of
"none" ->
Just None
"ssl" ->
Just SSL
"starttls" ->
Just StartTLS
_ ->
Nothing
label : SSLType -> String
label st =
case st of
None ->
"None"
SSL ->
"SSL/TLS"
StartTLS ->
"StartTLS"

View File

@ -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

View File

@ -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 )

View File

@ -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" ]

View File

@ -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

View File

@ -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;

View File

@ -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"),