Skip to content

Commit

Permalink
Impl. email send endpoint
Browse files Browse the repository at this point in the history
Currently only implemented for firebase mail extension type.
  • Loading branch information
pvannierop committed Jun 20, 2024
1 parent 00ad614 commit f884961
Show file tree
Hide file tree
Showing 7 changed files with 282 additions and 1 deletion.
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ dependencies {
testImplementation group: 'org.junit.platform', name: 'junit-platform-commons', version: '1.8.2'
testImplementation group: 'org.junit.platform', name: 'junit-platform-launcher', version: '1.8.2'
testImplementation group: 'org.junit.platform', name: 'junit-platform-engine', version: '1.8.2'
testImplementation group: 'org.mockito', name: 'mockito-inline', version: '2.7.21'

gatlingImplementation('com.fasterxml.jackson.datatype:jackson-datatype-jsr310')
}
Expand Down Expand Up @@ -205,4 +206,4 @@ tasks.named("dependencyUpdates").configure {
rejectVersionIf {
isNonStable(it.candidate.version)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
*
* * Copyright 2024 The Hyve
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/

package org.radarbase.appserver.controller;

import lombok.extern.slf4j.Slf4j;
import org.radarbase.appserver.config.AuthConfig.AuthEntities;
import org.radarbase.appserver.config.AuthConfig.AuthPermissions;
import org.radarbase.appserver.controller.model.SendEmailRequest;
import org.radarbase.appserver.service.EmailService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import radar.spring.auth.common.Authorized;
import radar.spring.auth.common.PermissionOn;

import java.util.concurrent.CompletableFuture;

/**
* Endpoint for sending emails from app on behalf of the study subject.
*
* @author Pim van Nierop
*/
@CrossOrigin
@RestController
@ConditionalOnProperty(value = "send-email.enabled", havingValue = "true")
@Slf4j
public class EmailController {

@Autowired
private EmailService emailService;

@Authorized(
permission = AuthPermissions.UPDATE,
entity = AuthEntities.SUBJECT,
permissionOn = PermissionOn.SUBJECT
)
@PostMapping(
"/email/" + PathsUtil.PROJECT_PATH + "/" + PathsUtil.PROJECT_ID_CONSTANT +
"/" + PathsUtil.USER_PATH + "/" + PathsUtil.SUBJECT_ID_CONSTANT)
public CompletableFuture<Boolean> subjectSendEmail(
@PathVariable String projectId,
@PathVariable String subjectId,
@RequestBody SendEmailRequest sendEmailRequest) {

log.info("Sending email for project: {}, subject: {}", projectId, subjectId);

return emailService.send(sendEmailRequest);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package org.radarbase.appserver.controller.model;

public record SendEmailRequest(String subject, String message, String to, String cc, String bcc) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.radarbase.appserver.service;

import org.radarbase.appserver.controller.model.SendEmailRequest;

import java.util.concurrent.CompletableFuture;

public interface EmailService {
CompletableFuture<Boolean> send(SendEmailRequest sendEmailRequest);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
*
* * Copyright 2024 The Hyve
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/

package org.radarbase.appserver.service;

import com.google.api.core.ApiFuture;
import com.google.cloud.firestore.CollectionReference;
import com.google.cloud.firestore.DocumentReference;
import com.google.cloud.firestore.Firestore;
import com.google.cloud.firestore.FirestoreOptions;
import lombok.extern.slf4j.Slf4j;
import org.radarbase.appserver.controller.model.SendEmailRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;


@Slf4j
@Service
@ConditionalOnExpression("${send-email.enabled:false} and 'firebase' == '${email.type:}'")
public class FirebaseEmailService implements EmailService {

@Override
@Async
public CompletableFuture<Boolean> send(SendEmailRequest emailRequest) {

Assert.notNull(emailRequest, "Send email request cannot be null");
Assert.isTrue(!emailRequest.to().isEmpty(), "Send email request must have a recipient");
Assert.isTrue(!emailRequest.subject().isEmpty(), "Send email request must have a subject");
Assert.isTrue(!emailRequest.message().isEmpty(), "Send email request must have a message");
try {
// Sending emails using Firebase works by placing a document in the Firebase Firestore.
// Firebase will then trigger a cloud function that sends the email.
Firestore firestore = FirestoreOptions.getDefaultInstance().getService();
CollectionReference mailCollection = firestore.collection("mail");
ApiFuture<DocumentReference> apiFuture = mailCollection.add(createEmailMap(emailRequest));
apiFuture.get();
} catch (InterruptedException | ExecutionException e) {
Thread.currentThread().interrupt();
return CompletableFuture.completedFuture(false);
}
return CompletableFuture.completedFuture(true);
}

// The 'from' email address is configured in Firebase.
private Map<String, Object> createEmailMap(SendEmailRequest emailRequest) {
Map<String, String> messageMap = new HashMap<>();
messageMap.put("subject", emailRequest.subject());
messageMap.put("text", emailRequest.message());
messageMap.put("html", emailRequest.message());
Map<String, Object> emailMap = new HashMap<>();
emailMap.put("to", emailRequest.to());
emailMap.put("message", messageMap);
return emailMap;
}

}
5 changes: 5 additions & 0 deletions src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,8 @@ security.github.client.maxContentLength=1000000
security.github.cache.size=10000
security.github.cache.duration=3600
security.github.cache.retryDuration=60

# SEND EMAIL
send-email.enabled=true
# can be 'firebase' (smtp is not supported yet)
email.type=firebase
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
*
* * Copyright 2024 The Hyve
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/

package org.radarbase.appserver.service.storage;

import com.google.api.core.ApiFuture;
import com.google.cloud.firestore.CollectionReference;
import com.google.cloud.firestore.Firestore;
import com.google.cloud.firestore.FirestoreOptions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.radarbase.appserver.controller.model.SendEmailRequest;
import org.radarbase.appserver.service.FirebaseEmailService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import java.util.concurrent.CompletableFuture;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(SpringExtension.class)
@SpringBootTest(
classes = {FirebaseEmailService.class},
properties = {
"send-email.enabled=true",
"email.type=firebase",
}
)
class FirebaseEmailServiceTest {

@Autowired
private FirebaseEmailService firebaseEmailService;

private SendEmailRequest validEmailRequest = new SendEmailRequest(
"subject", "message", "to", "cc", "bcc"
);
private CollectionReference mailCollection;
private MockedStatic<FirestoreOptions> firestoreOptionsStatic;
private ApiFuture firebaseApiFuture;

@BeforeEach
public void setUp() throws Exception {
firestoreOptionsStatic = Mockito.mockStatic(FirestoreOptions.class);
Firestore firestore = Mockito.mock(Firestore.class);
FirestoreOptions firestoreOptionsInstance = Mockito.mock(FirestoreOptions.class);
when(firestoreOptionsInstance.getService()).thenReturn(firestore);
firestoreOptionsStatic.when(FirestoreOptions::getDefaultInstance).thenReturn(firestoreOptionsInstance);

mailCollection = mock(CollectionReference.class);
when(firestore.collection("mail")).thenReturn(mailCollection);

firebaseApiFuture = mock(ApiFuture.class);
when(mailCollection.add(anyMap())).thenReturn(firebaseApiFuture);
when(firebaseApiFuture.get()).thenReturn(null);
}

@AfterEach
public void tearDown() {
firestoreOptionsStatic.close();
}

@Test
void testArguments() {
assertDoesNotThrow(() -> firebaseEmailService.send(validEmailRequest));
assertDoesNotThrow(() -> firebaseEmailService.send(new SendEmailRequest("subject", "message", "to", null, null)));
assertThrows(Exception.class, () -> firebaseEmailService.send(new SendEmailRequest(null, "message", "to", "cc", "bcc")));
assertThrows(Exception.class, () -> firebaseEmailService.send(new SendEmailRequest("subject", null, "to", "cc", "bcc")));
assertThrows(Exception.class, () -> firebaseEmailService.send(new SendEmailRequest("subject", "message", null, "cc", "bcc")));
}

@Test
void testSendEmail() throws Exception {
CompletableFuture<Boolean> future = firebaseEmailService.send(validEmailRequest);
Boolean success = future.get();
assertTrue(success);
verify(mailCollection, times(1)).add(anyMap());
}

@Test
void testFailSendEmail() throws Exception {
when(firebaseApiFuture.get()).thenThrow(new InterruptedException());
CompletableFuture<Boolean> future = firebaseEmailService.send(validEmailRequest);
Boolean success = future.get();
assertTrue(!success);
}

}

0 comments on commit f884961

Please sign in to comment.