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));
+ }
+ }
+
}