From 9281d07e9698240e69dcfb1ca29d6f013e4d8729 Mon Sep 17 00:00:00 2001 From: Tim Chamberlain Date: Wed, 24 Apr 2024 17:11:48 -0500 Subject: [PATCH] CE-1068: changes from review: allow multiple email address entry, fixed download urls, fixed localhost to use inbucket/filesystem --- .../actions/interfaces/QStorageInterface.java | 9 ++ .../core/actions/tables/StorageAction.java | 12 +++ .../messaging/email/SendEmailAction.java | 30 ++++++- .../RenderSavedReportExecuteStep.java | 83 +++++++++++++++++-- .../RenderSavedReportMetaDataProducer.java | 6 ++ .../email/EmailMessagingProviderTest.java | 3 +- .../actions/FilesystemStorageAction.java | 13 ++- .../s3/actions/S3StorageAction.java | 27 +++++- 8 files changed, 165 insertions(+), 18 deletions(-) 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 e3d3b980..99b7462d 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 @@ -57,4 +57,13 @@ public interface QStorageInterface ////////// } + + /******************************************************************************* + ** + *******************************************************************************/ + default String getDownloadURL(StorageInput storageInput) throws QException + { + return (null); + } + } 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 52293a03..5cbcfc7e 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 @@ -105,4 +105,16 @@ public class StorageAction QStorageInterface storageInterface = qBackendModuleInterface.getStorageInterface(); storageInterface.makePublic(storageInput); } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public String getDownloadURL(StorageInput storageInput) throws QException + { + QBackendModuleInterface qBackendModuleInterface = preAction(storageInput); + QStorageInterface storageInterface = qBackendModuleInterface.getStorageInterface(); + return (storageInterface.getDownloadURL(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 eed19bb2..2723be6f 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 @@ -28,20 +28,25 @@ import java.util.List; import java.util.Properties; import com.kingsrook.qqq.backend.core.context.QContext; 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.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.StringUtils; import jakarta.mail.Address; import jakarta.mail.Message; +import jakarta.mail.Multipart; import jakarta.mail.Session; import jakarta.mail.Transport; import jakarta.mail.internet.AddressException; import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeBodyPart; import jakarta.mail.internet.MimeMessage; +import jakarta.mail.internet.MimeMultipart; /******************************************************************************* @@ -60,10 +65,10 @@ public class SendEmailAction ///////////////////////////////////////// // set up properties to make a session // ///////////////////////////////////////// - Properties properties = System.getProperties(); + Properties properties = new Properties(); properties.setProperty("mail.smtp.host", messagingProvider.getSmtpServer()); properties.setProperty("mail.smtp.port", messagingProvider.getSmtpPort()); - Session session = Session.getDefaultInstance(properties); + Session session = Session.getInstance(properties); try { @@ -72,7 +77,6 @@ public class SendEmailAction //////////////////////////////////////////// MimeMessage emailMessage = new MimeMessage(session); emailMessage.setSubject(sendMessageInput.getSubject()); - emailMessage.setText(sendMessageInput.getContentList().get(0).getBody()); Party to = sendMessageInput.getTo(); if(to instanceof MultiParty toMultiParty) @@ -100,6 +104,25 @@ public class SendEmailAction addSender(emailMessage, from); } + Multipart multipart = new MimeMultipart(); + for(Content content : sendMessageInput.getContentList()) + { + if(EmailContentRole.HTML.equals(content.getContentRole())) + { + MimeBodyPart mimeBodyPart = new MimeBodyPart(); + mimeBodyPart.setContent(content.getBody(), "text/html; charset=utf-8"); + multipart.addBodyPart(mimeBodyPart); + } + else if(EmailContentRole.TEXT.equals(content.getContentRole())) + { + MimeBodyPart mimeBodyPart = new MimeBodyPart(); + mimeBodyPart.setContent(content.getBody(), "text/plain; charset=utf-8"); + multipart.addBodyPart(mimeBodyPart); + } + } + + emailMessage.setContent(multipart); + ///////////// // send it // ///////////// @@ -132,7 +155,6 @@ public class SendEmailAction else { List
replyToList = Arrays.asList(replyTo); - replyToList.add(internetAddress); emailMessage.setReplyTo(replyToList.toArray(new Address[0])); } } 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 766a2a09..d75e74c1 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 @@ -29,6 +29,9 @@ import java.time.LocalDate; import java.time.LocalTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.UUID; import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction; @@ -39,6 +42,7 @@ 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.exceptions.QUserFacingException; 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; @@ -64,6 +68,7 @@ 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 org.apache.commons.validator.EmailValidator; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -89,6 +94,9 @@ public class RenderSavedReportExecuteStep implements BackendStep //////////////////////////////// // read inputs, set up params // //////////////////////////////// + String sesProviderName = runBackendStepInput.getValueString(RenderSavedReportMetaDataProducer.SES_PROVIDER_NAME); + String fromEmailAddress = runBackendStepInput.getValueString(RenderSavedReportMetaDataProducer.FROM_EMAIL_ADDRESS); + String replyToEmailAddress = runBackendStepInput.getValueString(RenderSavedReportMetaDataProducer.REPLY_TO_EMAIL_ADDRESS); 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); @@ -96,6 +104,15 @@ public class RenderSavedReportExecuteStep implements BackendStep String downloadFileBaseName = getDownloadFileBaseName(runBackendStepInput, savedReport); String storageReference = LocalDate.now() + "/" + LocalTime.now().toString().replaceAll(":", "").replaceFirst("\\..*", "") + "/" + UUID.randomUUID() + "/" + downloadFileBaseName + "." + reportFormat.getExtension(); + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if sending an email (or emails), validate the addresses before doing anything so user gets error and can fix // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + List toEmailAddressList = new ArrayList<>(); + if(sendToEmailAddress != null) + { + toEmailAddressList = validateEmailAddresses(sendToEmailAddress); + } + StorageAction storageAction = new StorageAction(); StorageInput storageInput = new StorageInput(storageTableName).withReference(storageReference); OutputStream outputStream = storageAction.createOutputStream(storageInput); @@ -133,7 +150,6 @@ public class RenderSavedReportExecuteStep implements BackendStep reportInput.setInputValues(values); ReportOutput reportOutput = new GenerateReportAction().execute(reportInput); - storageAction.makePublic(storageInput); /////////////////////////////////// // update record to show success // @@ -152,20 +168,33 @@ public class RenderSavedReportExecuteStep implements BackendStep 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())) + if(!toEmailAddressList.isEmpty() && CollectionUtils.nullSafeHasContents(QContext.getQInstance().getMessagingProviders())) { - String s3Url = "https://bucket-ctlive-reports-dev.s3.us-east-2.amazonaws.com/saved-reports/" + storageReference; // TODO: derp + /////////////////////////////////////////////////////////// + // since sending email, make s3 file publicly accessible // + /////////////////////////////////////////////////////////// + storageAction.makePublic(storageInput); + //////////////////////////////////////////////// + // add multiparty in case multiple recipients // + //////////////////////////////////////////////// + MultiParty recipients = new MultiParty(); + for(String toAddress : toEmailAddressList) + { + recipients.addParty(new Party().withAddress(toAddress).withRole(EmailPartyRole.TO)); + } + + String downloadURL = storageAction.getDownloadURL(storageInput); new SendMessageAction().execute(new SendMessageInput() - .withMessagingProviderName("defaultMessagingProvider") // TODO: derp - .withTo(new Party().withAddress(sendToEmailAddress).withRole(EmailPartyRole.TO)) + .withMessagingProviderName(sesProviderName) + .withTo(recipients) .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 + .withParty(new Party().withAddress(fromEmailAddress).withRole(EmailPartyRole.FROM)) + .withParty(new Party().withAddress(replyToEmailAddress).withRole(EmailPartyRole.REPLY_TO)) ) .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 + "")) + .withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody("To download your report, open this URL in your browser: " + downloadURL)) + .withContent(new Content().withContentRole(EmailContentRole.HTML).withBody("Link: " + downloadFileName + "")) ); } } @@ -188,6 +217,42 @@ public class RenderSavedReportExecuteStep implements BackendStep + /******************************************************************************* + ** + *******************************************************************************/ + private List validateEmailAddresses(String sendToEmailAddress) throws QUserFacingException + { + //////////////////////////////////////////////////////////////// + // split email address string on spaces, comma, and semicolon // + //////////////////////////////////////////////////////////////// + List toEmailAddressList = Arrays.asList(sendToEmailAddress.split("[\\s,;]+")); + + ////////////////////////////////////////////////////// + // check each address keeping track of any bad ones // + ////////////////////////////////////////////////////// + List invalidEmails = new ArrayList<>(); + EmailValidator validator = EmailValidator.getInstance(); + for(String emailAddress : toEmailAddressList) + { + if(!validator.isValid(emailAddress)) + { + invalidEmails.add(emailAddress); + } + } + + /////////////////////////////////////// + // if bad one found, throw exception // + /////////////////////////////////////// + if(!invalidEmails.isEmpty()) + { + throw (new QUserFacingException("The following email addresses were invalid: " + StringUtils.join(",", invalidEmails))); + } + + return (toEmailAddressList); + } + + + /******************************************************************************* ** *******************************************************************************/ 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 828f5aa6..6490fb65 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 @@ -47,6 +47,9 @@ public class RenderSavedReportMetaDataProducer implements MetaDataProducerInterf { public static final String NAME = "renderSavedReport"; + public static final String SES_PROVIDER_NAME = "sesProviderName"; + public static final String FROM_EMAIL_ADDRESS = "fromEmailAddress"; + public static final String REPLY_TO_EMAIL_ADDRESS = "replyToEmailAddress"; 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"; @@ -67,6 +70,9 @@ public class RenderSavedReportMetaDataProducer implements MetaDataProducerInterf .addStep(new QBackendStepMetaData() .withName("pre") .withInputData(new QFunctionInputMetaData() + .withField(new QFieldMetaData(SES_PROVIDER_NAME, QFieldType.STRING)) + .withField(new QFieldMetaData(FROM_EMAIL_ADDRESS, QFieldType.STRING)) + .withField(new QFieldMetaData(REPLY_TO_EMAIL_ADDRESS, QFieldType.STRING)) .withField(new QFieldMetaData(FIELD_NAME_STORAGE_TABLE_NAME, QFieldType.STRING)) .withRecordListMetaData(new QRecordListMetaData().withTableName(SavedReport.TABLE_NAME))) .withCode(new QCodeReference(RenderSavedReportPreStep.class))) diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/email/EmailMessagingProviderTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/email/EmailMessagingProviderTest.java index 77077de1..3f008fd7 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/email/EmailMessagingProviderTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/messaging/email/EmailMessagingProviderTest.java @@ -57,8 +57,7 @@ class EmailMessagingProviderTest extends BaseTest .withParty(new Party().withAddress("james.maes@kingsrook.com").withLabel("Mames Maes").withRole(EmailPartyRole.CC)) .withParty(new Party().withAddress("tyler.samples@kingsrook.com").withLabel("Tylers Ample").withRole(EmailPartyRole.BCC)) ) - // .withFrom(new Party().withAddress("darin.kelkhoff@gmail.com").withLabel("Darin Kelkhoff")) - .withFrom(new Party().withAddress("tim.chamberlain@kingsrook.com").withLabel("Tim Chamberlain")) + .withFrom(new Party().withAddress("darin.kelkhoff@gmail.com").withLabel("Darin Kelkhoff")) .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!")) diff --git a/qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/local/actions/FilesystemStorageAction.java b/qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/local/actions/FilesystemStorageAction.java index 24d9fa47..e5a0b4f2 100644 --- a/qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/local/actions/FilesystemStorageAction.java +++ b/qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/local/actions/FilesystemStorageAction.java @@ -51,7 +51,7 @@ public class FilesystemStorageAction extends AbstractFilesystemAction implements try { String fullPath = getFullPath(storageInput); - File file = new File(fullPath); + File file = new File(fullPath); if(!file.getParentFile().exists()) { if(!file.getParentFile().mkdirs()) @@ -100,4 +100,15 @@ public class FilesystemStorageAction extends AbstractFilesystemAction implements } } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getDownloadURL(StorageInput storageInput) throws QException + { + return ("file://" + getFullPath(storageInput)); + } + } 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 40009fea..96c72bde 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 @@ -112,7 +112,30 @@ public class S3StorageAction extends AbstractS3Action implements QStorageInterfa } catch(Exception e) { - throw (new QException("Exception getting s3 input stream for file", e)); + throw (new QException("Exception getting s3 input stream for file.", e)); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getDownloadURL(StorageInput storageInput) throws QException + { + try + { + S3BackendMetaData backend = (S3BackendMetaData) storageInput.getBackend(); + preAction(backend); + + AmazonS3 amazonS3 = getS3Utils().getAmazonS3(); + String fullPath = getFullPath(storageInput); + return (amazonS3.getUrl(backend.getBucketName(), fullPath).toString()); + } + catch(Exception e) + { + throw (new QException("Exception getting the S3 download URL.", e)); } } @@ -135,7 +158,7 @@ public class S3StorageAction extends AbstractS3Action implements QStorageInterfa } catch(Exception e) { - throw (new QException("Exception making s3 file publicly available", e)); + throw (new QException("Exception making s3 file publicly available.", e)); } }