CE-1068: checkpoint commit of SES support

This commit is contained in:
Tim Chamberlain
2024-04-23 20:55:58 -05:00
parent a591f57591
commit 5e4305d1d5
12 changed files with 902 additions and 5 deletions

View File

@ -173,6 +173,19 @@
<version>1.12.321</version>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-ses</artifactId>
<version>1.12.705</version>
</dependency>
<dependency>
<groupId>cloud.localstack</groupId>
<artifactId>localstack-utils</artifactId>
<version>0.2.20</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>

View File

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

View File

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

View File

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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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);
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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);
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> 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<Party> partyList = getPartyListFromParty(input.getFrom());
if(partyList == null)
{
return (Collections.emptyList());
}
///////////////////////////////
// only get reply to parties //
///////////////////////////////
List<Party> replyToParties = partyList.stream().filter(p -> EmailPartyRole.REPLY_TO.equals(p.getRole())).toList();
//////////////////////////////////
// get addresses from reply tos //
//////////////////////////////////
List<String> 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<Party> 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<Party> getPartyListFromParty(Party party)
{
//////////////////////////////////////////////
// get all parties into one list of parties //
//////////////////////////////////////////////
List<Party> 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<String> toList = new ArrayList<>();
List<String> ccList = new ArrayList<>();
List<String> bccList = new ArrayList<>();
List<Party> 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;
}
}

View File

@ -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: <a target=\"_blank\" href=\"" + s3Url + "\">" + downloadFileName + "</a>"))
);
}
}
catch(Exception e)
{

View File

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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 <u>is</u> an <b>HTML</b> 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());
}
}

View File

@ -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));
}
/*******************************************************************************
**
*******************************************************************************/

View File

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