From 5e4305d1d597b50ebaeea05f69df797f0a98bd78 Mon Sep 17 00:00:00 2001 From: Tim Chamberlain Date: Tue, 23 Apr 2024 20:55:58 -0500 Subject: [PATCH] CE-1068: checkpoint commit of SES support --- qqq-backend-core/pom.xml | 13 + .../actions/interfaces/QStorageInterface.java | 13 +- .../core/actions/tables/StorageAction.java | 12 + .../messaging/email/SendEmailAction.java | 2 +- .../messaging/ses/SESMessagingProvider.java | 56 +++ .../ses/SESMessagingProviderMetaData.java | 148 ++++++++ .../metadata/messaging/ses/SendSESAction.java | 335 ++++++++++++++++++ .../RenderSavedReportExecuteStep.java | 36 +- .../RenderSavedReportMetaDataProducer.java | 4 +- .../messaging/ses/SendSESActionTest.java | 243 +++++++++++++ .../qqq/backend/core/utils/TestUtils.java | 21 ++ .../s3/actions/S3StorageAction.java | 24 ++ 12 files changed, 902 insertions(+), 5 deletions(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SESMessagingProvider.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SESMessagingProviderMetaData.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SendSESAction.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SendSESActionTest.java diff --git a/qqq-backend-core/pom.xml b/qqq-backend-core/pom.xml index 2b6fb128..60561f25 100644 --- a/qqq-backend-core/pom.xml +++ b/qqq-backend-core/pom.xml @@ -173,6 +173,19 @@ 1.12.321 + + com.amazonaws + aws-java-sdk-ses + 1.12.705 + + + + cloud.localstack + localstack-utils + 0.2.20 + test + + org.quartz-scheduler quartz diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/interfaces/QStorageInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/interfaces/QStorageInterface.java index 1181494b..e3d3b980 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/interfaces/QStorageInterface.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/interfaces/QStorageInterface.java @@ -30,7 +30,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.storage.StorageInput; /******************************************************************************* ** Interface for actions that a backend can perform, based on streaming data - ** into the backend's storage. + ** into the backend's storage. *******************************************************************************/ public interface QStorageInterface { @@ -46,4 +46,15 @@ public interface QStorageInterface *******************************************************************************/ InputStream getInputStream(StorageInput storageInput) throws QException; + + /******************************************************************************* + ** + *******************************************************************************/ + default void makePublic(StorageInput storageInput) throws QException + { + ////////// + // noop // + ////////// + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/StorageAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/StorageAction.java index 6de52d99..52293a03 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/StorageAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/StorageAction.java @@ -93,4 +93,16 @@ public class StorageAction QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(backend); return (qModule); } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void makePublic(StorageInput storageInput) throws QException + { + QBackendModuleInterface qBackendModuleInterface = preAction(storageInput); + QStorageInterface storageInterface = qBackendModuleInterface.getStorageInterface(); + storageInterface.makePublic(storageInput); + } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/email/SendEmailAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/email/SendEmailAction.java index ebb67d98..eed19bb2 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/email/SendEmailAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/email/SendEmailAction.java @@ -87,7 +87,7 @@ public class SendEmailAction addRecipient(emailMessage, to); } - Party from = sendMessageInput.getTo(); + Party from = sendMessageInput.getFrom(); if(from instanceof MultiParty fromMultiParty) { for(Party party : fromMultiParty.getPartyList()) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SESMessagingProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SESMessagingProvider.java new file mode 100644 index 00000000..8f08974f --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SESMessagingProvider.java @@ -0,0 +1,56 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.model.metadata.messaging.ses; + + +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput; +import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class SESMessagingProvider implements MessagingProviderInterface +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getType() + { + return (SESMessagingProviderMetaData.TYPE); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public SendMessageOutput sendMessage(SendMessageInput sendMessageInput) throws QException + { + return new SendSESAction().sendMessage(sendMessageInput); + } +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SESMessagingProviderMetaData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SESMessagingProviderMetaData.java new file mode 100644 index 00000000..24a18514 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SESMessagingProviderMetaData.java @@ -0,0 +1,148 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.model.metadata.messaging.ses; + + +import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData; +import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class SESMessagingProviderMetaData extends QMessagingProviderMetaData +{ + private String accessKey; + private String secretKey; + private String region; + + public static final String TYPE = "SES"; + + static + { + QMessagingProviderDispatcher.registerMessagingProvider(new SESMessagingProvider()); + } + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public SESMessagingProviderMetaData() + { + super(); + setType(TYPE); + } + + + + /******************************************************************************* + ** Getter for accessKey + *******************************************************************************/ + public String getAccessKey() + { + return (this.accessKey); + } + + + + /******************************************************************************* + ** Setter for accessKey + *******************************************************************************/ + public void setAccessKey(String accessKey) + { + this.accessKey = accessKey; + } + + + + /******************************************************************************* + ** Fluent setter for accessKey + *******************************************************************************/ + public SESMessagingProviderMetaData withAccessKey(String accessKey) + { + this.accessKey = accessKey; + return (this); + } + + + + /******************************************************************************* + ** Getter for secretKey + *******************************************************************************/ + public String getSecretKey() + { + return (this.secretKey); + } + + + + /******************************************************************************* + ** Setter for secretKey + *******************************************************************************/ + public void setSecretKey(String secretKey) + { + this.secretKey = secretKey; + } + + + + /******************************************************************************* + ** Fluent setter for secretKey + *******************************************************************************/ + public SESMessagingProviderMetaData withSecretKey(String secretKey) + { + this.secretKey = secretKey; + return (this); + } + + + + /******************************************************************************* + ** Getter for region + *******************************************************************************/ + public String getRegion() + { + return (this.region); + } + + + + /******************************************************************************* + ** Setter for region + *******************************************************************************/ + public void setRegion(String region) + { + this.region = region; + } + + + + /******************************************************************************* + ** Fluent setter for region + *******************************************************************************/ + public SESMessagingProviderMetaData withRegion(String region) + { + this.region = region; + return (this); + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SendSESAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SendSESAction.java new file mode 100644 index 00000000..53e13f54 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SendSESAction.java @@ -0,0 +1,335 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.model.metadata.messaging.ses; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.simpleemail.AmazonSimpleEmailService; +import com.amazonaws.services.simpleemail.AmazonSimpleEmailServiceClientBuilder; +import com.amazonaws.services.simpleemail.model.Body; +import com.amazonaws.services.simpleemail.model.Content; +import com.amazonaws.services.simpleemail.model.Destination; +import com.amazonaws.services.simpleemail.model.Message; +import com.amazonaws.services.simpleemail.model.SendEmailRequest; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Party; +import com.kingsrook.qqq.backend.core.model.actions.messaging.PartyRole; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput; +import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole; +import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class SendSESAction +{ + private static final QLogger LOG = QLogger.getLogger(SendSESAction.class); + + private AmazonSimpleEmailService amazonSES; + + + + /******************************************************************************* + ** + *******************************************************************************/ + public SendMessageOutput sendMessage(SendMessageInput sendMessageInput) throws QException + { + try + { + AmazonSimpleEmailService client = getAmazonSES(sendMessageInput); + + /////////////////////////////////// + // build up a send email request // + /////////////////////////////////// + SendEmailRequest request = new SendEmailRequest() + .withSource(getSource(sendMessageInput)) + .withReplyToAddresses(getReplyTos(sendMessageInput)) + .withDestination(buildDestination(sendMessageInput)) + .withMessage(buildMessage(sendMessageInput)); + + client.sendEmail(request); + LOG.info("SES Message [" + request.getMessage().getSubject().getData() + "] was sent to [" + request.getDestination().toString() + "]."); + } + catch(Exception e) + { + String message = "An unexpected error occurred sending an SES message."; + throw (new QException(message, e)); + } + + return (null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + Message buildMessage(SendMessageInput input) throws QException + { + /////////////////////////////////////////////////////////////////////////////////////////////// + // iterate over all contents of our input, looking for an HTML and Text version of the email // + /////////////////////////////////////////////////////////////////////////////////////////////// + Body body = new Body(); + for(com.kingsrook.qqq.backend.core.model.actions.messaging.Content content : CollectionUtils.nonNullList(input.getContentList())) + { + if(EmailContentRole.TEXT.equals(content.getContentRole())) + { + body.setText(new Content().withCharset("UTF-8").withData(content.getBody())); + } + else if(EmailContentRole.HTML.equals(content.getContentRole())) + { + body.setHtml(new Content().withCharset("UTF-8").withData(content.getBody())); + } + } + + //////////////////////////////////////////////// + // error if no text or html body was provided // + //////////////////////////////////////////////// + if(body.getText() == null && body.getHtml() == null) + { + throw (new QException("Cannot send SES message because neither a 'Text' nor an 'HTML' body was provided.")); + } + + //////////////////////////////////////// + // warning if no subject was provided // + //////////////////////////////////////// + Message message = new Message(); + message.setBody(body); + + ///////////////////////////////////// + // warn if no subject was provided // + ///////////////////////////////////// + if(input.getSubject() == null) + { + LOG.warn("Sending SES message with no subject."); + } + else + { + message.setSubject(new Content().withCharset("UTF-8").withData(input.getSubject())); + } + + return (message); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + List getReplyTos(SendMessageInput input) throws QException + { + //////////////////////////// + // no input, no reply tos // + //////////////////////////// + if(input == null) + { + return (Collections.emptyList()); + } + + /////////////////////////////////////// + // build up a list of froms if multi // + /////////////////////////////////////// + List partyList = getPartyListFromParty(input.getFrom()); + if(partyList == null) + { + return (Collections.emptyList()); + } + + /////////////////////////////// + // only get reply to parties // + /////////////////////////////// + List replyToParties = partyList.stream().filter(p -> EmailPartyRole.REPLY_TO.equals(p.getRole())).toList(); + + ////////////////////////////////// + // get addresses from reply tos // + ////////////////////////////////// + List replyTos = replyToParties.stream().map(Party::getAddress).toList(); + + ///////////////////////////// + // return the from address // + ///////////////////////////// + return (replyTos); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + String getSource(SendMessageInput input) throws QException + { + /////////////////////////////// + // error if no from provided // + /////////////////////////////// + if(input.getFrom() == null) + { + throw (new QException("Cannot send SES message because a FROM was not provided.")); + } + + /////////////////////////////////////// + // build up a list of froms if multi // + /////////////////////////////////////// + List partyList = getPartyListFromParty(input.getFrom()); + + /////////////////////////////////////// + // remove any roles that aren't FROM // + /////////////////////////////////////// + partyList.removeIf(p -> p.getRole() != null && !EmailPartyRole.FROM.equals(p.getRole())); + + /////////////////////////////////////////////////////////////////////////////////////////// + // if no froms found, error, if more than one found, log a warning and use the first one // + /////////////////////////////////////////////////////////////////////////////////////////// + if(partyList.isEmpty()) + { + throw (new QException("Cannot send SES message because a FROM was not provided.")); + } + else if(partyList.size() > 1) + { + LOG.warn("More than one FROM value was found, will send using the first one found [" + partyList.get(0).getAddress() + "]."); + } + + ///////////////////////////// + // return the from address // + ///////////////////////////// + return (partyList.get(0).getAddress()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + List getPartyListFromParty(Party party) + { + ////////////////////////////////////////////// + // get all parties into one list of parties // + ////////////////////////////////////////////// + List partyList = new ArrayList<>(); + if(party != null) + { + if(party instanceof MultiParty toMultiParty) + { + partyList.addAll(toMultiParty.getPartyList()); + } + else + { + partyList.add(party); + } + } + return (partyList); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + Destination buildDestination(SendMessageInput input) throws QException + { + //////////////////////////////////////////////////////////////////// + // iterate over the parties putting it the proper party type list // + //////////////////////////////////////////////////////////////////// + List toList = new ArrayList<>(); + List ccList = new ArrayList<>(); + List bccList = new ArrayList<>(); + + List partyList = getPartyListFromParty(input.getTo()); + for(Party party : partyList) + { + if(EmailPartyRole.CC.equals(party.getRole())) + { + ccList.add(party.getAddress()); + } + else if(EmailPartyRole.BCC.equals(party.getRole())) + { + bccList.add(party.getAddress()); + } + else if(party.getRole() == null || PartyRole.Default.DEFAULT.equals(party.getRole()) || EmailPartyRole.TO.equals(party.getRole())) + { + toList.add(party.getAddress()); + } + else + { + throw (new QException("An unrecognized recipient role of [" + party.getRole() + "] was provided.")); + } + } + + ////////////////////////////////////////// + // if no to addresses, this is an error // + ////////////////////////////////////////// + if(toList.isEmpty()) + { + throw (new QException("Cannot send SES message because no TO addresses were provided.")); + } + + ///////////////////////////////////////////// + // build and return aws destination object // + ///////////////////////////////////////////// + return (new Destination() + .withToAddresses(toList) + .withCcAddresses(ccList) + .withBccAddresses(bccList)); + } + + + + /******************************************************************************* + ** Set the amazonSES object. + *******************************************************************************/ + public void setAmazonSES(AmazonSimpleEmailService amazonSES) + { + this.amazonSES = amazonSES; + } + + + + /******************************************************************************* + ** Internal accessor for the amazonSES object - should always use this, not the field. + *******************************************************************************/ + protected AmazonSimpleEmailService getAmazonSES(SendMessageInput sendMessageInput) + { + if(amazonSES == null) + { + SESMessagingProviderMetaData messagingProvider = (SESMessagingProviderMetaData) QContext.getQInstance().getMessagingProvider(sendMessageInput.getMessagingProviderName()); + + ///////////////////////////////////////////// + // get credentials and build an SES client // + ///////////////////////////////////////////// + BasicAWSCredentials credentials = new BasicAWSCredentials(messagingProvider.getAccessKey(), messagingProvider.getSecretKey()); + amazonSES = AmazonSimpleEmailServiceClientBuilder.standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(messagingProvider.getRegion()).build(); + } + + return amazonSES; + } +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/RenderSavedReportExecuteStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/RenderSavedReportExecuteStep.java index 9cf6ba45..766a2a09 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/RenderSavedReportExecuteStep.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/RenderSavedReportExecuteStep.java @@ -31,13 +31,21 @@ import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Map; import java.util.UUID; +import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.actions.reporting.GenerateReportAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.StorageAction; import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; +import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Party; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; +import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole; +import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportDestination; @@ -53,6 +61,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData; import com.kingsrook.qqq.backend.core.model.savedreports.RenderedReport; import com.kingsrook.qqq.backend.core.model.savedreports.RenderedReportStatus; import com.kingsrook.qqq.backend.core.model.savedreports.SavedReport; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.ExceptionUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -82,10 +91,14 @@ public class RenderSavedReportExecuteStep implements BackendStep //////////////////////////////// String storageTableName = runBackendStepInput.getValueString(RenderSavedReportMetaDataProducer.FIELD_NAME_STORAGE_TABLE_NAME); ReportFormat reportFormat = ReportFormat.fromString(runBackendStepInput.getValueString(RenderSavedReportMetaDataProducer.FIELD_NAME_REPORT_FORMAT)); + String sendToEmailAddress = runBackendStepInput.getValueString(RenderSavedReportMetaDataProducer.FIELD_NAME_EMAIL_ADDRESS); SavedReport savedReport = new SavedReport(runBackendStepInput.getRecords().get(0)); String downloadFileBaseName = getDownloadFileBaseName(runBackendStepInput, savedReport); String storageReference = LocalDate.now() + "/" + LocalTime.now().toString().replaceAll(":", "").replaceFirst("\\..*", "") + "/" + UUID.randomUUID() + "/" + downloadFileBaseName + "." + reportFormat.getExtension(); - OutputStream outputStream = new StorageAction().createOutputStream(new StorageInput(storageTableName).withReference(storageReference)); + + StorageAction storageAction = new StorageAction(); + StorageInput storageInput = new StorageInput(storageTableName).withReference(storageReference); + OutputStream outputStream = storageAction.createOutputStream(storageInput); LOG.info("Starting to render a report", logPair("savedReportId", savedReport.getId()), logPair("tableName", savedReport.getTableName()), logPair("storageReference", storageReference)); runBackendStepInput.getAsyncJobCallback().updateStatus("Generating Report"); @@ -120,6 +133,7 @@ public class RenderSavedReportExecuteStep implements BackendStep reportInput.setInputValues(values); ReportOutput reportOutput = new GenerateReportAction().execute(reportInput); + storageAction.makePublic(storageInput); /////////////////////////////////// // update record to show success // @@ -132,10 +146,28 @@ public class RenderSavedReportExecuteStep implements BackendStep .withValue("rowCount", reportOutput.getTotalRecordCount()) )); - runBackendStepOutput.addValue("downloadFileName", downloadFileBaseName + "." + reportFormat.getExtension()); + String downloadFileName = downloadFileBaseName + "." + reportFormat.getExtension(); + runBackendStepOutput.addValue("downloadFileName", downloadFileName); runBackendStepOutput.addValue("storageTableName", storageTableName); runBackendStepOutput.addValue("storageReference", storageReference); LOG.info("Completed rendering a report", logPair("savedReportId", savedReport.getId()), logPair("tableName", savedReport.getTableName()), logPair("storageReference", storageReference), logPair("rowCount", reportOutput.getTotalRecordCount())); + + if(sendToEmailAddress != null && CollectionUtils.nullSafeHasContents(QContext.getQInstance().getMessagingProviders())) + { + String s3Url = "https://bucket-ctlive-reports-dev.s3.us-east-2.amazonaws.com/saved-reports/" + storageReference; // TODO: derp + + new SendMessageAction().execute(new SendMessageInput() + .withMessagingProviderName("defaultMessagingProvider") // TODO: derp + .withTo(new Party().withAddress(sendToEmailAddress).withRole(EmailPartyRole.TO)) + .withFrom(new MultiParty() + .withParty(new Party().withAddress("reports@coldtrack-dev.com").withRole(EmailPartyRole.FROM)) // TODO: derp + .withParty(new Party().withAddress("noreply@coldtrack-dev.com").withRole(EmailPartyRole.REPLY_TO)) // TODO: derp + ) + .withSubject(downloadFileBaseName) + .withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody("To download your report, open this URL in your browser: " + s3Url)) + .withContent(new Content().withContentRole(EmailContentRole.HTML).withBody("Link: " + downloadFileName + "")) + ); + } } catch(Exception e) { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/RenderSavedReportMetaDataProducer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/RenderSavedReportMetaDataProducer.java index f63d914f..828f5aa6 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/RenderSavedReportMetaDataProducer.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/RenderSavedReportMetaDataProducer.java @@ -49,6 +49,7 @@ public class RenderSavedReportMetaDataProducer implements MetaDataProducerInterf public static final String FIELD_NAME_STORAGE_TABLE_NAME = "storageTableName"; public static final String FIELD_NAME_REPORT_FORMAT = "reportFormat"; + public static final String FIELD_NAME_EMAIL_ADDRESS = "reportDestinationEmailAddress"; @@ -74,7 +75,8 @@ public class RenderSavedReportMetaDataProducer implements MetaDataProducerInterf .withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)) .withFormField(new QFieldMetaData(FIELD_NAME_REPORT_FORMAT, QFieldType.STRING) .withPossibleValueSourceName(ReportFormatPossibleValueEnum.NAME) - .withIsRequired(true))) + .withIsRequired(true)) + .withFormField(new QFieldMetaData(FIELD_NAME_EMAIL_ADDRESS, QFieldType.STRING).withLabel("Send To Email Address"))) .addStep(new QBackendStepMetaData() .withName("execute") .withInputData(new QFunctionInputMetaData().withRecordListMetaData(new QRecordListMetaData() diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SendSESActionTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SendSESActionTest.java new file mode 100644 index 00000000..16f8792a --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/ses/SendSESActionTest.java @@ -0,0 +1,243 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.model.metadata.messaging.ses; + + +import cloud.localstack.ServiceName; +import cloud.localstack.docker.LocalstackDockerExtension; +import cloud.localstack.docker.annotation.LocalstackDockerProperties; +import com.amazonaws.services.simpleemail.AmazonSimpleEmailService; +import com.amazonaws.services.simpleemail.model.Destination; +import com.amazonaws.services.simpleemail.model.Message; +import com.amazonaws.services.simpleemail.model.VerifyEmailAddressRequest; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; +import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Party; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; +import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole; +import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/******************************************************************************* + ** Unit test for SendSESAction + *******************************************************************************/ +@ExtendWith(LocalstackDockerExtension.class) +@LocalstackDockerProperties(useSingleDockerContainer = true, services = { ServiceName.SES }, portEdge = "2960", portElasticSearch = "2961", imageTag = "1.4") +class SendSESActionTest extends BaseTest +{ + public static final String TEST_TO_EMAIL_ADDRESS = "tim-to@coldtrack.com"; + public static final String TEST_FROM_EMAIL_ADDRESS = "tim-from@coldtrack.com"; + + + + /******************************************************************************* + ** Before each unit test, get the test bucket into a known state + *******************************************************************************/ + @BeforeEach + public void beforeEach() + { + AmazonSimpleEmailService amazonSES = getAmazonSES(); + amazonSES.verifyEmailAddress(new VerifyEmailAddressRequest().withEmailAddress(TEST_TO_EMAIL_ADDRESS)); + amazonSES.verifyEmailAddress(new VerifyEmailAddressRequest().withEmailAddress(TEST_FROM_EMAIL_ADDRESS)); + } + + + + /******************************************************************************* + ** Access a localstack-configured SES client. + *******************************************************************************/ + protected AmazonSimpleEmailService getAmazonSES() + { + return (cloud.localstack.awssdkv1.TestUtils.getClientSES()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testSendSES() throws QException + { + SendMessageInput sendMessageInput = new SendMessageInput() + .withMessagingProviderName(TestUtils.SES_MESSAGING_PROVIDER_NAME) + .withTo(new MultiParty() + .withParty(new Party().withAddress(TEST_TO_EMAIL_ADDRESS).withLabel("Test TO").withRole(EmailPartyRole.TO)) + ) + .withFrom(new Party().withAddress(TEST_FROM_EMAIL_ADDRESS).withLabel("Test FROM")) + .withSubject("This is another qqq test message.") + .withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody("This is a text body")) + .withContent(new Content().withContentRole(EmailContentRole.HTML).withBody("This is an HTML body!")); + + SendSESAction sendSESAction = new SendSESAction(); + sendSESAction.setAmazonSES(getAmazonSES()); + sendSESAction.sendMessage(sendMessageInput); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testGetPartyListFromParty() throws QException + { + SendSESAction sendSESAction = new SendSESAction(); + assertEquals(0, sendSESAction.getPartyListFromParty(null).size()); + assertEquals(1, sendSESAction.getPartyListFromParty(new Party()).size()); + assertEquals(4, sendSESAction.getPartyListFromParty( + new MultiParty() + .withParty(new Party()) + .withParty(new Party()) + .withParty(new Party()) + .withParty(new Party()) + ).size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testGetSource() throws QException + { + ///////////////////////////////////////////////////////////// + // assert exception if no from given or one without a FROM // + ///////////////////////////////////////////////////////////// + SendSESAction sendSESAction = new SendSESAction(); + assertThatThrownBy(() -> sendSESAction.getSource(new SendMessageInput())).isInstanceOf(QException.class).hasMessageContaining("not provided"); + assertThatThrownBy(() -> sendSESAction.getSource(new SendMessageInput().withFrom(new Party().withRole(EmailPartyRole.REPLY_TO)))).isInstanceOf(QException.class).hasMessageContaining("not provided"); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + // should only be one source, and should be the first one in multi party since multiple not supported // + //////////////////////////////////////////////////////////////////////////////////////////////////////// + SendMessageInput multiPartyInput = new SendMessageInput().withFrom(new MultiParty() + .withParty(new Party().withAddress("test1").withRole(EmailPartyRole.REPLY_TO)) + .withParty(new Party().withAddress("test2").withRole(EmailPartyRole.FROM)) + .withParty(new Party().withAddress("test3").withRole(EmailPartyRole.REPLY_TO)) + .withParty(new Party().withAddress("test4").withRole(EmailPartyRole.FROM)) + ); + assertEquals("test2", sendSESAction.getSource(multiPartyInput)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testGetReplyTos() throws QException + { + SendMessageInput multiPartyInput = new SendMessageInput() + .withFrom(new MultiParty() + .withParty(new Party().withAddress("test1").withRole(EmailPartyRole.REPLY_TO)) + .withParty(new Party().withAddress("test2").withRole(EmailPartyRole.FROM)) + .withParty(new Party().withAddress("test3").withRole(EmailPartyRole.REPLY_TO)) + .withParty(new Party().withAddress("test4").withRole(EmailPartyRole.FROM)) + ); + SendMessageInput singleInput = new SendMessageInput() + .withFrom(new Party().withAddress("test1").withRole(EmailPartyRole.FROM)); + + SendSESAction sendSESAction = new SendSESAction(); + assertEquals(2, sendSESAction.getReplyTos(multiPartyInput).size()); + assertEquals(0, sendSESAction.getReplyTos(singleInput).size()); + assertEquals(0, sendSESAction.getReplyTos(null).size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testBuildDestination() throws QException + { + ///////////////////////////////////////// + // assert exception if no tos provided // + ///////////////////////////////////////// + SendSESAction sendSESAction = new SendSESAction(); + assertThatThrownBy(() -> sendSESAction.buildDestination(new SendMessageInput())).isInstanceOf(QException.class).hasMessageContaining("were provided"); + assertThatThrownBy(() -> sendSESAction.buildDestination(new SendMessageInput().withTo(new Party().withAddress("test1").withRole(EmailPartyRole.CC)))).isInstanceOf(QException.class).hasMessageContaining("were provided"); + + /////////////////////////////////////////// + // exception if a FROM given in to field // + /////////////////////////////////////////// + assertThatThrownBy(() -> sendSESAction.buildDestination(new SendMessageInput().withTo(new Party().withAddress("test1").withRole(EmailPartyRole.FROM)))).isInstanceOf(QException.class).hasMessageContaining("unrecognized"); + + SendMessageInput multiPartyInput = new SendMessageInput() + .withTo(new MultiParty() + .withParty(new Party().withAddress("test1").withRole(EmailPartyRole.CC)) + .withParty(new Party().withAddress("test2").withRole(EmailPartyRole.TO)) + .withParty(new Party().withAddress("test3").withRole(EmailPartyRole.BCC)) + .withParty(new Party().withAddress("test4").withRole(EmailPartyRole.BCC)) + .withParty(new Party().withAddress("test5").withRole(EmailPartyRole.TO)) + .withParty(new Party().withAddress("test6").withRole(EmailPartyRole.TO)) + .withParty(new Party().withAddress("test7").withRole(EmailPartyRole.TO)) + ); + Destination destination = sendSESAction.buildDestination(multiPartyInput); + assertEquals(2, destination.getBccAddresses().size()); + assertEquals(4, destination.getToAddresses().size()); + assertEquals(1, destination.getCcAddresses().size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testBuildMessage() throws QException + { + ///////////////////////////////////////// + // assert exception if no tos provided // + ///////////////////////////////////////// + SendSESAction sendSESAction = new SendSESAction(); + assertThatThrownBy(() -> sendSESAction.buildMessage(new SendMessageInput())).isInstanceOf(QException.class).hasMessageContaining("'Text' nor an 'HTML'"); + + /////////////////////////////////////////// + // exception if a FROM given in to field // + /////////////////////////////////////////// + assertThatThrownBy(() -> sendSESAction.buildDestination(new SendMessageInput().withTo(new Party().withAddress("test1").withRole(EmailPartyRole.FROM)))).isInstanceOf(QException.class).hasMessageContaining("unrecognized"); + + String htmlContent = "HTML_CONTENT"; + String textContent = "TEXT_CONTENT"; + String subject = "SUBJECT"; + SendMessageInput input = new SendMessageInput() + .withContent(new Content().withContentRole(EmailContentRole.HTML).withBody(htmlContent)) + .withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody(textContent)) + .withSubject(subject); + Message message = sendSESAction.buildMessage(input); + assertEquals(htmlContent, message.getBody().getHtml().getData()); + assertEquals(textContent, message.getBody().getText().getData()); + assertEquals(subject, message.getSubject().getData()); + } + +} diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java index 3702ff4a..fdd0f5ab 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java @@ -76,6 +76,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData; import com.kingsrook.qqq.backend.core.model.metadata.messaging.email.EmailMessagingProviderMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.messaging.ses.SESMessagingProviderMetaData; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PVSValueFormatAndFields; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; @@ -183,6 +184,7 @@ public class TestUtils public static final String SECURITY_KEY_TYPE_INTERNAL_OR_EXTERNAL = "internalOrExternal"; public static final String EMAIL_MESSAGING_PROVIDER_NAME = "email"; + public static final String SES_MESSAGING_PROVIDER_NAME = "ses"; public static final String SIMPLE_SCHEDULER_NAME = "simpleScheduler"; public static final String TEST_SQS_QUEUE = "testSQSQueue"; @@ -247,6 +249,7 @@ public class TestUtils qInstance.addQueue(defineTestSqsQueue()); qInstance.addMessagingProvider(defineEmailMessagingProvider()); + qInstance.addMessagingProvider(defineSESMessagingProvider()); defineWidgets(qInstance); defineApps(qInstance); @@ -258,6 +261,24 @@ public class TestUtils + /******************************************************************************* + ** + *******************************************************************************/ + private static QMessagingProviderMetaData defineSESMessagingProvider() + { + String accessKey = "MOCK"; // interpreter.interpret("${env.SES_ACCESS_KEY}"); + String secretKey = "MOCK"; // interpreter.interpret("${env.SES_SECRET_KEY}"); + String region = "MOCK"; // interpreter.interpret("${env.SES_REGION}"); + + return (new SESMessagingProviderMetaData() + .withAccessKey(accessKey) + .withSecretKey(secretKey) + .withRegion(region) + .withName(SES_MESSAGING_PROVIDER_NAME)); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/s3/actions/S3StorageAction.java b/qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/s3/actions/S3StorageAction.java index 73bf65bf..40009fea 100644 --- a/qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/s3/actions/S3StorageAction.java +++ b/qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/s3/actions/S3StorageAction.java @@ -26,6 +26,7 @@ import java.io.File; import java.io.InputStream; import java.io.OutputStream; import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.CannedAccessControlList; import com.amazonaws.services.s3.model.GetObjectRequest; import com.amazonaws.services.s3.model.S3Object; import com.amazonaws.services.s3.model.S3ObjectInputStream; @@ -115,4 +116,27 @@ public class S3StorageAction extends AbstractS3Action implements QStorageInterfa } } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void makePublic(StorageInput storageInput) throws QException + { + try + { + S3BackendMetaData backend = (S3BackendMetaData) storageInput.getBackend(); + preAction(backend); + + AmazonS3 amazonS3 = getS3Utils().getAmazonS3(); + String fullPath = getFullPath(storageInput); + amazonS3.setObjectAcl(backend.getBucketName(), fullPath, CannedAccessControlList.PublicRead); + } + catch(Exception e) + { + throw (new QException("Exception making s3 file publicly available", e)); + } + } + }