From e92fab6fa391132ba53c9e2e81570ff5a429881a Mon Sep 17 00:00:00 2001 From: Steven Winship <39765413+stevenwinship@users.noreply.github.com> Date: Thu, 16 Jan 2025 10:35:45 -0500 Subject: [PATCH 01/10] add new feedback api --- .../11129-send-feedback-to-contacts.md | 13 ++ doc/sphinx-guides/source/api/native-api.rst | 33 ++- .../source/installation/config.rst | 5 + .../iq/dataverse/api/SendFeedbackAPI.java | 108 ++++++++++ ...eckRateLimitForDatasetFeedbackCommand.java | 18 ++ .../iq/dataverse/feedback/Feedback.java | 7 +- .../settings/SettingsServiceBean.java | 21 +- .../iq/dataverse/util/SystemConfig.java | 4 + .../util/cache/CacheFactoryBean.java | 3 + .../dataverse/util/cache/RateLimitUtil.java | 11 + .../iq/dataverse/api/SendFeedbackApiIT.java | 195 ++++++++++++++++++ .../edu/harvard/iq/dataverse/api/UtilIT.java | 18 +- tests/integration-tests.txt | 2 +- 13 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 doc/release-notes/11129-send-feedback-to-contacts.md create mode 100644 src/main/java/edu/harvard/iq/dataverse/api/SendFeedbackAPI.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CheckRateLimitForDatasetFeedbackCommand.java create mode 100644 src/test/java/edu/harvard/iq/dataverse/api/SendFeedbackApiIT.java diff --git a/doc/release-notes/11129-send-feedback-to-contacts.md b/doc/release-notes/11129-send-feedback-to-contacts.md new file mode 100644 index 00000000000..eec6850377d --- /dev/null +++ b/doc/release-notes/11129-send-feedback-to-contacts.md @@ -0,0 +1,13 @@ +This feature adds a new API to send feedback to the Collection, Dataset, or DataFile's contacts. +Similar to the "admin/feedback" API the "sendfeedback" API sends an email to all the contacts listed for the Dataset. The main differences for this feature are: +1. This API is not limited to Admins +2. This API does not return the email addresses in the "toEmail" and "ccEmail" elements for privacy reasons +3. This API can be rate limited to avoid spamming +4. The body size limit can be configured +5. The body will be stripped of any html code to prevent malicious scripts or links + +To set the Rate Limiting for guest users (See Rate Limiting Configuration for more details. This example allows 1 send per hour for any guest) +``curl http://localhost:8080/api/admin/settings/:RateLimitingCapacityByTierAndAction -X PUT -d '[{\"tier\": 0, \"limitPerHour\": 1, \"actions\": [\"CheckRateLimitForDatasetFeedbackCommand\"]}]'`` + +To set the message size limit (example limit of 1080 chars): +``curl -X PUT -d 1080 http://localhost:8080/api/admin/settings/:ContactFeedbackMessageSizeLimit`` diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 7a1dc75a1dd..640e4879089 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -2477,6 +2477,7 @@ The fully expanded example above (without environment variables) looks like this The review process can sometimes resemble a tennis match, with the authors submitting and resubmitting the dataset over and over until the curators are satisfied. Each time the curators send a "reason for return" via API, that reason is sent by email and is persisted into the database, stored at the dataset version level. Note the reason is required, unless the `disable-return-to-author-reason` feature flag has been set (see :ref:`feature-flags`). Reason is a free text field and could be as simple as "The author would like to modify his dataset", "Files are missing", "Nothing to report" or "A curation report with comments and suggestions/instructions will follow in another email" that suits your situation. +The :ref:`send-feedback-admin` Admin only API call may be useful as a way to move the conversation to email. However, note that these emails go to contacts (versus authors) and there is no database record of the email contents. (:ref:`dataverse.mail.cc-support-on-contact-email` will send a copy of these emails to the support email address which would provide a record.) The :ref:`send-feedback` API call may be useful as a way to move the conversation to email. However, note that these emails go to contacts (versus authors) and there is no database record of the email contents. (:ref:`dataverse.mail.cc-support-on-contact-email` will send a copy of these emails to the support email address which would provide a record.) Link a Dataset @@ -6598,10 +6599,10 @@ A curl example using allowing access to a dataset's metadata Please see :ref:`dataverse.api.signature-secret` for the configuration option to add a shared secret, enabling extra security. -.. _send-feedback: +.. _send-feedback-admin: -Send Feedback To Contact(s) -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Send Feedback To Contact(s) Admin API +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This API call allows sending an email to the contacts for a collection, dataset, or datafile or to the support email address when no object is specified. The call is protected by the normal /admin API protections (limited to localhost or requiring a separate key), but does not otherwise limit the sending of emails. @@ -6624,6 +6625,32 @@ A curl example using an ``ID`` Note that this call could be useful in coordinating with dataset authors (assuming they are also contacts) as an alternative/addition to the functionality provided by :ref:`return-a-dataset`. +.. _send-feedback: + +Send Feedback To Contact(s) +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This API call allows sending an email to the contacts for a collection, dataset, or datafile or to the support email address when no object is specified. +The call is protected from embedded html in the body as well as the ability to configure body size limits and rate limiting to avoid the potential for spam. + +The call is a POST with a JSON object as input with four keys: +- "targetId" - the id of the collection, dataset, or datafile. Persistent ids and collection aliases are not supported. (Optional) +- "subject" - the email subject line +- "body" - the email body to send +- "fromEmail" - the email to list in the reply-to field. (Dataverse always sends mail from the system email, but does it "on behalf of" and with a reply-to for the specified user. Authenticated users will have the 'fromEmail' filled in from their profile if this field is not specified) + +A curl example using an ``ID`` + +.. code-block:: bash + + export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + export SERVER_URL=https://demo.dataverse.org + export JSON='{"targetId":24, "subject":"Data Question", "body":"Please help me understand your data. Thank you!"}' + + curl -X POST -H "X-Dataverse-key:$API_KEY" -H 'Content-Type:application/json' -d "$JSON" "$SERVER_URL/api/sendfeedback" + +Note that this call could be useful in coordinating with dataset authors (assuming they are also contacts) as an alternative/addition to the functionality provided by :ref:`return-a-dataset`. + .. _thumbnail_reset: Reset Thumbnail Failure Flags diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 111356b9a70..41f5663cbda 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -4422,7 +4422,12 @@ This is enabled via the new setting `:MDCStartDate` that specifies the cut-over ``curl -X PUT -d '2019-10-01' http://localhost:8080/api/admin/settings/:MDCStartDate`` +:ContactFeedbackMessageSizeLimit +++++++++++++++++++++++++++++++++ + +Maximum length of the text body that can be sent to the contacts of a Collection, Dataset, or DataFile. Setting this limit to Zero will denote unlimited length. +``curl -X PUT -d 1080 http://localhost:8080/api/admin/settings/:ContactFeedbackMessageSizeLimit`` .. _:Languages: diff --git a/src/main/java/edu/harvard/iq/dataverse/api/SendFeedbackAPI.java b/src/main/java/edu/harvard/iq/dataverse/api/SendFeedbackAPI.java new file mode 100644 index 00000000000..ccebf98ffc3 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/SendFeedbackAPI.java @@ -0,0 +1,108 @@ +package edu.harvard.iq.dataverse.api; + +import edu.harvard.iq.dataverse.*; +import edu.harvard.iq.dataverse.api.auth.AuthRequired; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.branding.BrandingUtil; +import edu.harvard.iq.dataverse.engine.command.impl.CheckRateLimitForDatasetFeedbackCommand; +import edu.harvard.iq.dataverse.feedback.Feedback; +import edu.harvard.iq.dataverse.feedback.FeedbackUtil; +import edu.harvard.iq.dataverse.util.cache.CacheFactoryBean; +import edu.harvard.iq.dataverse.validation.EMailValidator; +import jakarta.ejb.EJB; +import jakarta.json.*; +import jakarta.mail.internet.InternetAddress; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; + +import java.util.logging.Logger; + +@Path("sendfeedback") +public class SendFeedbackAPI extends AbstractApiBean { + private static final Logger logger = Logger.getLogger(SendFeedbackAPI.class.getCanonicalName()); + @EJB + MailServiceBean mailService; + @EJB + CacheFactoryBean cacheFactory; + /** + * This method mimics the contact form and sends an email to the contacts of the + * specified Collection/Dataset/DataFile, optionally ccing the support email + * address, or to the support email address when there is no target object. + * + * !!!!! This should not be moved outside the /admin path unless/until some form + * of captcha or other spam-prevention mechanism is added. As is, it allows an + * unauthenticated user (with access to the /admin api path) to send email from + * anyone to any contacts in Dataverse. It also does not do much to validate + * user input (e.g. to strip potentially malicious html, etc.)!!!! + **/ + @POST + @AuthRequired + @Consumes("application/json") + public Response submitFeedback(@Context ContainerRequestContext crc, JsonObject jsonObject) { + try { + JsonNumber jsonNumber = jsonObject.getJsonNumber("targetId"); + DvObject feedbackTarget = null; + if (jsonNumber != null) { + feedbackTarget = dvObjSvc.findDvObject(jsonNumber.longValue()); + if(feedbackTarget==null) { + return error(Response.Status.BAD_REQUEST, "Feedback target object not found"); + } + } + // Check for rate limit exceeded. + if (!cacheFactory.checkRate(getRequestUser(crc), new CheckRateLimitForDatasetFeedbackCommand(null, feedbackTarget))) { + return error(Response.Status.TOO_MANY_REQUESTS, "Too many requests to send feedback"); + } + + DataverseSession dataverseSession = null; + String userMessage = sanitizeBody(jsonObject.getString("body")); + InternetAddress systemAddress = mailService.getSupportAddress().orElse(null); + String userEmail = getEmail(jsonObject, crc); + String messageSubject = jsonObject.getString("subject"); + String baseUrl = systemConfig.getDataverseSiteUrl(); + String installationBrandName = BrandingUtil.getInstallationBrandName(); + String supportTeamName = BrandingUtil.getSupportTeamName(systemAddress); + JsonArrayBuilder jab = Json.createArrayBuilder(); + Feedback feedback = FeedbackUtil.gatherFeedback(feedbackTarget, dataverseSession, messageSubject, userMessage, systemAddress, userEmail, baseUrl, installationBrandName, supportTeamName, SendFeedbackDialog.ccSupport(feedbackTarget)); + jab.add(feedback.toLimitedJsonObjectBuilder()); + mailService.sendMail(feedback.getFromEmail(), feedback.getToEmail(), feedback.getCcEmail(), feedback.getSubject(), feedback.getBody()); + return ok(jab); + } catch (WrappedResponse resp) { + return resp.getResponse(); + } + } + + private String getEmail(JsonObject jsonObject, ContainerRequestContext crc) throws WrappedResponse { + String fromEmail = jsonObject.containsKey("fromEmail") ? jsonObject.getString("fromEmail") : ""; + if (fromEmail.isBlank() && crc != null) { + User user = getRequestUser(crc); + if (user instanceof AuthenticatedUser) { + fromEmail = ((AuthenticatedUser) user).getEmail(); + } + } + if (fromEmail == null || fromEmail.isBlank()) { + throw new WrappedResponse(badRequest("Missing 'fromEmail'")); + } + if (!EMailValidator.isEmailValid(fromEmail)) { + throw new WrappedResponse(badRequest("Invalid 'fromEmail'")); + } + return fromEmail; + } + private String sanitizeBody (String body) throws WrappedResponse { + // remove malicious html + String sanitizedBody = body == null ? "" : body.replaceAll("\\<.*?>", ""); + + long limit = systemConfig.getContactFeedbackMessageSizeLimit(); + if (limit > 0 && sanitizedBody.length() > limit) { + throw new WrappedResponse(badRequest("body exceeds feedback length")); + } else if (sanitizedBody.length() == 0) { + throw new WrappedResponse(badRequest("body can not be empty")); + } + + return sanitizedBody; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CheckRateLimitForDatasetFeedbackCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CheckRateLimitForDatasetFeedbackCommand.java new file mode 100644 index 00000000000..d25dbd974c2 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CheckRateLimitForDatasetFeedbackCommand.java @@ -0,0 +1,18 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.DvObject; +import edu.harvard.iq.dataverse.engine.command.AbstractVoidCommand; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; + +public class CheckRateLimitForDatasetFeedbackCommand extends AbstractVoidCommand { + + public CheckRateLimitForDatasetFeedbackCommand(DataverseRequest aRequest, DvObject dvObject) { + super(aRequest, dvObject); + } + + @Override + protected void executeImpl(CommandContext ctxt) throws CommandException { } +} + diff --git a/src/main/java/edu/harvard/iq/dataverse/feedback/Feedback.java b/src/main/java/edu/harvard/iq/dataverse/feedback/Feedback.java index c1162eb8db6..f043e36f6f6 100644 --- a/src/main/java/edu/harvard/iq/dataverse/feedback/Feedback.java +++ b/src/main/java/edu/harvard/iq/dataverse/feedback/Feedback.java @@ -54,5 +54,10 @@ public JsonObjectBuilder toJsonObjectBuilder() { .add("subject", subject) .add("body", body); } - + public JsonObjectBuilder toLimitedJsonObjectBuilder() { + return new NullSafeJsonBuilder() + .add("fromEmail", fromEmail) + .add("subject", subject) + .add("body", body); + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java index b5eb483c2c8..5b0a178969b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java @@ -684,7 +684,9 @@ Whether Harvesting (OAI) service is enabled * When ingesting tabular data files, store the generated tab-delimited * files *with* the variable names line up top. */ - StoreIngestedTabularFilesWithVarHeaders + StoreIngestedTabularFilesWithVarHeaders, + + ContactFeedbackMessageSizeLimit ; @Override @@ -749,6 +751,23 @@ public Long getValueForKeyAsLong(Key key){ return null; } + } + + /** + * Attempt to convert the value to an integer + * - Applicable for keys such as MaxFileUploadSizeInBytes + * + * On failure (key not found or string not convertible to a long), returns defaultValue + * @param key + * @param defaultValue + * @return + */ + public Long getValueForKeyAsLong(Key key, Long defaultValue) { + Long val = getValueForKeyAsLong(key); + if (val == null) { + return defaultValue; + } + return val; } /** diff --git a/src/main/java/edu/harvard/iq/dataverse/util/SystemConfig.java b/src/main/java/edu/harvard/iq/dataverse/util/SystemConfig.java index e769cacfdb1..5a78ee97ce2 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/SystemConfig.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/SystemConfig.java @@ -1173,4 +1173,8 @@ public String getRateLimitsJson() { public String getRateLimitingDefaultCapacityTiers() { return settingsService.getValueForKey(SettingsServiceBean.Key.RateLimitingDefaultCapacityTiers, ""); } + + public long getContactFeedbackMessageSizeLimit() { + return settingsService.getValueForKeyAsLong(SettingsServiceBean.Key.ContactFeedbackMessageSizeLimit, 0L); + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/util/cache/CacheFactoryBean.java b/src/main/java/edu/harvard/iq/dataverse/util/cache/CacheFactoryBean.java index 36b2b35b48f..c27d6f8a559 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/cache/CacheFactoryBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/cache/CacheFactoryBean.java @@ -52,6 +52,9 @@ public boolean checkRate(User user, Command command) { int capacity = RateLimitUtil.getCapacity(systemConfig, user, action); if (capacity == RateLimitUtil.NO_LIMIT) { return true; + } else if (capacity == RateLimitUtil.RESET_CACHE) { + rateLimitCache.clear(); + return true; } else { String cacheKey = RateLimitUtil.generateCacheKey(user, action); return (!RateLimitUtil.rateLimited(rateLimitCache, cacheKey, capacity)); diff --git a/src/main/java/edu/harvard/iq/dataverse/util/cache/RateLimitUtil.java b/src/main/java/edu/harvard/iq/dataverse/util/cache/RateLimitUtil.java index b566cd42fe1..572ea8d5601 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/cache/RateLimitUtil.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/cache/RateLimitUtil.java @@ -25,6 +25,8 @@ public class RateLimitUtil { static final List rateLimits = new CopyOnWriteArrayList<>(); static final Map rateLimitMap = new ConcurrentHashMap<>(); public static final int NO_LIMIT = -1; + public static final int RESET_CACHE = -2; + static String settingRateLimitsJson = ""; static String generateCacheKey(final User user, final String action) { return (user != null ? user.getIdentifier() : GuestUser.get().getIdentifier()) + @@ -34,6 +36,15 @@ static int getCapacity(SystemConfig systemConfig, User user, String action) { if (user != null && user.isSuperuser()) { return NO_LIMIT; } + + // If the setting changes then reset the cache + if (!settingRateLimitsJson.equals(systemConfig.getRateLimitsJson())) { + settingRateLimitsJson = systemConfig.getRateLimitsJson(); + logger.fine("Setting RateLimitingCapacityByTierAndAction changed (" + settingRateLimitsJson + "). Resetting cache"); + rateLimits.clear(); + return RESET_CACHE; + } + // get the capacity, i.e. calls per hour, from config return (user instanceof AuthenticatedUser authUser) ? getCapacityByTierAndAction(systemConfig, authUser.getRateLimitTier(), action) : diff --git a/src/test/java/edu/harvard/iq/dataverse/api/SendFeedbackApiIT.java b/src/test/java/edu/harvard/iq/dataverse/api/SendFeedbackApiIT.java new file mode 100644 index 00000000000..82707435516 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/SendFeedbackApiIT.java @@ -0,0 +1,195 @@ +package edu.harvard.iq.dataverse.api; + +import edu.harvard.iq.dataverse.settings.SettingsServiceBean; +import io.restassured.RestAssured; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import jakarta.json.Json; +import jakarta.json.JsonObjectBuilder; +import org.hamcrest.CoreMatchers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static jakarta.ws.rs.core.Response.Status.*; +import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SendFeedbackApiIT { + + @BeforeAll + public static void setUpClass() { + RestAssured.baseURI = UtilIT.getRestAssuredBaseUri(); + } + + @AfterEach + public void reset() { + UtilIT.deleteSetting(SettingsServiceBean.Key.RateLimitingCapacityByTierAndAction); + } + + @Test + public void testSupportRequest() { + JsonObjectBuilder job = Json.createObjectBuilder(); + job.add("fromEmail", "from@mailinator.com"); + job.add("subject", "Help!"); + job.add("body", "I need help."); + + Response response = UtilIT.sendFeedback(job, null); + response.prettyPrint(); + response.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data[0].fromEmail", CoreMatchers.equalTo("from@mailinator.com")); + } + + @Test + public void testSubmitFeedbackOnRootDataverse() { + JsonObjectBuilder job = Json.createObjectBuilder(); + long rootDataverseId = 1; + job.add("targetId", rootDataverseId); + job.add("fromEmail", "from@mailinator.com"); + job.add("toEmail", "to@mailinator.com"); + job.add("subject", "collaboration"); + job.add("body", "Are you interested writing a grant based on this research?"); + + Response response = UtilIT.submitFeedback(job); + response.prettyPrint(); + response.then().assertThat() + .statusCode(OK.getStatusCode()); + } + + @Test + public void testSendFeedbackOnDataset() { + Response createUser = UtilIT.createRandomUser(); + createUser.prettyPrint(); + createUser.then().assertThat() + .statusCode(OK.getStatusCode()); + String apiToken = UtilIT.getApiTokenFromResponse(createUser); + String fromEmail = UtilIT.getEmailFromResponse(createUser); + + Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken); + createDataverseResponse.then().assertThat() + .statusCode(CREATED.getStatusCode()); + + String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + + String pathToJsonFile = "scripts/api/data/dataset-create-new-all-default-fields.json"; + Response createDataset = UtilIT.createDatasetViaNativeApi(dataverseAlias, pathToJsonFile, apiToken); + createDataset.then().assertThat() + .statusCode(CREATED.getStatusCode()); + + long datasetId = JsonPath.from(createDataset.body().asString()).getLong("data.id"); + Response response; + + // Test with body text length to long + UtilIT.setSetting(SettingsServiceBean.Key.ContactFeedbackMessageSizeLimit, "10"); + response = UtilIT.sendFeedback(buildJsonEmail(datasetId, null), apiToken); + response.prettyPrint(); + response.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", CoreMatchers.equalTo("body exceeds feedback length")); + // reset to unlimited + UtilIT.setSetting(SettingsServiceBean.Key.ContactFeedbackMessageSizeLimit, "0"); + + // Test with no body/body length =0 + response = UtilIT.sendFeedback(Json.createObjectBuilder().add("targetId", datasetId).add("subject", "collaboration").add("body", ""), apiToken); + response.prettyPrint(); + response.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", CoreMatchers.equalTo("body can not be empty")); + + // Don't send fromEmail. Let it get it from the requesting user + response = UtilIT.sendFeedback(buildJsonEmail(datasetId, null), apiToken); + response.prettyPrint(); + response.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data[0].fromEmail", CoreMatchers.equalTo(fromEmail)); + + // Test guest calling with no token + fromEmail = "testEmail@example.com"; + response = UtilIT.sendFeedback(buildJsonEmail(datasetId, fromEmail), null); + response.prettyPrint(); + response.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data[0].fromEmail", CoreMatchers.equalTo(fromEmail)); + validateEmail(response.body().asString()); + + // Test guest calling with no token and missing email + response = UtilIT.sendFeedback(buildJsonEmail(datasetId, null), null); + response.prettyPrint(); + response.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", CoreMatchers.equalTo("Missing 'fromEmail'")); + + // Test with invalid email - also tests that fromEmail trumps the users email if it is included in the Json + response = UtilIT.sendFeedback(buildJsonEmail(datasetId, "BADEmail"), apiToken); + response.prettyPrint(); + response.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", CoreMatchers.equalTo("Invalid 'fromEmail'")); + } + + private JsonObjectBuilder buildJsonEmail(long datasetId, String fromEmail) { + JsonObjectBuilder job = Json.createObjectBuilder(); + job.add("targetId", datasetId); + job.add("subject", "collaboration"); + job.add("body", "Are you interested writing a grant based on this research? {\"