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