From 2e3454c7a199569714e5de889a96301ed51fded3 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 5 Jan 2020 00:12:23 +0100 Subject: [PATCH 01/12] Starting with mail settings --- build.sbt | 6 +- .../src/main/resources/docspell-openapi.yml | 173 +++++++++++++++++- .../postgresql/V1.1.0__useremail.sql | 16 ++ .../docspell/store/impl/DoobieMeta.scala | 24 +++ .../docspell/store/records/RUserEmail.scala | 71 +++++++ .../src/main/elm/Comp/EmailSettingsForm.elm | 68 +++++++ .../src/main/elm/Comp/EmailSettingsManage.elm | 70 +++++++ .../src/main/elm/Comp/EmailSettingsTable.elm | 49 +++++ .../src/main/elm/Page/UserSettings/Data.elm | 5 + .../src/main/elm/Page/UserSettings/Update.elm | 8 + .../src/main/elm/Page/UserSettings/View.elm | 27 ++- project/Dependencies.scala | 6 + 12 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.1.0__useremail.sql create mode 100644 modules/store/src/main/scala/docspell/store/records/RUserEmail.scala create mode 100644 modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm create mode 100644 modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm create mode 100644 modules/webapp/src/main/elm/Comp/EmailSettingsTable.elm 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/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 88eba465..9f8383f5 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1212,8 +1212,172 @@ 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`. + 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. + 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. + 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`. + 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`. + 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 with all attachments of the item + with `id` as mail attachments. If the item has no attachments, + then the mail is sent without any. If the item's attachments + exceed a specific size, the mail will not be sent. + 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" + components: schemas: + SimpleMail: + description: | + A simple e-mail. + required: + - recipients + - subject + - body + properties: + recipients: + type: array + items: + type: string + subject: + type: string + body: + type: string + 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 + - smtpPort + - smtpUser + - smtpPassword + - from + - sslType + - certificateCheck + 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 + sslType: + type: string + certificateCheck: + type: boolean CheckFileResult: description: | Results when searching for file checksums. @@ -2157,7 +2321,7 @@ components: id: name: id in: path - description: A identifier + description: An identifier required: true schema: type: string @@ -2182,3 +2346,10 @@ components: required: false schema: type: string + name: + name: name + in: path + description: An e-mail connection name + required: true + schema: + type: string 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..3595f5a9 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.1.0__useremail.sql @@ -0,0 +1,16 @@ +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 not null, + "smtp_user" varchar(254) not null, + "smtp_password" varchar(254) not null, + "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") +); 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..98d3b594 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -12,6 +12,8 @@ import doobie.implicits.legacy.instant._ import doobie.util.log.Success import io.circe.{Decoder, Encoder} import docspell.common.syntax.all._ +import emil.{MailAddress, SSLType} +import emil.javamail.syntax._ trait DoobieMeta { @@ -88,9 +90,31 @@ trait DoobieMeta { implicit val metaLanguage: Meta[Language] = Meta[String].imap(Language.unsafe)(_.iso3) + + implicit val sslType: Meta[SSLType] = + Meta[String].imap(DoobieMeta.readSSLType)(DoobieMeta.sslTypeString) + + implicit val mailAddress: Meta[MailAddress] = + Meta[String].imap(str => MailAddress.parse(str).fold(sys.error, identity))(_.asUnicodeString) } object DoobieMeta extends DoobieMeta { import org.log4s._ private val logger = getLogger + + private def readSSLType(str: String): SSLType = + str.toLowerCase match { + case "ssl" => SSLType.SSL + case "starttls" => SSLType.StartTLS + case "none" => SSLType.NoEncryption + case _ => sys.error(s"Invalid ssl-type: $str") + } + + private def sslTypeString(st: SSLType): String = + st match { + case SSLType.SSL => "ssl" + case SSLType.StartTLS => "starttls" + case SSLType.NoEncryption => "none" + } + } 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..5f564b4f --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala @@ -0,0 +1,71 @@ +package docspell.store.records + +import doobie._ +import doobie.implicits._ +import docspell.common._ +import docspell.store.impl.Column +import docspell.store.impl.Implicits._ +import emil.{MailAddress, SSLType} + +case class RUserEmail( + id: Ident, + uid: Ident, + name: String, + smtpHost: String, + smtpPort: Int, + smtpUser: String, + smtpPassword: Password, + smtpSsl: SSLType, + smtpCertCheck: Boolean, + mailFrom: MailAddress, + mailReplyTo: Option[MailAddress], + created: Timestamp +) {} + +object RUserEmail { + + 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 findByUser(userId: Ident): ConnectionIO[Vector[RUserEmail]] = + selectSimple(all, table, uid.is(userId)).query[RUserEmail].to[Vector] + +} 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..c3785eca --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm @@ -0,0 +1,68 @@ +module Comp.EmailSettingsForm exposing + ( Model + , Msg + , emptyModel + , update + , view + ) + +import Api.Model.EmailSettings exposing (EmailSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onInput) + + +type alias Model = + { settings : EmailSettings + , name : String + } + + +emptyModel : Model +emptyModel = + { settings = Api.Model.EmailSettings.empty + , name = "" + } + + +init : EmailSettings -> Model +init ems = + { settings = ems + , name = ems.name + } + + +type Msg + = SetName String + + +isValid : Model -> Bool +isValid model = + True + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + ( model, 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" + ] + [] + ] + ] 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..293d2df8 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm @@ -0,0 +1,70 @@ +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 (..) + + +type alias Model = + { tableModel : Comp.EmailSettingsTable.Model + , formModel : Comp.EmailSettingsForm.Model + , viewMode : ViewMode + , formError : Maybe String + , loading : Bool + , deleteConfirm : Comp.YesNoDimmer.Model + } + + +emptyModel : Model +emptyModel = + { tableModel = Comp.EmailSettingsTable.emptyModel + , formModel = Comp.EmailSettingsForm.emptyModel + , viewMode = Table + , formError = Nothing + , loading = False + , deleteConfirm = Comp.YesNoDimmer.emptyModel + } + + +init : ( Model, Cmd Msg ) +init = + ( emptyModel, Cmd.none ) + + +type ViewMode + = Table + | Form + + +type Msg + = TableMsg Comp.EmailSettingsTable.Msg + | FormMsg Comp.EmailSettingsForm.Msg + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update flags msg model = + ( model, Cmd.none ) + + +view : Model -> Html Msg +view model = + case model.viewMode of + Table -> + Html.map TableMsg (Comp.EmailSettingsTable.view model.tableModel) + + Form -> + Html.map FormMsg (Comp.EmailSettingsForm.view model.formModel) 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..d09c9bee --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/EmailSettingsTable.elm @@ -0,0 +1,49 @@ +module Comp.EmailSettingsTable exposing + ( Model + , Msg + , emptyModel + , update + , view + ) + +import Api.Model.EmailSettings exposing (EmailSettings) +import Html exposing (..) +import Html.Attributes exposing (..) + + +type alias Model = + { emailSettings : List EmailSettings + } + + +emptyModel : Model +emptyModel = + init [] + + +init : List EmailSettings -> Model +init ems = + { emailSettings = ems + } + + +type Msg + = Select EmailSettings + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + ( model, Cmd.none ) + + +view : Model -> Html Msg +view model = + table [ class "ui table" ] + [ thead [] + [ th [] [ text "Name" ] + , th [] [ text "Host/Port" ] + , th [] [ text "From" ] + ] + , tbody [] + [] + ] 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..0a17d578 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 (..) @@ -21,3 +22,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/project/Dependencies.scala b/project/Dependencies.scala index f5d467ec..33dcd904 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.1.1" val FastparseVersion = "2.1.3" val FlywayVersion = "6.1.3" 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"), From f235f3a0307d65cd12227105c8097a6218462529 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 5 Jan 2020 23:23:28 +0100 Subject: [PATCH 02/12] Starting with mail functionality --- .../scala/docspell/backend/BackendApp.scala | 3 + .../scala/docspell/backend/ops/OMail.scala | 43 ++++ .../src/main/resources/docspell-openapi.yml | 14 +- .../docspell/restserver/RestServer.scala | 27 +-- .../routes/MailSettingsRoutes.scala | 53 +++++ .../main/scala/docspell/store/EmilUtil.scala | 34 +++ .../docspell/store/impl/DoobieMeta.scala | 32 +-- .../docspell/store/records/RUserEmail.scala | 119 ++++++++++- .../src/main/elm/Comp/EmailSettingsForm.elm | 196 +++++++++++++++++- .../src/main/elm/Comp/EmailSettingsManage.elm | 130 +++++++++++- modules/webapp/src/main/elm/Comp/IntField.elm | 108 ++++++++++ .../src/main/elm/Comp/PasswordInput.elm | 74 +++++++ modules/webapp/src/main/elm/Data/SSLType.elm | 60 ++++++ modules/webapp/src/main/elm/Util/Maybe.elm | 16 +- 14 files changed, 853 insertions(+), 56 deletions(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/ops/OMail.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/MailSettingsRoutes.scala create mode 100644 modules/store/src/main/scala/docspell/store/EmilUtil.scala create mode 100644 modules/webapp/src/main/elm/Comp/IntField.elm create mode 100644 modules/webapp/src/main/elm/Comp/PasswordInput.elm create mode 100644 modules/webapp/src/main/elm/Data/SSLType.elm diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index 05ed9347..da8958cc 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -23,6 +23,7 @@ trait BackendApp[F[_]] { def node: ONode[F] def job: OJob[F] def item: OItem[F] + def mail: OMail[F] } object BackendApp { @@ -45,6 +46,7 @@ object BackendApp { nodeImpl <- ONode(store) jobImpl <- OJob(store, httpClientEc) itemImpl <- OItem(store) + mailImpl <- OMail(store) } yield new BackendApp[F] { val login: Login[F] = loginImpl val signup: OSignup[F] = signupImpl @@ -57,6 +59,7 @@ object BackendApp { val node = nodeImpl val job = jobImpl val item = itemImpl + val mail = mailImpl } def apply[F[_]: ConcurrentEffect: ContextShift]( 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..ae3eabff --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala @@ -0,0 +1,43 @@ +package docspell.backend.ops + +import cats.effect._ +import cats.implicits._ +import cats.data.OptionT +import docspell.common._ +import docspell.store._ +import docspell.store.records.RUserEmail + +trait OMail[F[_]] { + + def getSettings(accId: AccountId, nameQ: Option[String]): F[Vector[RUserEmail]] + + def createSettings(data: F[RUserEmail]): F[AddResult] + + def updateSettings(accId: AccountId, name: Ident, data: RUserEmail): F[Int] +} + +object OMail { + + def apply[F[_]: Effect](store: Store[F]): Resource[F, OMail[F]] = + Resource.pure(new OMail[F] { + def getSettings(accId: AccountId, nameQ: Option[String]): F[Vector[RUserEmail]] = + store.transact(RUserEmail.findByAccount(accId, nameQ)) + + def createSettings(data: F[RUserEmail]): F[AddResult] = + for { + ru <- data + ins = RUserEmail.insert(ru) + exists = RUserEmail.exists(ru.uid, ru.name) + ar <- store.add(ins, exists) + } yield ar + + def updateSettings(accId: AccountId, name: Ident, data: RUserEmail): F[Int] = { + val op = for { + um <- OptionT(RUserEmail.getByName(accId, name)) + n <- OptionT.liftF(RUserEmail.update(um.id, data)) + } yield n + + store.transact(op.value).map(_.getOrElse(0)) + } + }) +} diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 9f8383f5..039cbca7 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1222,7 +1222,10 @@ paths: sent e-mails. Multiple e-mail settings can be specified, they are - distinguished by their `name`. + distinguished by their `name`. The query `q` parameter does a + simple substring search in the connection name. + parameters: + - $ref: "#/components/parameters/q" responses: 200: description: Ok @@ -1352,12 +1355,9 @@ components: required: - name - smtpHost - - smtpPort - - smtpUser - - smtpPassword - from - sslType - - certificateCheck + - ignoreCertificates properties: name: type: string @@ -1374,9 +1374,11 @@ components: format: password from: type: string + replyTo: + type: string sslType: type: string - certificateCheck: + ignoreCertificates: type: boolean CheckFileResult: description: | diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index c88dfa9b..435810db 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -57,19 +57,20 @@ object RestServer { token: AuthToken ): HttpRoutes[F] = Router( - "auth" -> LoginRoutes.session(restApp.backend.login, cfg), - "tag" -> TagRoutes(restApp.backend, token), - "equipment" -> EquipmentRoutes(restApp.backend, token), - "organization" -> OrganizationRoutes(restApp.backend, token), - "person" -> PersonRoutes(restApp.backend, token), - "source" -> SourceRoutes(restApp.backend, token), - "user" -> UserRoutes(restApp.backend, token), - "collective" -> CollectiveRoutes(restApp.backend, token), - "queue" -> JobQueueRoutes(restApp.backend, token), - "item" -> ItemRoutes(restApp.backend, token), - "attachment" -> AttachmentRoutes(restApp.backend, token), - "upload" -> UploadRoutes.secured(restApp.backend, cfg, token), - "checkfile" -> CheckFileRoutes.secured(restApp.backend, token) + "auth" -> LoginRoutes.session(restApp.backend.login, cfg), + "tag" -> TagRoutes(restApp.backend, token), + "equipment" -> EquipmentRoutes(restApp.backend, token), + "organization" -> OrganizationRoutes(restApp.backend, token), + "person" -> PersonRoutes(restApp.backend, token), + "source" -> SourceRoutes(restApp.backend, token), + "user" -> UserRoutes(restApp.backend, token), + "collective" -> CollectiveRoutes(restApp.backend, token), + "queue" -> JobQueueRoutes(restApp.backend, token), + "item" -> ItemRoutes(restApp.backend, token), + "attachment" -> AttachmentRoutes(restApp.backend, token), + "upload" -> UploadRoutes.secured(restApp.backend, cfg, token), + "checkfile" -> CheckFileRoutes.secured(restApp.backend, token), + "email/settings" -> MailSettingsRoutes(restApp.backend, token) ) def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = 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..eed7d114 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSettingsRoutes.scala @@ -0,0 +1,53 @@ +package docspell.restserver.routes + +import cats.effect._ +import cats.implicits._ +import org.http4s._ +import org.http4s.dsl.Http4sDsl +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.circe.CirceEntityDecoder._ + +import docspell.backend.BackendApp +import docspell.backend.auth.AuthToken +import docspell.common._ +import docspell.restapi.model._ +import docspell.store.records.RUserEmail +import docspell.store.EmilUtil + +object MailSettingsRoutes { + + def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { + case req @ GET -> Root => + val q = req.params.get("q").map(_.trim).filter(_.nonEmpty) + for { + list <- backend.mail.getSettings(user.account, q) + res = list.map(convert) + resp <- Ok(EmailSettingsList(res.toList)) + } yield resp + + case req @ POST -> Root => + for { + in <- req.as[EmailSettings] + resp <- Ok(BasicResult(false, "not implemented")) + } yield resp + } + + } + + def convert(ru: RUserEmail): EmailSettings = + EmailSettings( + ru.name, + ru.smtpHost, + ru.smtpPort, + ru.smtpUser, + ru.smtpPassword, + EmilUtil.mailAddressString(ru.mailFrom), + ru.mailReplyTo.map(EmilUtil.mailAddressString _), + EmilUtil.sslTypeString(ru.smtpSsl), + !ru.smtpCertCheck + ) +} 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..25339c0f --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/EmilUtil.scala @@ -0,0 +1,34 @@ +package docspell.store + +import emil._ +import emil.javamail.syntax._ + +object EmilUtil { + + def readSSLType(str: String): Either[String, SSLType] = + str.toLowerCase match { + case "ssl" => Right(SSLType.SSL) + case "starttls" => Right(SSLType.StartTLS) + case "none" => Right(SSLType.NoEncryption) + case _ => Left(s"Invalid ssl-type: $str") + } + + def unsafeReadSSLType(str: String): SSLType = + readSSLType(str).fold(sys.error, identity) + + def sslTypeString(st: SSLType): String = + st match { + case SSLType.SSL => "ssl" + case SSLType.StartTLS => "starttls" + case SSLType.NoEncryption => "none" + } + + def readMailAddress(str: String): Either[String, MailAddress] = + MailAddress.parse(str) + + def unsafeReadMailAddress(str: String): MailAddress = + readMailAddress(str).fold(sys.error, identity) + + def mailAddressString(ma: MailAddress): String = + ma.asUnicodeString +} 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 98d3b594..06fb93c5 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -2,18 +2,15 @@ package docspell.store.impl import java.time.format.DateTimeFormatter import java.time.{Instant, LocalDate} - -import docspell.common.Timestamp - -import docspell.common._ +import io.circe.{Decoder, Encoder} import doobie._ -//import doobie.implicits.javatime._ import doobie.implicits.legacy.instant._ import doobie.util.log.Success -import io.circe.{Decoder, Encoder} -import docspell.common.syntax.all._ import emil.{MailAddress, SSLType} -import emil.javamail.syntax._ + +import docspell.common._ +import docspell.common.syntax.all._ +import docspell.store.EmilUtil trait DoobieMeta { @@ -92,29 +89,14 @@ trait DoobieMeta { Meta[String].imap(Language.unsafe)(_.iso3) implicit val sslType: Meta[SSLType] = - Meta[String].imap(DoobieMeta.readSSLType)(DoobieMeta.sslTypeString) + Meta[String].imap(EmilUtil.unsafeReadSSLType)(EmilUtil.sslTypeString) implicit val mailAddress: Meta[MailAddress] = - Meta[String].imap(str => MailAddress.parse(str).fold(sys.error, identity))(_.asUnicodeString) + Meta[String].imap(EmilUtil.unsafeReadMailAddress)(EmilUtil.mailAddressString) } object DoobieMeta extends DoobieMeta { import org.log4s._ private val logger = getLogger - private def readSSLType(str: String): SSLType = - str.toLowerCase match { - case "ssl" => SSLType.SSL - case "starttls" => SSLType.StartTLS - case "none" => SSLType.NoEncryption - case _ => sys.error(s"Invalid ssl-type: $str") - } - - private def sslTypeString(st: SSLType): String = - st match { - case SSLType.SSL => "ssl" - case SSLType.StartTLS => "starttls" - case SSLType.NoEncryption => "none" - } - } diff --git a/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala index 5f564b4f..98cd9dfb 100644 --- a/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala +++ b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala @@ -2,6 +2,9 @@ package docspell.store.records import doobie._ import doobie.implicits._ +import cats.effect._ +import cats.implicits._ +import cats.data.OptionT import docspell.common._ import docspell.store.impl.Column import docspell.store.impl.Implicits._ @@ -10,11 +13,11 @@ import emil.{MailAddress, SSLType} case class RUserEmail( id: Ident, uid: Ident, - name: String, + name: Ident, smtpHost: String, - smtpPort: Int, - smtpUser: String, - smtpPassword: Password, + smtpPort: Option[Int], + smtpUser: Option[String], + smtpPassword: Option[Password], smtpSsl: SSLType, smtpCertCheck: Boolean, mailFrom: MailAddress, @@ -24,6 +27,67 @@ case class RUserEmail( object RUserEmail { + def apply[F[_]: Sync]( + uid: Ident, + name: Ident, + smtpHost: String, + smtpPort: Option[Int], + smtpUser: Option[String], + smtpPassword: Option[Password], + smtpSsl: SSLType, + smtpCertCheck: Boolean, + mailFrom: MailAddress, + mailReplyTo: Option[MailAddress] + ): F[RUserEmail] = + for { + now <- Timestamp.current[F] + id <- Ident.randomId[F] + } yield RUserEmail( + id, + uid, + name, + smtpHost, + smtpPort, + smtpUser, + smtpPassword, + smtpSsl, + smtpCertCheck, + mailFrom, + mailReplyTo, + now + ) + + def apply( + accId: AccountId, + name: Ident, + smtpHost: String, + smtpPort: Option[Int], + smtpUser: Option[String], + smtpPassword: Option[Password], + smtpSsl: SSLType, + smtpCertCheck: Boolean, + mailFrom: MailAddress, + mailReplyTo: Option[MailAddress] + ): OptionT[ConnectionIO, RUserEmail] = + for { + now <- OptionT.liftF(Timestamp.current[ConnectionIO]) + id <- OptionT.liftF(Ident.randomId[ConnectionIO]) + user <- OptionT(RUser.findByAccount(accId)) + } yield RUserEmail( + id, + user.uid, + name, + smtpHost, + smtpPort, + smtpUser, + smtpPassword, + smtpSsl, + smtpCertCheck, + mailFrom, + mailReplyTo, + now + ) + val table = fr"useremail" object Columns { @@ -65,7 +129,54 @@ object RUserEmail { sql"${v.id},${v.uid},${v.name},${v.smtpHost},${v.smtpPort},${v.smtpUser},${v.smtpPassword},${v.smtpSsl},${v.smtpCertCheck},${v.mailFrom},${v.mailReplyTo},${v.created}" ).update.run + def update(eId: Ident, v: RUserEmail): ConnectionIO[Int] = + updateRow(table, id.is(eId), commas( + name.setTo(v.name), + smtpHost.setTo(v.smtpHost), + smtpPort.setTo(v.smtpPort), + smtpUser.setTo(v.smtpUser), + smtpPass.setTo(v.smtpPassword), + smtpSsl.setTo(v.smtpSsl), + smtpCertCheck.setTo(v.smtpCertCheck), + mailFrom.setTo(v.mailFrom), + mailReplyTo.setTo(v.mailReplyTo) + )).update.run + def findByUser(userId: Ident): ConnectionIO[Vector[RUserEmail]] = selectSimple(all, table, uid.is(userId)).query[RUserEmail].to[Vector] + private def findByAccount0( + accId: AccountId, + nameQ: Option[String], + exact: Boolean + ): Query0[RUserEmail] = { + val mUid = uid.prefix("m") + val mName = name.prefix("m") + val uId = RUser.Columns.uid.prefix("u") + val uColl = RUser.Columns.cid.prefix("u") + val uLogin = RUser.Columns.login.prefix("u") + val from = table ++ fr"m INNER JOIN" ++ RUser.table ++ fr"u ON" ++ mUid.is(uId) + val cond = Seq(uColl.is(accId.collective), uLogin.is(accId.user)) ++ (nameQ match { + case Some(str) if exact => Seq(mName.is(str)) + case Some(str) if !exact => Seq(mName.lowerLike(s"%${str.toLowerCase}%")) + case None => Seq.empty + }) + + selectSimple(all, from, and(cond)).query[RUserEmail] + } + + def findByAccount( + accId: AccountId, + nameQ: Option[String] + ): ConnectionIO[Vector[RUserEmail]] = + findByAccount0(accId, nameQ, false).to[Vector] + + def getByName(accId: AccountId, name: Ident): ConnectionIO[Option[RUserEmail]] = + findByAccount0(accId, Some(name.id), true).option + + def exists(accId: AccountId, name: Ident): ConnectionIO[Boolean] = + getByName(accId, name).map(_.isDefined) + + def exists(userId: Ident, connName: Ident): ConnectionIO[Boolean] = + selectCount(id, table, and(uid.is(userId), name.is(connName))).query[Int].unique.map(_ > 0) } diff --git a/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm b/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm index c3785eca..3b83578c 100644 --- a/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm @@ -2,19 +2,36 @@ module Comp.EmailSettingsForm exposing ( Model , Msg , emptyModel + , getSettings + , init , update , view ) import Api.Model.EmailSettings exposing (EmailSettings) +import Comp.Dropdown +import Comp.IntField +import Comp.PasswordInput +import Data.SSLType exposing (SSLType) import Html exposing (..) import Html.Attributes exposing (..) -import Html.Events exposing (onInput) +import Html.Events exposing (onCheck, onInput) +import Util.Maybe type alias Model = { settings : EmailSettings , name : String + , host : String + , portField : Comp.IntField.Model + , portNum : Maybe Int + , user : Maybe String + , passField : Comp.PasswordInput.Model + , password : Maybe String + , from : String + , replyTo : Maybe String + , sslType : Comp.Dropdown.Model SSLType + , ignoreCertificates : Bool } @@ -22,6 +39,22 @@ emptyModel : Model emptyModel = { settings = Api.Model.EmailSettings.empty , name = "" + , host = "" + , portField = Comp.IntField.init (Just 0) Nothing "SMTP Port" + , portNum = Nothing + , user = Nothing + , passField = Comp.PasswordInput.init + , password = Nothing + , from = "" + , replyTo = Nothing + , sslType = + Comp.Dropdown.makeSingleList + { makeOption = \s -> { value = Data.SSLType.toString s, text = Data.SSLType.label s } + , placeholder = "" + , options = Data.SSLType.all + , selected = Just Data.SSLType.None + } + , ignoreCertificates = False } @@ -29,21 +62,103 @@ init : EmailSettings -> Model init ems = { settings = ems , name = ems.name + , host = ems.smtpHost + , portField = Comp.IntField.init (Just 0) Nothing "SMTP Port" + , portNum = ems.smtpPort + , user = ems.smtpUser + , passField = Comp.PasswordInput.init + , password = ems.smtpPassword + , from = ems.from + , replyTo = ems.replyTo + , sslType = + Comp.Dropdown.makeSingleList + { makeOption = \s -> { value = Data.SSLType.toString s, text = Data.SSLType.label s } + , placeholder = "" + , options = Data.SSLType.all + , selected = Data.SSLType.fromString ems.sslType + } + , ignoreCertificates = ems.ignoreCertificates } +getSettings : Model -> ( Maybe String, EmailSettings ) +getSettings model = + ( Util.Maybe.fromString model.settings.name + , { name = model.name + , smtpHost = model.host + , smtpUser = model.user + , smtpPort = model.portNum + , smtpPassword = model.password + , from = model.from + , replyTo = model.replyTo + , sslType = + Comp.Dropdown.getSelected model.sslType + |> List.head + |> Maybe.withDefault Data.SSLType.None + |> Data.SSLType.toString + , ignoreCertificates = model.ignoreCertificates + } + ) + + type Msg = SetName String + | SetHost String + | PortMsg Comp.IntField.Msg + | SetUser String + | PassMsg Comp.PasswordInput.Msg + | SSLTypeMsg (Comp.Dropdown.Msg SSLType) + | SetFrom String + | SetReplyTo String + | ToggleCheckCert isValid : Model -> Bool isValid model = - True + model.host /= "" && model.name /= "" update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = - ( model, Cmd.none ) + case msg of + SetName str -> + ( { model | name = str }, Cmd.none ) + + SetHost str -> + ( { model | host = str }, Cmd.none ) + + PortMsg m -> + let + ( pm, val ) = + Comp.IntField.update m model.portField + in + ( { model | portField = pm, portNum = val }, Cmd.none ) + + SetUser str -> + ( { model | user = Util.Maybe.fromString str }, Cmd.none ) + + PassMsg m -> + let + ( pm, val ) = + Comp.PasswordInput.update m model.passField + in + ( { model | passField = pm, password = val }, Cmd.none ) + + SSLTypeMsg m -> + let + ( sm, sc ) = + Comp.Dropdown.update m model.sslType + in + ( { model | sslType = sm }, Cmd.map SSLTypeMsg sc ) + + SetFrom str -> + ( { model | from = str }, Cmd.none ) + + SetReplyTo str -> + ( { model | replyTo = Util.Maybe.fromString str }, Cmd.none ) + + ToggleCheckCert -> + ( { model | ignoreCertificates = not model.ignoreCertificates }, Cmd.none ) view : Model -> Html Msg @@ -61,8 +176,81 @@ view model = [ type_ "text" , value model.name , onInput SetName - , placeholder "Connection name" + , placeholder "Connection name, e.g. 'gmail.com'" ] [] + , div [ class "ui info message" ] + [ text "The connection name must not contain whitespace or special characters." + ] + ] + , div [ class "fields" ] + [ div [ class "fifteen wide required field" ] + [ label [] [ text "SMTP Host" ] + , input + [ type_ "text" + , placeholder "SMTP host name, e.g. 'mail.gmail.com'" + , value model.host + , onInput SetHost + ] + [] + ] + , Html.map PortMsg (Comp.IntField.view model.portNum model.portField) + ] + , div [ class "two fields" ] + [ div [ class "field" ] + [ label [] [ text "SMTP User" ] + , input + [ type_ "text" + , placeholder "SMTP Username, e.g. 'your.name@gmail.com'" + , Maybe.withDefault "" model.user |> value + , onInput SetUser + ] + [] + ] + , div [ class "field" ] + [ label [] [ text "SMTP Password" ] + , Html.map PassMsg (Comp.PasswordInput.view model.password model.passField) + ] + ] + , div [ class "two fields" ] + [ div [ class "required field" ] + [ label [] [ text "From Address" ] + , input + [ type_ "text" + , placeholder "Sender E-Mail address" + , value model.from + , onInput SetFrom + ] + [] + ] + , div [ class "field" ] + [ label [] [ text "Reply-To" ] + , input + [ type_ "text" + , placeholder "Optional reply-to E-Mail address" + , Maybe.withDefault "" model.replyTo |> value + , onInput SetReplyTo + ] + [] + ] + ] + , div [ class "two fields" ] + [ div [ class "inline field" ] + [ div [ class "ui checkbox" ] + [ input + [ type_ "checkbox" + , checked model.ignoreCertificates + , onCheck (\_ -> ToggleCheckCert) + ] + [] + , label [] [ text "Ignore certificate check" ] + ] + ] + ] + , div [ class "two fields" ] + [ div [ class "field" ] + [ label [] [ text "SSL" ] + , Html.map SSLTypeMsg (Comp.Dropdown.view model.sslType) + ] ] ] diff --git a/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm b/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm index 293d2df8..ba2cc6ed 100644 --- a/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm +++ b/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm @@ -17,6 +17,7 @@ import Comp.YesNoDimmer import Data.Flags exposing (Flags) import Html exposing (..) import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput) type alias Model = @@ -25,6 +26,7 @@ type alias Model = , viewMode : ViewMode , formError : Maybe String , loading : Bool + , query : String , deleteConfirm : Comp.YesNoDimmer.Model } @@ -36,6 +38,7 @@ emptyModel = , viewMode = Table , formError = Nothing , loading = False + , query = "" , deleteConfirm = Comp.YesNoDimmer.emptyModel } @@ -53,18 +56,139 @@ type ViewMode type Msg = TableMsg Comp.EmailSettingsTable.Msg | FormMsg Comp.EmailSettingsForm.Msg + | SetQuery String + | InitNew + | YesNoMsg Comp.YesNoDimmer.Msg + | RequestDelete + | SetViewMode ViewMode update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) update flags msg model = - ( model, Cmd.none ) + case msg of + InitNew -> + let + ems = + Api.Model.EmailSettings.empty + + nm = + { model + | viewMode = Form + , formError = Nothing + , formModel = Comp.EmailSettingsForm.init ems + } + in + ( nm, Cmd.none ) + + TableMsg m -> + let + ( tm, tc ) = + Comp.EmailSettingsTable.update m model.tableModel + in + ( { model | tableModel = tm }, Cmd.map TableMsg tc ) + + FormMsg m -> + let + ( fm, fc ) = + Comp.EmailSettingsForm.update m model.formModel + in + ( { model | formModel = fm }, Cmd.map FormMsg fc ) + + SetQuery str -> + ( { model | query = str }, Cmd.none ) + + YesNoMsg m -> + let + ( dm, flag ) = + Comp.YesNoDimmer.update m model.deleteConfirm + in + ( { model | deleteConfirm = dm }, Cmd.none ) + + RequestDelete -> + ( model, Cmd.none ) + + SetViewMode m -> + ( { model | viewMode = m }, Cmd.none ) view : Model -> Html Msg view model = case model.viewMode of Table -> - Html.map TableMsg (Comp.EmailSettingsTable.view model.tableModel) + viewTable model Form -> - Html.map FormMsg (Comp.EmailSettingsForm.view model.formModel) + viewForm model + + +viewTable : Model -> Html Msg +viewTable model = + div [] + [ div [ class "ui secondary menu container" ] + [ div [ class "ui container" ] + [ div [ class "fitted-item" ] + [ div [ class "ui icon input" ] + [ input + [ type_ "text" + , onInput SetQuery + , value model.query + , placeholder "Search…" + ] + [] + , i [ class "ui search icon" ] + [] + ] + ] + , div [ class "right menu" ] + [ div [ class "fitted-item" ] + [ a + [ class "ui primary button" + , href "#" + , onClick InitNew + ] + [ i [ class "plus icon" ] [] + , text "New Settings" + ] + ] + ] + ] + ] + , Html.map TableMsg (Comp.EmailSettingsTable.view model.tableModel) + ] + + +viewForm : Model -> Html Msg +viewForm model = + div [ class "ui segment" ] + [ Html.map YesNoMsg (Comp.YesNoDimmer.view model.deleteConfirm) + , Html.map FormMsg (Comp.EmailSettingsForm.view model.formModel) + , div + [ classList + [ ( "ui error message", True ) + , ( "invisible", model.formError == Nothing ) + ] + ] + [ Maybe.withDefault "" model.formError |> text + ] + , div [ class "ui divider" ] [] + , button [ class "ui primary button" ] + [ text "Submit" + ] + , a [ class "ui secondary button", onClick (SetViewMode Table), href "" ] + [ text "Cancel" + ] + , if model.formModel.settings.name /= "" then + a [ class "ui right floated red button", href "", onClick RequestDelete ] + [ text "Delete" ] + + else + span [] [] + , div + [ classList + [ ( "ui dimmer", True ) + , ( "active", model.loading ) + ] + ] + [ div [ class "ui loader" ] [] + ] + ] 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..8e519034 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/IntField.elm @@ -0,0 +1,108 @@ +module Comp.IntField exposing (Model, Msg, init, update, view) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onInput) + + +type alias Model = + { min : Maybe Int + , max : Maybe Int + , label : String + , error : Maybe String + , lastInput : String + } + + +type Msg + = SetValue String + + +init : Maybe Int -> Maybe Int -> String -> Model +init min max label = + { min = min + , max = max + , label = label + , error = Nothing + , lastInput = "" + } + + +tooLow : Model -> Int -> Bool +tooLow model n = + Maybe.map ((<) n) model.min + |> Maybe.withDefault False + + +tooHigh : Model -> Int -> Bool +tooHigh model n = + Maybe.map ((>) n) model.max + |> Maybe.withDefault False + + +update : Msg -> Model -> ( Model, Maybe Int ) +update msg model = + let + tooHighError = + Maybe.withDefault 0 model.max + |> String.fromInt + |> (++) "Number must be <= " + + tooLowError = + Maybe.withDefault 0 model.min + |> String.fromInt + |> (++) "Number must be >= " + in + case msg of + SetValue str -> + let + m = + { model | lastInput = str } + in + case String.toInt str of + Just n -> + if tooLow model n then + ( { m | error = Just tooLowError } + , Nothing + ) + + else if tooHigh model n then + ( { m | error = Just tooHighError } + , Nothing + ) + + else + ( { m | error = Nothing }, Just n ) + + Nothing -> + ( { m | error = Just ("'" ++ str ++ "' is not a valid number!") } + , Nothing + ) + + +view : Maybe Int -> Model -> Html Msg +view nval model = + div + [ classList + [ ( "field", True ) + , ( "error", model.error /= Nothing ) + ] + ] + [ label [] [ text model.label ] + , input + [ type_ "text" + , Maybe.map String.fromInt nval + |> Maybe.withDefault model.lastInput + |> value + , onInput SetValue + ] + [] + , div + [ classList + [ ( "ui pointing red basic label", True ) + , ( "hidden", model.error == Nothing ) + ] + ] + [ Maybe.withDefault "" model.error |> text + ] + ] 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/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/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 From 32050a9faf02d438c9f16510e1b8581374883fc3 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 7 Jan 2020 00:20:28 +0100 Subject: [PATCH 03/12] Finish mail settings --- .../scala/docspell/backend/ops/OMail.scala | 62 ++++++++-- .../routes/MailSettingsRoutes.scala | 74 +++++++++++- .../postgresql/V1.1.0__useremail.sql | 6 +- .../docspell/store/records/RUserEmail.scala | 44 +++++-- modules/webapp/src/main/elm/Api.elm | 56 +++++++++ .../src/main/elm/Comp/EmailSettingsForm.elm | 13 +- .../src/main/elm/Comp/EmailSettingsManage.elm | 114 ++++++++++++++++-- .../src/main/elm/Comp/EmailSettingsTable.elm | 35 +++++- modules/webapp/src/main/elm/Comp/IntField.elm | 26 ++-- .../src/main/elm/Page/UserSettings/Update.elm | 14 ++- modules/webapp/src/main/webjar/docspell.css | 4 + 11 files changed, 391 insertions(+), 57 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala index ae3eabff..388a8d23 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala @@ -3,6 +3,8 @@ package docspell.backend.ops import cats.effect._ import cats.implicits._ import cats.data.OptionT +import emil.{MailAddress, SSLType} + import docspell.common._ import docspell.store._ import docspell.store.records.RUserEmail @@ -11,33 +13,71 @@ trait OMail[F[_]] { def getSettings(accId: AccountId, nameQ: Option[String]): F[Vector[RUserEmail]] - def createSettings(data: F[RUserEmail]): F[AddResult] + def findSettings(accId: AccountId, name: Ident): OptionT[F, RUserEmail] - def updateSettings(accId: AccountId, name: Ident, data: RUserEmail): F[Int] + def createSettings(accId: AccountId, data: OMail.SmtpSettings): F[AddResult] + + def updateSettings(accId: AccountId, name: Ident, data: OMail.SmtpSettings): F[Int] + + def deleteSettings(accId: AccountId, name: Ident): F[Int] } object OMail { + 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]): Resource[F, OMail[F]] = Resource.pure(new OMail[F] { def getSettings(accId: AccountId, nameQ: Option[String]): F[Vector[RUserEmail]] = store.transact(RUserEmail.findByAccount(accId, nameQ)) - def createSettings(data: F[RUserEmail]): F[AddResult] = - for { - ru <- data - ins = RUserEmail.insert(ru) - exists = RUserEmail.exists(ru.uid, ru.name) - ar <- store.add(ins, exists) - } yield ar + def findSettings(accId: AccountId, name: Ident): OptionT[F, RUserEmail] = + OptionT(store.transact(RUserEmail.getByName(accId, name))) - def updateSettings(accId: AccountId, name: Ident, data: RUserEmail): F[Int] = { + 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)) - n <- OptionT.liftF(RUserEmail.update(um.id, data)) + 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)) }) } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/MailSettingsRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSettingsRoutes.scala index eed7d114..ffb26b49 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/MailSettingsRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSettingsRoutes.scala @@ -2,17 +2,21 @@ 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 { @@ -29,10 +33,51 @@ object MailSettingsRoutes { 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 { - in <- req.as[EmailSettings] - resp <- Ok(BasicResult(false, "not implemented")) + 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 } @@ -50,4 +95,29 @@ object MailSettingsRoutes { 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/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 index 3595f5a9..fdd711e6 100644 --- 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 @@ -3,9 +3,9 @@ CREATE TABLE "useremail" ( "uid" varchar(254) not null, "name" varchar(254) not null, "smtp_host" varchar(254) not null, - "smtp_port" int not null, - "smtp_user" varchar(254) not null, - "smtp_password" 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, diff --git a/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala index 98cd9dfb..cc67029b 100644 --- a/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala +++ b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala @@ -57,7 +57,7 @@ object RUserEmail { now ) - def apply( + def fromAccount( accId: AccountId, name: Ident, smtpHost: String, @@ -130,17 +130,21 @@ object RUserEmail { ).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 + 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] @@ -162,7 +166,7 @@ object RUserEmail { case None => Seq.empty }) - selectSimple(all, from, and(cond)).query[RUserEmail] + (selectSimple(all.map(_.prefix("m")), from, and(cond)) ++ orderBy(mName.f)).query[RUserEmail] } def findByAccount( @@ -174,6 +178,20 @@ object RUserEmail { 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) diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 31bb7eff..dde1217c 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,6 +17,7 @@ module Api exposing , getItemProposals , getJobQueueState , getJobQueueStateIn + , getMailSettings , getOrgLight , getOrganizations , getPersons @@ -60,6 +63,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) @@ -99,6 +104,57 @@ import Util.File import Util.Http as Http2 + +--- 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/EmailSettingsForm.elm b/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm index 3b83578c..461be387 100644 --- a/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm @@ -4,6 +4,7 @@ module Comp.EmailSettingsForm exposing , emptyModel , getSettings , init + , isValid , update , view ) @@ -40,7 +41,7 @@ emptyModel = { settings = Api.Model.EmailSettings.empty , name = "" , host = "" - , portField = Comp.IntField.init (Just 0) Nothing "SMTP Port" + , portField = Comp.IntField.init (Just 0) Nothing True "SMTP Port" , portNum = Nothing , user = Nothing , passField = Comp.PasswordInput.init @@ -63,7 +64,7 @@ init ems = { settings = ems , name = ems.name , host = ems.smtpHost - , portField = Comp.IntField.init (Just 0) Nothing "SMTP Port" + , portField = Comp.IntField.init (Just 0) Nothing True "SMTP Port" , portNum = ems.smtpPort , user = ems.smtpUser , passField = Comp.PasswordInput.init @@ -184,7 +185,7 @@ view model = ] ] , div [ class "fields" ] - [ div [ class "fifteen wide required field" ] + [ div [ class "thirteen wide required field" ] [ label [] [ text "SMTP Host" ] , input [ type_ "text" @@ -194,7 +195,11 @@ view model = ] [] ] - , Html.map PortMsg (Comp.IntField.view model.portNum model.portField) + , Html.map PortMsg + (Comp.IntField.view model.portNum + "three wide field" + model.portField + ) ] , div [ class "two fields" ] [ div [ class "field" ] diff --git a/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm b/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm index ba2cc6ed..9e3d55b8 100644 --- a/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm +++ b/modules/webapp/src/main/elm/Comp/EmailSettingsManage.elm @@ -18,6 +18,8 @@ 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 = @@ -43,9 +45,9 @@ emptyModel = } -init : ( Model, Cmd Msg ) -init = - ( emptyModel, Cmd.none ) +init : Flags -> ( Model, Cmd Msg ) +init flags = + ( emptyModel, Api.getMailSettings flags "" MailSettingsResp ) type ViewMode @@ -61,6 +63,10 @@ type Msg | 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 ) @@ -84,8 +90,27 @@ update flags msg model = 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 - ( { model | tableModel = tm }, Cmd.map TableMsg tc ) + ( m2, Cmd.map TableMsg tc ) FormMsg m -> let @@ -95,21 +120,84 @@ update flags msg model = ( { model | formModel = fm }, Cmd.map FormMsg fc ) SetQuery str -> - ( { model | query = str }, Cmd.none ) + 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.none ) + ( { model | deleteConfirm = dm }, cmd ) RequestDelete -> - ( model, Cmd.none ) + 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 = @@ -171,10 +259,18 @@ viewForm model = [ Maybe.withDefault "" model.formError |> text ] , div [ class "ui divider" ] [] - , button [ class "ui primary button" ] + , button + [ class "ui primary button" + , onClick Submit + , href "#" + ] [ text "Submit" ] - , a [ class "ui secondary button", onClick (SetViewMode Table), href "" ] + , a + [ class "ui secondary button" + , onClick (SetViewMode Table) + , href "" + ] [ text "Cancel" ] , if model.formModel.settings.name /= "" then diff --git a/modules/webapp/src/main/elm/Comp/EmailSettingsTable.elm b/modules/webapp/src/main/elm/Comp/EmailSettingsTable.elm index d09c9bee..bfa3a388 100644 --- a/modules/webapp/src/main/elm/Comp/EmailSettingsTable.elm +++ b/modules/webapp/src/main/elm/Comp/EmailSettingsTable.elm @@ -2,6 +2,7 @@ module Comp.EmailSettingsTable exposing ( Model , Msg , emptyModel + , init , update , view ) @@ -9,10 +10,12 @@ module Comp.EmailSettingsTable exposing 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 } @@ -24,6 +27,7 @@ emptyModel = init : List EmailSettings -> Model init ems = { emailSettings = ems + , selected = Nothing } @@ -33,17 +37,40 @@ type Msg update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = - ( model, Cmd.none ) + case msg of + Select ems -> + ( { model | selected = Just ems }, Cmd.none ) view : Model -> Html Msg view model = - table [ class "ui table" ] + table [ class "ui selectable pointer table" ] [ thead [] - [ th [] [ text "Name" ] + [ 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 index 8e519034..498a8ec8 100644 --- a/modules/webapp/src/main/elm/Comp/IntField.elm +++ b/modules/webapp/src/main/elm/Comp/IntField.elm @@ -11,6 +11,7 @@ type alias Model = , label : String , error : Maybe String , lastInput : String + , optional : Bool } @@ -18,26 +19,27 @@ type Msg = SetValue String -init : Maybe Int -> Maybe Int -> String -> Model -init min max label = +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 False + |> Maybe.withDefault (not model.optional) tooHigh : Model -> Int -> Bool tooHigh model n = Maybe.map ((>) n) model.max - |> Maybe.withDefault False + |> Maybe.withDefault (not model.optional) update : Msg -> Model -> ( Model, Maybe Int ) @@ -75,16 +77,20 @@ update msg model = ( { m | error = Nothing }, Just n ) Nothing -> - ( { m | error = Just ("'" ++ str ++ "' is not a valid number!") } - , 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 -> Model -> Html Msg -view nval model = +view : Maybe Int -> String -> Model -> Html Msg +view nval classes model = div [ classList - [ ( "field", True ) + [ ( classes, True ) , ( "error", model.error /= Nothing ) ] ] diff --git a/modules/webapp/src/main/elm/Page/UserSettings/Update.elm b/modules/webapp/src/main/elm/Page/UserSettings/Update.elm index 0a17d578..8241f44c 100644 --- a/modules/webapp/src/main/elm/Page/UserSettings/Update.elm +++ b/modules/webapp/src/main/elm/Page/UserSettings/Update.elm @@ -13,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 diff --git a/modules/webapp/src/main/webjar/docspell.css b/modules/webapp/src/main/webjar/docspell.css index 24205b67..b96f9d27 100644 --- a/modules/webapp/src/main/webjar/docspell.css +++ b/modules/webapp/src/main/webjar/docspell.css @@ -88,6 +88,10 @@ background-color: #d8dfe5; } +.ui.selectable.pointer.table tr { + cursor: pointer; +} + span.small-info { font-size: smaller; color: rgba(0,0,0,0.6); From 4490a444a90051dafa16d1a0d934b2663a9c6427 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 7 Jan 2020 00:20:41 +0100 Subject: [PATCH 04/12] Allow dots in identifiers --- modules/common/src/main/scala/docspell/common/Ident.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/common/src/main/scala/docspell/common/Ident.scala b/modules/common/src/main/scala/docspell/common/Ident.scala index 199bd225..e17d54f0 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)) From 51ce48997c8d8670089f52240614b47a0599ba35 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Wed, 8 Jan 2020 01:26:39 +0100 Subject: [PATCH 05/12] Start with send-mail widget --- .../src/main/resources/docspell-openapi.yml | 16 +- modules/webapp/src/main/elm/Comp/Dropdown.elm | 4 +- .../src/main/elm/Comp/EmailSettingsForm.elm | 5 +- .../webapp/src/main/elm/Comp/ItemDetail.elm | 66 ++++- modules/webapp/src/main/elm/Comp/ItemMail.elm | 225 ++++++++++++++++++ modules/webapp/src/main/webjar/docspell.css | 6 + 6 files changed, 317 insertions(+), 5 deletions(-) create mode 100644 modules/webapp/src/main/elm/Comp/ItemMail.elm diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 039cbca7..f78744a9 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1325,11 +1325,18 @@ components: schemas: SimpleMail: description: | - A simple e-mail. + 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. required: - recipients - subject - body + - addAllAttachments + - attachmentIds properties: recipients: type: array @@ -1339,6 +1346,13 @@ components: type: string body: type: string + addAllAttachments: + type: boolean + attachemntIds: + type: array + items: + type: string + format: ident EmailSettingsList: description: | A list of user email settings. 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 index 461be387..fa85576e 100644 --- a/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/EmailSettingsForm.elm @@ -76,7 +76,10 @@ init ems = { makeOption = \s -> { value = Data.SSLType.toString s, text = Data.SSLType.label s } , placeholder = "" , options = Data.SSLType.all - , selected = Data.SSLType.fromString ems.sslType + , selected = + Data.SSLType.fromString ems.sslType + |> Maybe.withDefault Data.SSLType.None + |> Just } , ignoreCertificates = ems.ignoreCertificates } diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Comp/ItemDetail.elm index 182ca64e..3773bee3 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail.elm @@ -22,6 +22,7 @@ import Api.Model.TagList exposing (TagList) import Browser.Navigation as Nav import Comp.DatePicker import Comp.Dropdown exposing (isDropdownChangeMsg) +import Comp.ItemMail import Comp.YesNoDimmer import Data.Direction exposing (Direction) import Data.Flags exposing (Flags) @@ -57,6 +58,8 @@ type alias Model = , itemProposals : ItemProposals , dueDate : Maybe Int , dueDatePicker : DatePicker + , itemMail : Comp.ItemMail.Model + , mailOpen : Bool } @@ -116,6 +119,8 @@ emptyModel = , itemProposals = Api.Model.ItemProposals.empty , dueDate = Nothing , dueDatePicker = Comp.DatePicker.emptyModel + , itemMail = Comp.ItemMail.emptyModel + , mailOpen = False } @@ -158,6 +163,8 @@ type Msg | GetProposalResp (Result Http.Error ItemProposals) | RemoveDueDate | RemoveDate + | ItemMailMsg Comp.ItemMail.Msg + | ToggleMail @@ -282,12 +289,16 @@ 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 ] ) @@ -690,6 +701,29 @@ 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 + } + , Cmd.none + ) + + Comp.ItemMail.FormSend sm -> + Debug.todo "implement send" + + ToggleMail -> + ( { model | mailOpen = not model.mailOpen }, Cmd.none ) + -- view @@ -711,6 +745,7 @@ view inav model = , div [ classList [ ( "ui ablue-comp menu", True ) + , ( "top attached", model.mailOpen ) ] ] [ a [ class "item", Page.href HomePage ] @@ -743,13 +778,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 icon" ] [] + ] ] + , renderMailForm model , div [ class "ui grid" ] [ Html.map YesNoMsg (Comp.YesNoDimmer.view model.deleteConfirm) , div @@ -1197,3 +1244,18 @@ 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) + ] 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..99e643ae --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ItemMail.elm @@ -0,0 +1,225 @@ +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 FormAction + = FormSend SimpleMail + | 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 -> + let + rec = + String.split "," model.receiver + + sm = + SimpleMail rec model.subject model.body model.attachAll [] + in + ( model, FormSend sm ) + + +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/webjar/docspell.css b/modules/webapp/src/main/webjar/docspell.css index b96f9d27..e3c47eb9 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; } From 7a3289c41d2996c4dc69d0ed1b7aaac5644d39d4 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Wed, 8 Jan 2020 22:44:34 +0100 Subject: [PATCH 06/12] Prepare sending mail --- .../scala/docspell/backend/ops/OMail.scala | 22 ++++++- .../docspell/backend/ops/SendResult.scala | 12 ++++ .../src/main/resources/docspell-openapi.yml | 2 +- .../docspell/restserver/RestServer.scala | 1 + .../restserver/routes/MailSendRoutes.scala | 52 +++++++++++++++ .../postgresql/V1.1.0__useremail.sql | 14 +++++ .../main/scala/docspell/store/EmilUtil.scala | 4 ++ .../docspell/store/impl/DoobieMeta.scala | 3 + .../docspell/store/records/RSentMail.scala | 63 +++++++++++++++++++ modules/webapp/src/main/elm/Api.elm | 20 ++++++ .../webapp/src/main/elm/Comp/ItemDetail.elm | 60 +++++++++++++++++- modules/webapp/src/main/elm/Comp/ItemMail.elm | 27 +++++--- 12 files changed, 268 insertions(+), 12 deletions(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala create mode 100644 modules/store/src/main/scala/docspell/store/records/RSentMail.scala diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala index 388a8d23..6fd9ab32 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala @@ -8,6 +8,7 @@ import emil.{MailAddress, SSLType} import docspell.common._ import docspell.store._ import docspell.store.records.RUserEmail +import OMail.{ItemMail, SmtpSettings} trait OMail[F[_]] { @@ -15,15 +16,31 @@ trait OMail[F[_]] { def findSettings(accId: AccountId, name: Ident): OptionT[F, RUserEmail] - def createSettings(accId: AccountId, data: OMail.SmtpSettings): F[AddResult] + 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] } object OMail { + case class ItemMail( + item: Ident, + subject: String, + recipients: List[MailAddress], + body: String, + attach: AttachSelection + ) + + sealed trait AttachSelection + object AttachSelection { + case object All extends AttachSelection + case class Selected(ids: List[Ident]) extends AttachSelection + } + case class SmtpSettings( name: Ident, smtpHost: String, @@ -79,5 +96,8 @@ object OMail { def deleteSettings(accId: AccountId, name: Ident): F[Int] = store.transact(RUserEmail.delete(accId, name)) + + def sendMail(accId: AccountId, name: Ident, m: ItemMail): F[SendResult] = + Effect[F].pure(SendResult.Failure(new Exception("not implemented"))) }) } 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..b3ac6450 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala @@ -0,0 +1,12 @@ +package docspell.backend.ops + +import docspell.common._ + +sealed trait SendResult + +object SendResult { + + case class Success(id: Ident) extends SendResult + + case class Failure(ex: Throwable) extends SendResult +} diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index f78744a9..887c4ac4 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1348,7 +1348,7 @@ components: type: string addAllAttachments: type: boolean - attachemntIds: + attachmentIds: type: array items: type: string diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 435810db..f9eb1c1b 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -70,6 +70,7 @@ object RestServer { "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) ) 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..8c10c33e --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala @@ -0,0 +1,52 @@ +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.Failure(ex) => + BasicResult(false, s"Mail sending failed: ${ex.getMessage}") + } +} 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 index fdd711e6..0080ba0d 100644 --- 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 @@ -14,3 +14,17 @@ CREATE TABLE "useremail" ( unique ("uid", "name"), foreign key ("uid") references "user_"("uid") ); + +CREATE TABLE "sentmail" ( + "id" varchar(254) not null primary key, + "uid" varchar(254) not null, + "item_id" varchar(254) not null, + "message_id" varchar(254) not null, + "sender" 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"), + foreign key("item_id") references "item"("itemid") +); diff --git a/modules/store/src/main/scala/docspell/store/EmilUtil.scala b/modules/store/src/main/scala/docspell/store/EmilUtil.scala index 25339c0f..749a041a 100644 --- a/modules/store/src/main/scala/docspell/store/EmilUtil.scala +++ b/modules/store/src/main/scala/docspell/store/EmilUtil.scala @@ -1,5 +1,6 @@ package docspell.store +import cats.implicits._ import emil._ import emil.javamail.syntax._ @@ -29,6 +30,9 @@ object EmilUtil { 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 06fb93c5..84762e0b 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -93,6 +93,9 @@ trait DoobieMeta { implicit val mailAddress: Meta[MailAddress] = Meta[String].imap(EmilUtil.unsafeReadMailAddress)(EmilUtil.mailAddressString) + + implicit def mailAddressList: Meta[List[MailAddress]] = + ??? } object DoobieMeta extends DoobieMeta { 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..1caffc04 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RSentMail.scala @@ -0,0 +1,63 @@ +package docspell.store.records + +import fs2.Stream +import doobie._ +import doobie.implicits._ +import docspell.common._ +import docspell.store.impl.Column +import docspell.store.impl.Implicits._ +import emil.MailAddress + +case class RSentMail( + id: Ident, + uid: Ident, + itemId: Ident, + messageId: String, + sender: MailAddress, + subject: String, + recipients: List[MailAddress], + body: String, + created: Timestamp +) {} + +object RSentMail { + + val table = fr"sentmail" + + object Columns { + val id = Column("id") + val uid = Column("uid") + val itemId = Column("item_id") + val messageId = Column("message_id") + val sender = Column("sender") + val subject = Column("subject") + val recipients = Column("recipients") + val body = Column("body") + val created = Column("created") + + val all = List( + id, + uid, + itemId, + messageId, + sender, + subject, + recipients, + body, + created + ) + } + + import Columns._ + + def insert(v: RSentMail): ConnectionIO[Int] = + insertRow( + table, + all, + sql"${v.id},${v.uid},${v.itemId},${v.messageId},${v.sender},${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 + +} diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index dde1217c..c45e4d6a 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -40,6 +40,7 @@ module Api exposing , putUser , refreshSession , register + , sendMail , setCollectiveSettings , setConcEquip , setConcPerson @@ -86,6 +87,7 @@ 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.SimpleMail exposing (SimpleMail) import Api.Model.Source exposing (Source) import Api.Model.SourceList exposing (SourceList) import Api.Model.Tag exposing (Tag) @@ -105,6 +107,24 @@ import Util.Http as Http2 +--- 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 diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Comp/ItemDetail.elm index 3773bee3..8e4d32c3 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail.elm @@ -33,6 +33,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 @@ -60,6 +61,7 @@ type alias Model = , dueDatePicker : DatePicker , itemMail : Comp.ItemMail.Model , mailOpen : Bool + , mailSendResult : Maybe BasicResult } @@ -121,6 +123,7 @@ emptyModel = , dueDatePicker = Comp.DatePicker.emptyModel , itemMail = Comp.ItemMail.emptyModel , mailOpen = False + , mailSendResult = Nothing } @@ -165,6 +168,7 @@ type Msg | RemoveDate | ItemMailMsg Comp.ItemMail.Msg | ToggleMail + | SendMailResp (Result Http.Error BasicResult) @@ -714,16 +718,49 @@ update key flags next msg model = ( { model | itemMail = Comp.ItemMail.clear im , mailOpen = False + , mailSendResult = Nothing } , Cmd.none ) Comp.ItemMail.FormSend sm -> - Debug.todo "implement send" + 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 + } + , Cmd.none + ) + + SendMailResp (Err err) -> + let + errmsg = + Util.Http.errorToString err + in + ( { model | mailSendResult = Just (BasicResult False errmsg) } + , Cmd.none + ) + -- view @@ -793,7 +830,7 @@ view inav model = , onClick ToggleMail , href "#" ] - [ i [ class "mail icon" ] [] + [ i [ class "mail outline icon" ] [] ] ] , renderMailForm model @@ -1258,4 +1295,23 @@ renderMailForm model = [ 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 index 99e643ae..960d60ee 100644 --- a/modules/webapp/src/main/elm/Comp/ItemMail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemMail.elm @@ -42,8 +42,14 @@ type Msg | Send +type alias MailInfo = + { conn : String + , mail : SimpleMail + } + + type FormAction - = FormSend SimpleMail + = FormSend MailInfo | FormCancel | FormNone @@ -132,14 +138,19 @@ update msg model = ( model, FormCancel ) Send -> - let - rec = - String.split "," model.receiver + 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 sm ) + sm = + SimpleMail rec model.subject model.body model.attachAll [] + in + ( model, FormSend { conn = conn, mail = sm } ) + + _ -> + ( model, FormNone ) isValid : Model -> Bool From 2d69d39dd182d4658e1010492b08b72939efccde Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 9 Jan 2020 18:20:59 +0100 Subject: [PATCH 07/12] Connect multiple items to a mail --- .../postgresql/V1.1.0__useremail.sql | 14 +++-- .../docspell/store/records/RSentMail.scala | 5 +- .../store/records/RSentMailItem.scala | 52 +++++++++++++++++++ 3 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala 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 index 0080ba0d..5cf5f9f1 100644 --- 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 @@ -18,13 +18,21 @@ CREATE TABLE "useremail" ( CREATE TABLE "sentmail" ( "id" varchar(254) not null primary key, "uid" varchar(254) not null, - "item_id" varchar(254) not null, "message_id" varchar(254) not null, "sender" 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"), - foreign key("item_id") references "item"("itemid") + 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/records/RSentMail.scala b/modules/store/src/main/scala/docspell/store/records/RSentMail.scala index 1caffc04..9bde3fcc 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSentMail.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSentMail.scala @@ -11,7 +11,6 @@ import emil.MailAddress case class RSentMail( id: Ident, uid: Ident, - itemId: Ident, messageId: String, sender: MailAddress, subject: String, @@ -27,7 +26,6 @@ object RSentMail { object Columns { val id = Column("id") val uid = Column("uid") - val itemId = Column("item_id") val messageId = Column("message_id") val sender = Column("sender") val subject = Column("subject") @@ -38,7 +36,6 @@ object RSentMail { val all = List( id, uid, - itemId, messageId, sender, subject, @@ -54,7 +51,7 @@ object RSentMail { insertRow( table, all, - sql"${v.id},${v.uid},${v.itemId},${v.messageId},${v.sender},${v.subject},${v.recipients},${v.body},${v.created}" + sql"${v.id},${v.uid},${v.messageId},${v.sender},${v.subject},${v.recipients},${v.body},${v.created}" ).update.run def findByUser(userId: Ident): Stream[ConnectionIO, RSentMail] = 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..2a729539 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala @@ -0,0 +1,52 @@ +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 create[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 + +} From b795a22992f8f65db9c7617f3322004d1199b0ee Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Fri, 10 Jan 2020 00:45:29 +0100 Subject: [PATCH 08/12] Send mails for items --- .../scala/docspell/backend/BackendApp.scala | 10 +- .../scala/docspell/backend/ops/OMail.scala | 95 +++++++++++++++++-- .../docspell/backend/ops/SendResult.scala | 16 +++- .../restserver/src/main/resources/logback.xml | 2 + .../restserver/routes/MailSendRoutes.scala | 6 +- .../docspell/store/impl/DoobieMeta.scala | 4 +- .../docspell/store/records/RAttachment.scala | 20 ++++ .../docspell/store/records/RFileMeta.scala | 22 +++++ .../scala/docspell/store/records/RItem.scala | 3 + .../docspell/store/records/RSentMail.scala | 46 +++++++-- .../store/records/RSentMailItem.scala | 9 +- .../docspell/store/records/RUserEmail.scala | 16 +++- project/Dependencies.scala | 2 +- 13 files changed, 224 insertions(+), 27 deletions(-) create mode 100644 modules/store/src/main/scala/docspell/store/records/RFileMeta.scala diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index da8958cc..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[_]] { @@ -28,10 +29,11 @@ trait BackendApp[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) @@ -46,7 +48,7 @@ object BackendApp { nodeImpl <- ONode(store) jobImpl <- OJob(store, httpClientEc) itemImpl <- OItem(store) - mailImpl <- OMail(store) + mailImpl <- OMail(store, JavaMailEmil(blocker)) } yield new BackendApp[F] { val login: Login[F] = loginImpl val signup: OSignup[F] = signupImpl @@ -70,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 index 6fd9ab32..80875f92 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala @@ -1,14 +1,22 @@ package docspell.backend.ops +import fs2.Stream import cats.effect._ import cats.implicits._ import cats.data.OptionT -import emil.{MailAddress, SSLType} +import emil._ +import emil.javamail.syntax._ import docspell.common._ import docspell.store._ import docspell.store.records.RUserEmail import OMail.{ItemMail, SmtpSettings} +import docspell.store.records.RAttachment +import bitpeace.FileMeta +import bitpeace.RangeDef +import docspell.store.records.RItem +import docspell.store.records.RSentMail +import docspell.store.records.RSentMailItem trait OMail[F[_]] { @@ -35,10 +43,19 @@ object OMail { attach: AttachSelection ) - sealed trait AttachSelection + sealed trait AttachSelection { + def filter(v: Vector[(RAttachment, FileMeta)]): Vector[(RAttachment, FileMeta)] + } object AttachSelection { - case object All extends AttachSelection - case class Selected(ids: List[Ident]) extends 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( @@ -68,7 +85,7 @@ object OMail { ) } - def apply[F[_]: Effect](store: Store[F]): Resource[F, OMail[F]] = + 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)) @@ -97,7 +114,71 @@ object OMail { def deleteSettings(accId: AccountId, name: Ident): F[Int] = store.transact(RUserEmail.delete(accId, name)) - def sendMail(accId: AccountId, name: Ident, m: ItemMail): F[SendResult] = - Effect[F].pure(SendResult.Failure(new Exception("not implemented"))) + 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, + 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) + } + }) } diff --git a/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala b/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala index b3ac6450..f64f48f6 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/SendResult.scala @@ -6,7 +6,21 @@ sealed trait SendResult object SendResult { + /** Mail was successfully sent and stored to db. + */ case class Success(id: Ident) extends SendResult - case class Failure(ex: Throwable) 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/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/routes/MailSendRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala index 8c10c33e..3d7a08e3 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/MailSendRoutes.scala @@ -46,7 +46,11 @@ object MailSendRoutes { res match { case SendResult.Success(_) => BasicResult(true, "Mail sent.") - case SendResult.Failure(ex) => + 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/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala index 84762e0b..62f058cd 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -95,7 +95,9 @@ trait DoobieMeta { 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 { 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 index 9bde3fcc..8eb1b43d 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSentMail.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSentMail.scala @@ -1,12 +1,15 @@ 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, @@ -21,17 +24,46 @@ case class RSentMail( object RSentMail { + def apply[F[_]: Sync]( + uid: Ident, + messageId: String, + sender: MailAddress, + subject: String, + recipients: List[MailAddress], + body: String + ): F[RSentMail] = + for { + id <- Ident.randomId[F] + now <- Timestamp.current[F] + } yield RSentMail(id, uid, messageId, sender, subject, recipients, body, now) + + def forItem( + itemId: Ident, + accId: AccountId, + messageId: String, + sender: MailAddress, + 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, 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 subject = Column("subject") - val recipients = Column("recipients") + val id = Column("id") + val uid = Column("uid") + val messageId = Column("message_id") + val sender = Column("sender") + val subject = Column("subject") + val recipients = Column("recipients") val body = Column("body") - val created = Column("created") + val created = Column("created") val all = List( id, diff --git a/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala b/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala index 2a729539..b9b12da2 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala @@ -17,13 +17,16 @@ case class RSentMailItem( object RSentMailItem { - def create[F[_]: Sync](itemId: Ident, sentmailId: Ident, created: Option[Timestamp] = None): F[RSentMailItem] = + def apply[F[_]: Sync]( + itemId: Ident, + sentmailId: Ident, + created: Option[Timestamp] = None + ): F[RSentMailItem] = for { - id <- Ident.randomId[F] + 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 { diff --git a/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala index cc67029b..e8fcc0b7 100644 --- a/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala +++ b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala @@ -8,7 +8,7 @@ import cats.data.OptionT import docspell.common._ import docspell.store.impl.Column import docspell.store.impl.Implicits._ -import emil.{MailAddress, SSLType} +import emil.{MailAddress, MailConfig, SSLType} case class RUserEmail( id: Ident, @@ -23,7 +23,19 @@ case class RUserEmail( 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 { diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 33dcd904..73bb2890 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -9,7 +9,7 @@ object Dependencies { val BitpeaceVersion = "0.4.2" val CirceVersion = "0.12.3" val DoobieVersion = "0.8.8" - val EmilVersion = "0.1.1" + val EmilVersion = "0.1.2-SNAPSHOT" val FastparseVersion = "2.1.3" val FlywayVersion = "6.1.3" val Fs2Version = "2.1.0" From 2ecfb679d90807b65a317d043112f412376f7b5e Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Fri, 10 Jan 2020 23:41:03 +0100 Subject: [PATCH 09/12] Add routes to retrieve sent mails --- .../scala/docspell/backend/ops/OMail.scala | 47 +++++-- .../src/main/resources/docspell-openapi.yml | 128 +++++++++++++++++- .../docspell/restserver/RestServer.scala | 3 +- .../restserver/routes/SentMailRoutes.scala | 54 ++++++++ .../postgresql/V1.1.0__useremail.sql | 1 + .../docspell/store/impl/DoobieSyntax.scala | 6 - .../scala/docspell/store/queries/QMails.scala | 64 +++++++++ .../docspell/store/records/RSentMail.scala | 16 ++- .../store/records/RSentMailItem.scala | 2 + 9 files changed, 297 insertions(+), 24 deletions(-) create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/SentMailRoutes.scala create mode 100644 modules/store/src/main/scala/docspell/store/queries/QMails.scala diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala index 80875f92..5fad31bd 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OMail.scala @@ -6,17 +6,13 @@ 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.RUserEmail -import OMail.{ItemMail, SmtpSettings} -import docspell.store.records.RAttachment -import bitpeace.FileMeta -import bitpeace.RangeDef -import docspell.store.records.RItem -import docspell.store.records.RSentMail -import docspell.store.records.RSentMailItem +import docspell.store.records._ +import docspell.store.queries.QMails +import OMail.{ItemMail, Sent, SmtpSettings} trait OMail[F[_]] { @@ -31,10 +27,32 @@ trait OMail[F[_]] { 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, @@ -155,6 +173,7 @@ object OMail { accId, msgId, cfg.mailFrom, + name, m.subject, m.recipients, m.body @@ -180,5 +199,17 @@ object OMail { } 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/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 887c4ac4..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: @@ -1224,6 +1232,8 @@ paths: 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: @@ -1238,6 +1248,8 @@ paths: summary: Create new email settings description: | Create new e-mail settings. + security: + - authTokenHeader: [] requestBody: content: application/json: @@ -1259,6 +1271,8 @@ paths: description: | Return the stored e-mail settings for the given connection name. + security: + - authTokenHeader: [] responses: 200: description: Ok @@ -1271,6 +1285,8 @@ paths: summary: Change specific email settings. description: | Changes all settings for the connection with the given `name`. + security: + - authTokenHeader: [] requestBody: content: application/json: @@ -1288,6 +1304,8 @@ paths: summary: Delete e-mail settings. description: | Deletes the e-mail settings with the specified `name`. + security: + - authTokenHeader: [] responses: 200: description: Ok @@ -1301,10 +1319,11 @@ paths: tags: [ E-Mail ] summary: Send an email. description: | - Sends an email as specified with all attachments of the item - with `id` as mail attachments. If the item has no attachments, - then the mail is sent without any. If the item's attachments - exceed a specific size, the mail will not be sent. + 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" @@ -1320,9 +1339,100 @@ paths: 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. @@ -1330,7 +1440,8 @@ components: 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. + specify a list of file-ids that you want to include. This list + is ignored, if `addAllAttachments` is set to `true`. required: - recipients - subject @@ -2369,3 +2480,10 @@ components: 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/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index f9eb1c1b..160a92a7 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -71,7 +71,8 @@ object RestServer { "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/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/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 index 5cf5f9f1..75812938 100644 --- 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 @@ -20,6 +20,7 @@ CREATE TABLE "sentmail" ( "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, 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/RSentMail.scala b/modules/store/src/main/scala/docspell/store/records/RSentMail.scala index 8eb1b43d..a0679b20 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSentMail.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSentMail.scala @@ -16,6 +16,7 @@ case class RSentMail( uid: Ident, messageId: String, sender: MailAddress, + connName: Ident, subject: String, recipients: List[MailAddress], body: String, @@ -28,6 +29,7 @@ object RSentMail { uid: Ident, messageId: String, sender: MailAddress, + connName: Ident, subject: String, recipients: List[MailAddress], body: String @@ -35,24 +37,26 @@ object RSentMail { for { id <- Ident.randomId[F] now <- Timestamp.current[F] - } yield RSentMail(id, uid, messageId, sender, subject, recipients, body, now) + } 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, subject, recipients, body)) + 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 { @@ -60,6 +64,7 @@ object RSentMail { 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") @@ -70,6 +75,7 @@ object RSentMail { uid, messageId, sender, + connName, subject, recipients, body, @@ -83,10 +89,12 @@ object RSentMail { insertRow( table, all, - sql"${v.id},${v.uid},${v.messageId},${v.sender},${v.subject},${v.recipients},${v.body},${v.created}" + 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 index b9b12da2..5f796c6e 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSentMailItem.scala @@ -52,4 +52,6 @@ object RSentMailItem { sql"${v.id},${v.itemId},${v.sentMailId},${v.created}" ).update.run + def deleteMail(mailId: Ident): ConnectionIO[Int] = + deleteFrom(table, sentMailId.is(mailId)).update.run } From 88efe13209526f5770ba04a284f2209bf8fbe8b7 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 11 Jan 2020 01:51:05 +0100 Subject: [PATCH 10/12] Fix item route responses Also avoid storing empty strings in a nullable field. --- .../src/main/scala/docspell/common/Ident.scala | 2 +- .../docspell/restserver/routes/ItemRoutes.scala | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/modules/common/src/main/scala/docspell/common/Ident.scala b/modules/common/src/main/scala/docspell/common/Ident.scala index e17d54f0..6314dffc 100644 --- a/modules/common/src/main/scala/docspell/common/Ident.scala +++ b/modules/common/src/main/scala/docspell/common/Ident.scala @@ -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/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) + } } From 6e56aad251fb0f5cc9245586c15f63315d4693ee Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 11 Jan 2020 01:51:38 +0100 Subject: [PATCH 11/12] Sow sent mails in item detail --- modules/webapp/src/main/elm/Api.elm | 19 +++ .../webapp/src/main/elm/Comp/ItemDetail.elm | 104 ++++++++++++--- .../webapp/src/main/elm/Comp/SentMails.elm | 121 ++++++++++++++++++ modules/webapp/src/main/webjar/docspell.css | 4 + 4 files changed, 231 insertions(+), 17 deletions(-) create mode 100644 modules/webapp/src/main/elm/Comp/SentMails.elm diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index c45e4d6a..fe364cff 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -22,6 +22,7 @@ module Api exposing , getOrganizations , getPersons , getPersonsLight + , getSentMails , getSources , getTags , getUsers @@ -87,6 +88,7 @@ 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) @@ -107,6 +109,23 @@ 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 diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Comp/ItemDetail.elm index 8e4d32c3..2559821e 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail.elm @@ -17,12 +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) @@ -62,6 +64,8 @@ type alias Model = , itemMail : Comp.ItemMail.Model , mailOpen : Bool , mailSendResult : Maybe BasicResult + , sentMails : Comp.SentMails.Model + , sentMailsOpen : Bool } @@ -124,6 +128,8 @@ emptyModel = , itemMail = Comp.ItemMail.emptyModel , mailOpen = False , mailSendResult = Nothing + , sentMails = Comp.SentMails.init + , sentMailsOpen = False } @@ -169,6 +175,9 @@ type Msg | ItemMailMsg Comp.ItemMail.Msg | ToggleMail | SendMailResp (Result Http.Error BasicResult) + | SentMailsMsg Comp.SentMails.Msg + | ToggleSentMails + | SentMailsResp (Result Http.Error SentMails) @@ -269,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 @@ -303,6 +308,7 @@ update key flags next msg model = , Cmd.map ItemDatePickerMsg dpc , Cmd.map DueDatePickerMsg dpc , Cmd.map ItemMailMsg ic + , Api.getSentMails flags model.item.id SentMailsResp ] ) @@ -381,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 ) @@ -518,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 ) @@ -749,7 +757,11 @@ update key flags next msg model = | itemMail = mm , mailSendResult = Just br } - , Cmd.none + , if br.success then + Api.itemDetail flags model.item.id GetItemResp + + else + Cmd.none ) SendMailResp (Err err) -> @@ -761,6 +773,26 @@ update key flags next msg model = , 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 @@ -911,7 +943,7 @@ renderNotes model = , onClick ToggleNotes , href "#" ] - [ i [ class "delete icon" ] [] + [ i [ class "eye slash icon" ] [] ] ] ] @@ -919,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 -> @@ -937,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 -> @@ -958,6 +1027,7 @@ renderAttachmentsTabBody model = ] ) model.item.attachments + ++ mailTab renderItemInfo : Model -> Html Msg 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/webjar/docspell.css b/modules/webapp/src/main/webjar/docspell.css index e3c47eb9..39decf23 100644 --- a/modules/webapp/src/main/webjar/docspell.css +++ b/modules/webapp/src/main/webjar/docspell.css @@ -107,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; From 3d70a452c67f1581b0690901844eb4afecf79515 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 11 Jan 2020 18:49:09 +0100 Subject: [PATCH 12/12] Fix emil dependency --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 73bb2890..8510fe11 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -9,7 +9,7 @@ object Dependencies { val BitpeaceVersion = "0.4.2" val CirceVersion = "0.12.3" val DoobieVersion = "0.8.8" - val EmilVersion = "0.1.2-SNAPSHOT" + val EmilVersion = "0.2.0" val FastparseVersion = "2.1.3" val FlywayVersion = "6.1.3" val Fs2Version = "2.1.0"