diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a4afb5a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+# Intellij IDEA
+.idea/
+*.iml
+*.iws
+
+# Mac
+.DS_Store
+
+# Maven
+target/
diff --git a/README.adoc b/README.adoc
index 54006bb..a43c871 100644
--- a/README.adoc
+++ b/README.adoc
@@ -1,36 +1,16 @@
-== _Learning Spring Boot_ Contest
+image::https://raw.githubusercontent.com/maciejwalkowiak/contest/gh-pages/mercury.png[]
-This is a contest based on _Learning Spring Boot_. The idea is to submit a small, pithy, cool Spring Boot application to win a prize. We will soon announce the prizes.
+image:https://drone.io/github.com/maciejwalkowiak/contest/status.png[link=https://drone.io/github.com/maciejwalkowiak/contest/latest]
-=== Calling all Spring Boot apps
+____
+In Roman mythology *Mercury* is the patron god of messages and communication.
+____
-Years ago, the http://www.ioccc.org/years.html[The International Obfuscated C Code Contest] was invented. Seeing http://blog.aerojockey.com/post/iocccsim[Carl Banks' flight simulator] forever impressed me as a neat, pithy little app.
+*Mercury* is an application initially created for http://blog.greglturnquist.com/2014/12/announcing-learningspringboot-contest-cc-packtpub-springcentral.html[_Learning Spring Boot_] contest.
+Source code is available at GitHub: https://github.com/maciejwalkowiak/contest/
-Well, we aren't looking for obfuscated, complex, impossible to read apps. But we *ARE* looking for slick, cool apps that show off the power/coolness/wicked abilities of Spring Boot mixed with your hackable creativity. Characteristics the judges evaluate include:
+TIP: If you like the application *star* this Github repository and *tweet* about it with #mercuryapp hashtag.
-* Stylish
-* Short and sweet
-* Custom auto-configurations are welcome
-* Custom health indicators, metrics, and fancy usage thereof
-* Nice on the server side OR cool frontends (You don't have to build a web frontend to have a slick, elegeant, and original UI.)
-* Popularity. Tweet things up while you work on your submission. We will definitely look at stars on your forked repo of this contest, volume of traffic your generate, and other evidence of popularity.
+Mercury goal is to provide single **HTTP API** to send notifications in a company with internal applications in mind.
-=== How/when to submit
-
-IMPORANT: Deadline is 11:59pm January 17th CST (UTC-6). (I hate midnight deadlines, since they're so ambiguous.)
-
-To submit an entery:
-
-. Fork this repo.
-. Code your solution inside your fork.
-. Tweet/blog/reddit/facebook your efforts and gather evidence of your apps popularity (stars on your repo, total hits on your blog entry, total number of registered userse for your slick app, etc.)
-. Replace this README.adoc with your own documentation (cuteness rewarded!).
-. Submit a pull request (before the deadline!!!)
-. Wait to see the announcement.
-. Collect your prize!
-
-Those of you that can't wait, use the holiday time as you wish. Those of you that are enjoying time with family and friends, we included enough time so you can still get into the contest.
-
-NOTE: No member of Pivotal Inc. is permitted to enter this contest. Only one submission per person or team. If there is evidence of multiple "sock puppet" entries coming from the same group of people, the judges reserve the right to disqualify anyone involved. All decisions are final.
-
-Good luck!
+Read more on project website: https://maciejwalkowiak.github.io/contest/
\ No newline at end of file
diff --git a/etc/application.properties b/etc/application.properties
new file mode 100644
index 0000000..d51ea99
--- /dev/null
+++ b/etc/application.properties
@@ -0,0 +1,22 @@
+# Mercury configuration
+
+mercury.db.inMemory=true
+
+# MongoDB - optional
+#spring.data.mongodb.uri=mongodb://localhost/test # connection URL
+#spring.data.mongodb.database=
+#spring.data.mongodb.username=
+#spring.data.mongodb.password=
+
+# JavaMail configuration - optional
+#spring.mail.host=
+#spring.mail.port=
+#spring.mail.username=
+#spring.mail.password=
+
+# SendGrid configuration - optional
+#sendgrid.username=
+#sendgrid.password=
+
+# Slack configuration - optional
+#slack.hook.url=
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..0961403
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,91 @@
+
+
+ 4.0.0
+
+ com.maciejwalkowiak
+ mercury
+ 0.0.2
+ jar
+
+ Hermes Messenger
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 1.2.1.RELEASE
+
+
+
+
+ UTF-8
+ com.maciejwalkowiak.mercury.MercuryApplication
+ 1.8
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-mail
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-data-mongodb
+
+
+ com.github.fakemongo
+ fongo
+ 1.5.9
+
+
+ org.springframework.hateoas
+ spring-hateoas
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ com.sendgrid
+ sendgrid-java
+ 2.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.assertj
+ assertj-core
+ 1.7.0
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-assembly-plugin
+ 2.5.1
+
+ src/main/assembly/zip.xml
+
+
+
+
+
+
diff --git a/src/main/assembly/zip.xml b/src/main/assembly/zip.xml
new file mode 100644
index 0000000..3df653b
--- /dev/null
+++ b/src/main/assembly/zip.xml
@@ -0,0 +1,25 @@
+
+ dist
+
+ zip
+
+ false
+
+
+ ${project.basedir}/etc
+ ${project.artifactId}-${project.version}/config
+
+ *
+
+
+
+ ${project.build.directory}
+ ${project.artifactId}-${project.version}
+
+ *.jar
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/maciejwalkowiak/mercury/MercuryApplication.java b/src/main/java/com/maciejwalkowiak/mercury/MercuryApplication.java
new file mode 100644
index 0000000..5107145
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/MercuryApplication.java
@@ -0,0 +1,16 @@
+package com.maciejwalkowiak.mercury;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ComponentScan(includeFilters = @ComponentScan.Filter(Configuration.class), useDefaultFilters = false)
+@EnableAutoConfiguration
+public class MercuryApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(MercuryApplication.class, args);
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/CoreConfiguration.java b/src/main/java/com/maciejwalkowiak/mercury/core/CoreConfiguration.java
new file mode 100644
index 0000000..529620c
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/CoreConfiguration.java
@@ -0,0 +1,29 @@
+package com.maciejwalkowiak.mercury.core;
+
+import com.github.fakemongo.Fongo;
+import com.mongodb.Mongo;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.web.client.RestTemplate;
+
+@Configuration
+@ComponentScan
+@EnableAsync
+class CoreConfiguration {
+ @Configuration
+ @ConditionalOnProperty(name = "mercury.db.inMemory", havingValue = "true")
+ static class FongoConfig {
+ @Bean
+ Mongo mongo() {
+ return new Fongo("mongo").getMongo();
+ }
+ }
+
+ @Bean
+ RestTemplate restTemplate() {
+ return new RestTemplate();
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/GlobalControllerExceptionHandler.java b/src/main/java/com/maciejwalkowiak/mercury/core/GlobalControllerExceptionHandler.java
new file mode 100644
index 0000000..7453ded
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/GlobalControllerExceptionHandler.java
@@ -0,0 +1,51 @@
+package com.maciejwalkowiak.mercury.core;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static java.util.stream.Stream.concat;
+
+/**
+ * Converts exceptions to user friendly error messages.
+ *
+ * Inspired by http://www.jayway.com/2012/09/16/improve-your-spring-rest-api-part-i/
+ *
+ * @author Maciej Walkowiak
+ */
+@ControllerAdvice
+class GlobalControllerExceptionHandler {
+ @ExceptionHandler
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ @ResponseBody
+ ErrorMessage handleException(MethodArgumentNotValidException ex) {
+ Stream fieldErrorsStream = ex.getBindingResult().getFieldErrors().stream()
+ .map(error -> error.getField() + ", " + error.getDefaultMessage());
+
+ Stream globalErrorsStream = ex.getBindingResult().getGlobalErrors().stream()
+ .map(error -> error.getObjectName() + ", " + error.getDefaultMessage());
+
+ List errors = concat(fieldErrorsStream, globalErrorsStream).collect(Collectors.toList());
+
+ return new ErrorMessage(errors);
+ }
+
+ private static class ErrorMessage {
+ private final List errors;
+
+ public ErrorMessage(List errors) {
+ this.errors = errors;
+ }
+
+ public List getErrors() {
+ return errors;
+ }
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/Request.java b/src/main/java/com/maciejwalkowiak/mercury/core/Request.java
new file mode 100644
index 0000000..3772556
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/Request.java
@@ -0,0 +1,10 @@
+package com.maciejwalkowiak.mercury.core;
+
+/**
+ * Base class for incoming requests data structures.
+ * {@see com.maciejwalkowiak.mercury.mail.common.SendMailRequest}
+ *
+ * @author Maciej Walkowiak
+ */
+public abstract class Request {
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/api/ApiController.java b/src/main/java/com/maciejwalkowiak/mercury/core/api/ApiController.java
new file mode 100644
index 0000000..d6807d0
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/api/ApiController.java
@@ -0,0 +1,67 @@
+package com.maciejwalkowiak.mercury.core.api;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.hateoas.Link;
+import org.springframework.hateoas.ResourceSupport;
+import org.springframework.http.HttpEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
+
+/**
+ * In Mercury API is dynamic and depending how application is configured some URLs are available and some are not.
+ * {@link com.maciejwalkowiak.mercury.core.api.ApiController} exposes an array of all available URLs with current configuration
+ *
+ * @author Maciej Walkowiak
+ */
+@Controller
+@RequestMapping(value = "/api")
+class ApiController {
+ private final Optional> controllers;
+
+ @Autowired
+ ApiController(Optional> controllers) {
+ this.controllers = controllers;
+ }
+
+ @RequestMapping(method = { RequestMethod.GET, RequestMethod.OPTIONS })
+ public HttpEntity links() {
+ ApiResource apiResource = new ApiResource();
+ apiResource.add(linkTo(ApiController.class).withSelfRel());
+
+ if (controllers.isPresent()) {
+ controllers.get().forEach(c ->
+ apiResource.add(
+ c.links().stream()
+ .map(link -> new BracketsLink(link))
+ .collect(Collectors.toList())
+ )
+ );
+ }
+
+ return new HttpEntity<>(apiResource);
+ }
+
+ private static class ApiResource extends ResourceSupport {
+ }
+
+ /**
+ * Spring HATEOAS performs URL encoding and replaces characters "{" and "}" that are useful to show templated URL.
+ *
+ * BracketsLink is a hacky class that takes {@link org.springframework.hateoas.Link}
+ * from Spring HATEOAS package and brings back brackets "{" and "}"
+ *
+ * @author Maciej Walkowiak
+ */
+ private static class BracketsLink extends Link {
+ public BracketsLink(Link link) {
+ super(link.getHref().replaceAll("%7B", "{").replaceAll("%7D", "}"), link.getRel());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/api/HateoasController.java b/src/main/java/com/maciejwalkowiak/mercury/core/api/HateoasController.java
new file mode 100644
index 0000000..c15e757
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/api/HateoasController.java
@@ -0,0 +1,20 @@
+package com.maciejwalkowiak.mercury.core.api;
+
+import org.springframework.hateoas.Link;
+
+import java.util.List;
+
+/**
+ * Exposes links to {@link com.maciejwalkowiak.mercury.core.api.ApiController}
+ * so that they become visible under `/api` path
+ *
+ * @author Maciej Walkowiak
+ */
+public interface HateoasController {
+ /**
+ * Gets HATEOAS links
+ *
+ * @return list of links or empty list
+ */
+ List links();
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/message/AsyncMessageNotifier.java b/src/main/java/com/maciejwalkowiak/mercury/core/message/AsyncMessageNotifier.java
new file mode 100644
index 0000000..c71ee69
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/message/AsyncMessageNotifier.java
@@ -0,0 +1,49 @@
+package com.maciejwalkowiak.mercury.core.message;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+
+import javax.annotation.PostConstruct;
+import java.lang.reflect.ParameterizedType;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Notifies consumers about incoming message in asynchronous manner
+ *
+ * Has to be public because of @Async & JDK Proxies
+ *
+ * @author Maciej Walkowiak
+ */
+@Component
+public class AsyncMessageNotifier implements MessageNotifier {
+ private final Map consumersMap = new HashMap<>();
+ private final List consumers;
+
+ @Autowired
+ AsyncMessageNotifier(List consumers) {
+ this.consumers = consumers;
+ }
+
+ @PostConstruct
+ public void initConsumersMap() {
+ consumers.stream().forEach(c -> {
+ Class persistentClass = (Class) ((ParameterizedType)c.getClass().getGenericInterfaces()[0]).getActualTypeArguments()[0];
+
+ consumersMap.put(persistentClass, c);
+ });
+ }
+
+ @Override
+ @Async
+ public void notifyConsumers(Message> message) {
+ Assert.notNull(message);
+
+ if (consumersMap.containsKey(message.getRequest().getClass())) {
+ consumersMap.get(message.getRequest().getClass()).consume(message);
+ }
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/message/Message.java b/src/main/java/com/maciejwalkowiak/mercury/core/message/Message.java
new file mode 100644
index 0000000..dc71daf
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/message/Message.java
@@ -0,0 +1,72 @@
+package com.maciejwalkowiak.mercury.core.message;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.maciejwalkowiak.mercury.core.Request;
+import org.springframework.data.annotation.Id;
+
+/**
+ * Keeps all information about message:
+ * - original request
+ * - status
+ * - and error message if sending failed
+ *
+ * It's instances are saved in MongoDB by {@link MessageRepository}
+ *
+ * @param - request class
+ *
+ * @author Maciej Walkowiak
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class Message {
+ public enum Status {
+ QUEUED,
+ SENT,
+ FAILED
+ }
+
+ @Id
+ private String id;
+ private Status status;
+ private String errorMessage;
+ private T request;
+
+ /**
+ * Public no-arg controller required by Spring Hateoas
+ */
+ public Message() {
+ }
+
+ private Message(Status status, T request) {
+ this.status = status;
+ this.request = request;
+ }
+
+ public static Message queued(T request) {
+ return new Message<>(Status.QUEUED, request);
+ }
+
+ void sent() {
+ this.status = Status.SENT;
+ }
+
+ void failed(String errorMessage) {
+ this.status = Status.FAILED;
+ this.errorMessage = errorMessage;
+ }
+
+ public Status getStatus() {
+ return status;
+ }
+
+ public T getRequest() {
+ return request;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getErrorMessage() {
+ return errorMessage;
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageConsumer.java b/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageConsumer.java
new file mode 100644
index 0000000..e84a11e
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageConsumer.java
@@ -0,0 +1,11 @@
+package com.maciejwalkowiak.mercury.core.message;
+
+import com.maciejwalkowiak.mercury.core.Request;
+import com.maciejwalkowiak.mercury.core.message.Message;
+
+/**
+ * @author Maciej Walkowiak
+ */
+public interface MessageConsumer {
+ void consume(Message message);
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageController.java b/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageController.java
new file mode 100644
index 0000000..1427df3
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageController.java
@@ -0,0 +1,47 @@
+package com.maciejwalkowiak.mercury.core.message;
+
+import com.maciejwalkowiak.mercury.core.api.HateoasController;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.hateoas.Link;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
+import static org.springframework.hateoas.mvc.ControllerLinkBuilder.methodOn;
+
+/**
+ * Exposes message details through REST interface.
+ *
+ * Used to check message if message has been successfully saved.
+ *
+ * @author Maciej Walkowiak
+ */
+@RestController
+@RequestMapping("/api/message/")
+public class MessageController implements HateoasController {
+ private final MessageRepository messageRepository;
+
+ @Autowired
+ MessageController(MessageRepository messageRepository) {
+ this.messageRepository = messageRepository;
+ }
+
+ @RequestMapping(value = "{id}", method = RequestMethod.GET)
+ public ResponseEntity> message(@PathVariable String id) {
+ Message message = messageRepository.findOne(id);
+
+ return message != null ? new ResponseEntity<>(message, HttpStatus.OK) : new ResponseEntity<>(HttpStatus.NOT_FOUND);
+ }
+
+ @Override
+ public List links() {
+ return Arrays.asList(linkTo(methodOn(MessageController.class).message("{id}")).withRel("message"));
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageMetrics.java b/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageMetrics.java
new file mode 100644
index 0000000..fc3c143
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageMetrics.java
@@ -0,0 +1,32 @@
+package com.maciejwalkowiak.mercury.core.message;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.actuate.endpoint.PublicMetrics;
+import org.springframework.boot.actuate.metrics.Metric;
+import org.springframework.stereotype.Component;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.stream.Collectors;
+
+/**
+ * Exposes metrics for number of messages by status
+ *
+ * @author Maciej Walkowiak
+ */
+@Component
+class MessageMetrics implements PublicMetrics {
+ private final MessageRepository messageRepository;
+
+ @Autowired
+ MessageMetrics(MessageRepository messageRepository) {
+ this.messageRepository = messageRepository;
+ }
+
+ @Override
+ public Collection> metrics() {
+ return Arrays.stream(Message.Status.values())
+ .map(s -> new Metric<>("message." + s.name().toLowerCase(), messageRepository.countByStatus(s)))
+ .collect(Collectors.toList());
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageNotifier.java b/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageNotifier.java
new file mode 100644
index 0000000..99411c0
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageNotifier.java
@@ -0,0 +1,10 @@
+package com.maciejwalkowiak.mercury.core.message;
+
+/**
+ * Notifies consumers about incoming messages
+ *
+ * @author Maciej Walkowiak
+ */
+public interface MessageNotifier {
+ void notifyConsumers(Message> message);
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageRepository.java b/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageRepository.java
new file mode 100644
index 0000000..7bf9d25
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageRepository.java
@@ -0,0 +1,12 @@
+package com.maciejwalkowiak.mercury.core.message;
+
+import org.springframework.data.mongodb.repository.MongoRepository;
+import org.springframework.data.mongodb.repository.Query;
+
+/**
+ * MongoDB based repository for {@link Message}
+ */
+interface MessageRepository extends MongoRepository {
+ @Query(count = true, value = "{ 'status' : ?0 }")
+ long countByStatus(Message.Status status);
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageService.java b/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageService.java
new file mode 100644
index 0000000..dc2a9fb
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/message/MessageService.java
@@ -0,0 +1,54 @@
+package com.maciejwalkowiak.mercury.core.message;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.Assert;
+
+/**
+ * @author Maciej Walkowiak
+ */
+@Service
+public class MessageService {
+ private final MessageRepository repository;
+
+ @Autowired
+ MessageService(MessageRepository repository) {
+ this.repository = repository;
+ }
+
+ /**
+ * Saves message in data store
+ *
+ * @param message - message to save
+ */
+ public void save(Message message) {
+ Assert.notNull(message);
+
+ repository.save(message);
+ }
+
+ /**
+ * Invoked after message has been successfully sent. Changes it's status to "SENT"
+ *
+ * @param message - sent message
+ */
+ public void messageSent(Message message) {
+ Assert.notNull(message);
+
+ message.sent();
+ repository.save(message);
+ }
+
+ /**
+ * Invoked when sending message has failed. Changes it's status to "FAILED" and saves error details
+ *
+ * @param message - message that sending has failed
+ * @param errorMessage - error details
+ */
+ public void deliveryFailed(Message message, String errorMessage) {
+ Assert.notNull(message);
+
+ message.failed(errorMessage);
+ repository.save(message);
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/core/message/Messenger.java b/src/main/java/com/maciejwalkowiak/mercury/core/message/Messenger.java
new file mode 100644
index 0000000..e6e1c93
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/core/message/Messenger.java
@@ -0,0 +1,45 @@
+package com.maciejwalkowiak.mercury.core.message;
+
+import com.maciejwalkowiak.mercury.core.Request;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+
+/**
+ * Responsible for creating messages and notifying consumers about them.
+ *
+ * @author Maciej Walkowiak
+ */
+@Component
+public class Messenger {
+ private static final Logger LOG = LoggerFactory.getLogger(Messenger.class);
+
+ private final MessageService messageService;
+ private final MessageNotifier messageNotifier;
+
+ @Autowired
+ Messenger(MessageService messageService, MessageNotifier messageNotifier) {
+ this.messageService = messageService;
+ this.messageNotifier = messageNotifier;
+ }
+
+ /**
+ * Creates {@link Message} and publishes it into queue for processing
+ *
+ * @param request - request contains message details like content, recipients etc
+ * @return message with status "QUEUED"
+ */
+ public Message publish(Request request) {
+ Assert.notNull(request);
+
+ LOG.info("Received request: {}", request);
+
+ Message message = Message.queued(request);
+ messageService.save(message);
+ messageNotifier.notifyConsumers(message);
+
+ return message;
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/mail/common/MailConfiguration.java b/src/main/java/com/maciejwalkowiak/mercury/mail/common/MailConfiguration.java
new file mode 100644
index 0000000..fa6b3a4
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/mail/common/MailConfiguration.java
@@ -0,0 +1,10 @@
+package com.maciejwalkowiak.mercury.mail.common;
+
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ComponentScan
+class MailConfiguration {
+
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/mail/common/MailConsumer.java b/src/main/java/com/maciejwalkowiak/mercury/mail/common/MailConsumer.java
new file mode 100644
index 0000000..9575283
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/mail/common/MailConsumer.java
@@ -0,0 +1,49 @@
+package com.maciejwalkowiak.mercury.mail.common;
+
+import com.maciejwalkowiak.mercury.core.message.MessageConsumer;
+import com.maciejwalkowiak.mercury.core.message.Message;
+import com.maciejwalkowiak.mercury.core.message.MessageService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+/**
+ * Consumes incoming {@link com.maciejwalkowiak.mercury.mail.common.SendMailRequest} based events
+ * and sends emails through configured provider ({@link com.maciejwalkowiak.mercury.mail.common.MailingService}).
+ *
+ * If there is no provider configured all incoming emails will get status "FAILED"
+ *
+ * @author Maciej Walkowiak
+ */
+@Component
+class MailConsumer implements MessageConsumer {
+ private static final Logger LOG = LoggerFactory.getLogger(MailConsumer.class);
+
+ private final Optional mailingService;
+ private final MessageService messageService;
+
+ @Autowired
+ public MailConsumer(Optional mailingService, MessageService messageService) {
+ this.mailingService = mailingService;
+ this.messageService = messageService;
+ }
+
+ @Override
+ public void consume(Message message) {
+ LOG.info("Received send mail request: {}", message.getRequest());
+
+ if (mailingService.isPresent()) {
+ try {
+ mailingService.get().send(message.getRequest());
+ messageService.messageSent(message);
+ } catch (SendMailException e) {
+ messageService.deliveryFailed(message, e.getMessage());
+ }
+ } else {
+ messageService.deliveryFailed(message, "Mailing provider is not configured");
+ }
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/mail/common/MailController.java b/src/main/java/com/maciejwalkowiak/mercury/mail/common/MailController.java
new file mode 100644
index 0000000..06bc1d2
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/mail/common/MailController.java
@@ -0,0 +1,52 @@
+package com.maciejwalkowiak.mercury.mail.common;
+
+import com.maciejwalkowiak.mercury.core.api.HateoasController;
+import com.maciejwalkowiak.mercury.core.message.Message;
+import com.maciejwalkowiak.mercury.core.message.MessageController;
+import com.maciejwalkowiak.mercury.core.message.Messenger;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.hateoas.Link;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
+import static org.springframework.hateoas.mvc.ControllerLinkBuilder.methodOn;
+
+/**
+ * API for receiving {@link com.maciejwalkowiak.mercury.mail.common.SendMailRequest} requests
+ *
+ * @author Maciej Walkowiak
+ */
+@RestController
+@RequestMapping("/api/mail")
+class MailController implements HateoasController {
+ private final Messenger messenger;
+
+ @Autowired
+ MailController(Messenger messenger) {
+ this.messenger = messenger;
+ }
+
+ @RequestMapping(method = RequestMethod.POST)
+ public ResponseEntity send(@RequestBody @Valid SendMailRequest sendMailRequest) {
+ Message message = messenger.publish(sendMailRequest);
+
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .location(linkTo(methodOn(MessageController.class).message(message.getId())).toUri())
+ .build();
+ }
+
+ @Override
+ public List links() {
+ return Arrays.asList(linkTo(MailController.class).withRel("mail"));
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/mail/common/MailingService.java b/src/main/java/com/maciejwalkowiak/mercury/mail/common/MailingService.java
new file mode 100644
index 0000000..62c5557
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/mail/common/MailingService.java
@@ -0,0 +1,13 @@
+package com.maciejwalkowiak.mercury.mail.common;
+
+/**
+ * Service used to send emails
+ *
+ * {@see com.maciejwalkowiak.mercury.mail.javamail.JavaMailMailingService}
+ * {@see com.maciejwalkowiak.mercury.mail.sendgrid.SendGridMailingService}
+ *
+ * @author Maciej Walkowiak
+ */
+public interface MailingService {
+ void send(SendMailRequest sendMailRequest) throws SendMailException;
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/mail/common/SendMailException.java b/src/main/java/com/maciejwalkowiak/mercury/mail/common/SendMailException.java
new file mode 100644
index 0000000..4a794f7
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/mail/common/SendMailException.java
@@ -0,0 +1,16 @@
+package com.maciejwalkowiak.mercury.mail.common;
+
+/**
+ * Thrown when anything goes wrong with sending email
+ *
+ * @author Maciej Walkowiak
+ */
+public class SendMailException extends Exception {
+ public SendMailException(Throwable cause) {
+ super(cause);
+ }
+
+ public SendMailException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/mail/common/SendMailRequest.java b/src/main/java/com/maciejwalkowiak/mercury/mail/common/SendMailRequest.java
new file mode 100644
index 0000000..d52687c
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/mail/common/SendMailRequest.java
@@ -0,0 +1,107 @@
+package com.maciejwalkowiak.mercury.mail.common;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.maciejwalkowiak.mercury.core.Request;
+import org.hibernate.validator.constraints.NotEmpty;
+
+import javax.validation.constraints.NotNull;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Data structure containing all data needed to send email
+ *
+ * @author Maciej Walkowiak
+ */
+public class SendMailRequest extends Request {
+ @NotEmpty
+ private final List to;
+ private final List cc;
+ private final List bcc;
+ @NotNull
+ private final String text;
+ @NotNull
+ private final String subject;
+
+ @JsonCreator
+ public SendMailRequest(@JsonProperty("to") List to,
+ @JsonProperty("cc") List cc,
+ @JsonProperty("bcc") List bcc,
+ @JsonProperty("text") String text,
+ @JsonProperty("subject") String subject) {
+ this.to = to;
+ this.cc = cc;
+ this.bcc = bcc;
+ this.text = text;
+ this.subject = subject;
+ }
+
+ public List getTo() {
+ return to == null ? new ArrayList<>() : to;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ public String getSubject() {
+ return subject;
+ }
+
+ public List getCc() {
+ return cc == null ? new ArrayList<>() : cc;
+ }
+
+ public List getBcc() {
+ return bcc == null ? new ArrayList<>() : bcc;
+ }
+
+ @Override
+ public String toString() {
+ return "SendMailRequest{" +
+ "to=" + to +
+ ", cc=" + cc +
+ ", bcc=" + bcc +
+ ", text='" + text + '\'' +
+ ", subject='" + subject + '\'' +
+ '}';
+ }
+
+ public static class Builder {
+ private List to = new ArrayList<>();
+ private List cc = new ArrayList<>();
+ private List bcc = new ArrayList<>();
+ private String subject;
+ private String text;
+
+ public Builder to(String to) {
+ this.to.add(to);
+ return this;
+ }
+
+ public Builder cc(String cc) {
+ this.cc.add(cc);
+ return this;
+ }
+
+ public Builder bcc(String bcc) {
+ this.bcc.add(bcc);
+ return this;
+ }
+
+ public Builder subject(String subject) {
+ this.subject = subject;
+ return this;
+ }
+
+ public Builder text(String text) {
+ this.text = text;
+ return this;
+ }
+
+ public SendMailRequest build() {
+ return new SendMailRequest(to, cc, bcc, subject, text);
+ }
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/mail/javamail/JavaMailConfiguration.java b/src/main/java/com/maciejwalkowiak/mercury/mail/javamail/JavaMailConfiguration.java
new file mode 100644
index 0000000..9f73339
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/mail/javamail/JavaMailConfiguration.java
@@ -0,0 +1,11 @@
+package com.maciejwalkowiak.mercury.mail.javamail;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ComponentScan
+@ConditionalOnProperty(name = "spring.mail.host")
+class JavaMailConfiguration {
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/mail/javamail/JavaMailMailingService.java b/src/main/java/com/maciejwalkowiak/mercury/mail/javamail/JavaMailMailingService.java
new file mode 100644
index 0000000..fc5c2bc
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/mail/javamail/JavaMailMailingService.java
@@ -0,0 +1,45 @@
+package com.maciejwalkowiak.mercury.mail.javamail;
+
+import com.maciejwalkowiak.mercury.mail.common.MailingService;
+import com.maciejwalkowiak.mercury.mail.common.SendMailException;
+import com.maciejwalkowiak.mercury.mail.common.SendMailRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.mail.MailSender;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.stereotype.Service;
+
+@Service
+class JavaMailMailingService implements MailingService {
+ private static final Logger LOG = LoggerFactory.getLogger(JavaMailMailingService.class);
+ private final MailSender mailSender;
+
+ @Autowired
+ JavaMailMailingService(MailSender mailSender) {
+ this.mailSender = mailSender;
+ }
+
+ @Override
+ public void send(SendMailRequest sendMailRequest) throws SendMailException {
+ LOG.info("Sending mail: {}", sendMailRequest);
+
+ try {
+ mailSender.send(toMailMessage(sendMailRequest));
+ } catch (Exception e) {
+ throw new SendMailException(e);
+ }
+
+ }
+
+ SimpleMailMessage toMailMessage(SendMailRequest sendMailRequest) {
+ SimpleMailMessage msg = new SimpleMailMessage();
+ msg.setTo(sendMailRequest.getTo().stream().toArray(String[]::new));
+ msg.setCc(sendMailRequest.getCc().stream().toArray(String[]::new));
+ msg.setBcc(sendMailRequest.getBcc().stream().toArray(String[]::new));
+ msg.setSubject(sendMailRequest.getSubject());
+ msg.setText(sendMailRequest.getText());
+
+ return msg;
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/mail/javamail/MailServerHealthIndicator.java b/src/main/java/com/maciejwalkowiak/mercury/mail/javamail/MailServerHealthIndicator.java
new file mode 100644
index 0000000..3db79ba
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/mail/javamail/MailServerHealthIndicator.java
@@ -0,0 +1,74 @@
+package com.maciejwalkowiak.mercury.mail.javamail;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.actuate.health.AbstractHealthIndicator;
+import org.springframework.boot.actuate.health.Health;
+import org.springframework.boot.autoconfigure.mail.MailProperties;
+import org.springframework.mail.javamail.JavaMailSenderImpl;
+import org.springframework.stereotype.Component;
+
+import javax.mail.MessagingException;
+import javax.mail.NoSuchProviderException;
+import javax.mail.Session;
+import javax.mail.Transport;
+import java.util.Properties;
+
+/**
+ * Mail server health indicator for Spring Boot.
+ *
+ * Takes configured {@link org.springframework.boot.autoconfigure.mail.MailProperties}
+ * and tries to connect {@link javax.mail.Transport}. When no exception is thrown reports status "UP"
+ *
+ * @author Maciej Walkowiak
+ */
+@Component
+class MailServerHealthIndicator extends AbstractHealthIndicator {
+ private static final Logger LOG = LoggerFactory.getLogger(MailServerHealthIndicator.class);
+
+ private final MailProperties mailProperties;
+
+ @Autowired
+ MailServerHealthIndicator(MailProperties mailProperties) {
+ this.mailProperties = mailProperties;
+ }
+
+ @Override
+ protected void doHealthCheck(Health.Builder builder) {
+ Properties properties = new Properties();
+ properties.putAll(mailProperties.getProperties());
+
+ Session session = Session.getInstance(properties, null);
+
+ try {
+ Transport transport = getTransport(session);
+ transport.connect(mailProperties.getHost(), getPort(), mailProperties.getUsername(), mailProperties.getPassword());
+ transport.close();
+
+ builder.up();
+ } catch (MessagingException e) {
+ LOG.error("JavaMail connection is down", e);
+ builder.down(e);
+ }
+ }
+
+ private int getPort() {
+ Integer port = mailProperties.getPort();
+
+ if (port == null) {
+ port = JavaMailSenderImpl.DEFAULT_PORT;
+ }
+
+ return port;
+ }
+
+ private Transport getTransport(Session session) throws NoSuchProviderException {
+ String protocol = session.getProperty("mail.transport.protocol");
+ if (protocol == null) {
+ protocol = JavaMailSenderImpl.DEFAULT_PROTOCOL;
+ }
+
+ return session.getTransport(protocol);
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridConfiguration.java b/src/main/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridConfiguration.java
new file mode 100644
index 0000000..eb0c586
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridConfiguration.java
@@ -0,0 +1,23 @@
+package com.maciejwalkowiak.mercury.mail.sendgrid;
+
+import com.sendgrid.SendGrid;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ConditionalOnProperty(name = "sendgrid.username")
+@ComponentScan
+@EnableConfigurationProperties(SendGridProperties.class)
+class SendGridConfiguration {
+ @Autowired
+ private SendGridProperties properties;
+
+ @Bean
+ public SendGrid sendGrid() {
+ return new SendGrid(properties.getUsername(), properties.getPassword());
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridHealthIndicator.java b/src/main/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridHealthIndicator.java
new file mode 100644
index 0000000..311fb4d
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridHealthIndicator.java
@@ -0,0 +1,96 @@
+package com.maciejwalkowiak.mercury.mail.sendgrid;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.actuate.health.AbstractHealthIndicator;
+import org.springframework.boot.actuate.health.Health;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Checks if connection to SendGrid is working and if it's possible to get profile with provided credentials.
+ *
+ * @author Maciej Walkowiak
+ */
+@Component
+class SendGridHealthIndicator extends AbstractHealthIndicator {
+ private static final Logger LOG = LoggerFactory.getLogger(SendGridHealthIndicator.class);
+ private static final String GET_PROFILE_URL = "https://api.sendgrid.com/api/profile.get.json";
+
+ private final RestTemplate restTemplate;
+ private final SendGridProperties sendGridProperties;
+
+ @Autowired
+ SendGridHealthIndicator(RestTemplate restTemplate, SendGridProperties sendGridProperties) {
+ this.restTemplate = restTemplate;
+ this.sendGridProperties = sendGridProperties;
+ }
+
+ @Override
+ protected void doHealthCheck(Health.Builder builder) throws Exception {
+ try {
+ SendGridResponse sendGridResponse = restTemplate.getForObject(GET_PROFILE_URL, SendGridResponse.class,
+ getAuthenticationProperties());
+
+ if (sendGridResponse.error != null) {
+ LOG.error("SendGrid connection is down: {}", sendGridResponse.error);
+ builder.down().withDetail("message", sendGridResponse.error.message);
+ } else {
+ builder.up();
+ }
+ } catch (Exception e) {
+ builder.down().withException(e);
+ }
+
+ }
+
+ private Map getAuthenticationProperties() {
+ Map map = new HashMap<>();
+ map.put("api_user", sendGridProperties.getUsername());
+ map.put("api_password", sendGridProperties.getPassword());
+
+ return map;
+ }
+
+ private static class SendGridResponse {
+ private final ErrorDetails error;
+
+ @JsonCreator
+ public SendGridResponse(@JsonProperty("error") ErrorDetails error) {
+ this.error = error;
+ }
+
+ private static class ErrorDetails {
+ private final String code;
+ private final String message;
+
+ @JsonCreator
+ public ErrorDetails(@JsonProperty("code") String code, @JsonProperty("message") String message) {
+ this.code = code;
+ this.message = message;
+ }
+
+ @Override
+ public String toString() {
+ return "ErrorDetails{" +
+ "code='" + code + '\'' +
+ ", message='" + message + '\'' +
+ '}';
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "SendGridResponse{" +
+ "error=" + error +
+ '}';
+ }
+ }
+
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridMailingService.java b/src/main/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridMailingService.java
new file mode 100644
index 0000000..37701f2
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridMailingService.java
@@ -0,0 +1,51 @@
+package com.maciejwalkowiak.mercury.mail.sendgrid;
+
+import com.maciejwalkowiak.mercury.mail.common.MailingService;
+import com.maciejwalkowiak.mercury.mail.common.SendMailException;
+import com.maciejwalkowiak.mercury.mail.common.SendMailRequest;
+import com.sendgrid.SendGrid;
+import com.sendgrid.SendGridException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Primary;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+
+@Service
+@Primary
+class SendGridMailingService implements MailingService {
+ private static final Logger LOG = LoggerFactory.getLogger(SendGridMailingService.class);
+ private final SendGrid sendGrid;
+
+ @Autowired
+ SendGridMailingService(SendGrid sendGrid) {
+ this.sendGrid = sendGrid;
+ }
+
+ @Override
+ public void send(SendMailRequest sendMailRequest) throws SendMailException {
+ LOG.info("Sending mail: {}", sendMailRequest);
+
+ try {
+ SendGrid.Response response = sendGrid.send(toSendGridEmail(sendMailRequest));
+
+ LOG.info("Response: {}, {}", response.getMessage(), response.getCode());
+
+ if (response.getCode() != HttpStatus.OK.value()) {
+ throw new SendMailException(response.getMessage());
+ }
+ } catch (SendGridException e) {
+ throw new SendMailException(e);
+ }
+ }
+
+ SendGrid.Email toSendGridEmail(SendMailRequest sendMailRequest) {
+ return new SendGrid.Email()
+ .setText(sendMailRequest.getText())
+ .setTo(sendMailRequest.getTo().toArray(new String[]{}))
+ .setCc(sendMailRequest.getCc().toArray(new String[]{}))
+ .setBcc(sendMailRequest.getBcc().toArray(new String[]{}))
+ .setSubject(sendMailRequest.getSubject());
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridProperties.java b/src/main/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridProperties.java
new file mode 100644
index 0000000..4815a7c
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridProperties.java
@@ -0,0 +1,32 @@
+package com.maciejwalkowiak.mercury.mail.sendgrid;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "sendgrid")
+class SendGridProperties {
+ /**
+ * SendGrid username
+ */
+ private String username;
+
+ /**
+ * SendGrid password
+ */
+ private String password;
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/slack/SlackConfiguration.java b/src/main/java/com/maciejwalkowiak/mercury/slack/SlackConfiguration.java
new file mode 100644
index 0000000..67a1fd7
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/slack/SlackConfiguration.java
@@ -0,0 +1,12 @@
+package com.maciejwalkowiak.mercury.slack;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ComponentScan
+@ConditionalOnProperty(name = "slack.hook.url")
+class SlackConfiguration {
+
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/slack/SlackConsumer.java b/src/main/java/com/maciejwalkowiak/mercury/slack/SlackConsumer.java
new file mode 100644
index 0000000..a470c5a
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/slack/SlackConsumer.java
@@ -0,0 +1,34 @@
+package com.maciejwalkowiak.mercury.slack;
+
+import com.maciejwalkowiak.mercury.core.message.MessageConsumer;
+import com.maciejwalkowiak.mercury.core.message.Message;
+import com.maciejwalkowiak.mercury.core.message.MessageService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Component
+class SlackConsumer implements MessageConsumer {
+ private static final Logger LOG = LoggerFactory.getLogger(SlackConsumer.class);
+
+ private final SlackService slackService;
+ private final MessageService messageService;
+
+ @Autowired
+ public SlackConsumer(SlackService slackService, MessageService messageService) {
+ this.slackService = slackService;
+ this.messageService = messageService;
+ }
+
+ @Override
+ public void consume(Message message) {
+ try {
+ slackService.send(message.getRequest());
+ messageService.messageSent(message);
+ } catch (Exception e) {
+ LOG.error("Sending Slack message failed", e);
+ messageService.deliveryFailed(message, e.getMessage());
+ }
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/slack/SlackController.java b/src/main/java/com/maciejwalkowiak/mercury/slack/SlackController.java
new file mode 100644
index 0000000..5f12d4a
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/slack/SlackController.java
@@ -0,0 +1,47 @@
+package com.maciejwalkowiak.mercury.slack;
+
+import com.maciejwalkowiak.mercury.core.api.HateoasController;
+import com.maciejwalkowiak.mercury.core.message.Message;
+import com.maciejwalkowiak.mercury.core.message.MessageController;
+import com.maciejwalkowiak.mercury.core.message.Messenger;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.hateoas.Link;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
+import static org.springframework.hateoas.mvc.ControllerLinkBuilder.methodOn;
+
+@RestController
+@RequestMapping("/api/slack")
+class SlackController implements HateoasController {
+ private final Messenger messenger;
+
+ @Autowired
+ public SlackController(Messenger messenger) {
+ this.messenger = messenger;
+ }
+
+ @RequestMapping(method = RequestMethod.POST)
+ public ResponseEntity send(@RequestBody @Valid SlackRequest slackRequest) {
+ Message message = messenger.publish(slackRequest);
+
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .location(linkTo(methodOn(MessageController.class).message(message.getId())).toUri())
+ .build();
+ }
+
+ @Override
+ public List links() {
+ return Arrays.asList(linkTo(SlackController.class).withRel("slack"));
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/slack/SlackRequest.java b/src/main/java/com/maciejwalkowiak/mercury/slack/SlackRequest.java
new file mode 100644
index 0000000..c27965e
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/slack/SlackRequest.java
@@ -0,0 +1,62 @@
+package com.maciejwalkowiak.mercury.slack;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.maciejwalkowiak.mercury.core.Request;
+
+import javax.validation.constraints.NotNull;
+
+class SlackRequest extends Request {
+ @NotNull
+ private final String text;
+ private final String username;
+ private final String iconUrl;
+ private final String iconEmoji;
+ private final String channel;
+
+ @JsonCreator
+ public SlackRequest(@JsonProperty("text") String text,
+ @JsonProperty("username") String username,
+ @JsonProperty("icon_url") String iconUrl,
+ @JsonProperty("icon_emoji") String iconEmoji,
+ @JsonProperty("channel") String channel) {
+ this.text = text;
+ this.username = username;
+ this.iconUrl = iconUrl;
+ this.iconEmoji = iconEmoji;
+ this.channel = channel;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ @JsonProperty("icon_url")
+ public String getIconUrl() {
+ return iconUrl;
+ }
+
+ @JsonProperty("icon_emoji")
+ public String getIconEmoji() {
+ return iconEmoji;
+ }
+
+ public String getChannel() {
+ return channel;
+ }
+
+ @Override
+ public String toString() {
+ return "SlackRequest{" +
+ "text='" + text + '\'' +
+ ", username='" + username + '\'' +
+ ", iconUrl='" + iconUrl + '\'' +
+ ", iconEmoji='" + iconEmoji + '\'' +
+ ", channel='" + channel + '\'' +
+ '}';
+ }
+}
diff --git a/src/main/java/com/maciejwalkowiak/mercury/slack/SlackService.java b/src/main/java/com/maciejwalkowiak/mercury/slack/SlackService.java
new file mode 100644
index 0000000..7296778
--- /dev/null
+++ b/src/main/java/com/maciejwalkowiak/mercury/slack/SlackService.java
@@ -0,0 +1,22 @@
+package com.maciejwalkowiak.mercury.slack;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+@Service
+class SlackService {
+ private final RestTemplate restTemplate;
+ private final String slackWebHookUrl;
+
+ @Autowired
+ public SlackService(RestTemplate restTemplate, @Value("${slack.hook.url}") String slackWebHookUrl) {
+ this.restTemplate = restTemplate;
+ this.slackWebHookUrl = slackWebHookUrl;
+ }
+
+ public void send(SlackRequest slackRequest) {
+ restTemplate.postForEntity(slackWebHookUrl, slackRequest, String.class);
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..e69de29
diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml
new file mode 100644
index 0000000..a081f76
--- /dev/null
+++ b/src/main/resources/logback.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/test/java/com/maciejwalkowiak/mercury/MercuryApplicationTests.java b/src/test/java/com/maciejwalkowiak/mercury/MercuryApplicationTests.java
new file mode 100644
index 0000000..2d9bb44
--- /dev/null
+++ b/src/test/java/com/maciejwalkowiak/mercury/MercuryApplicationTests.java
@@ -0,0 +1,41 @@
+package com.maciejwalkowiak.mercury;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.internal.matchers.NotNull;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.SpringApplicationConfiguration;
+import org.springframework.boot.test.WebIntegrationTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@RunWith(SpringJUnit4ClassRunner.class)
+@SpringApplicationConfiguration(classes = MercuryApplication.class)
+@WebIntegrationTest(value = "mercury.db.inMemory=true", randomPort = true)
+public class MercuryApplicationTests {
+
+ @Autowired
+ private WebApplicationContext wac;
+
+ private MockMvc mockMvc;
+
+ @Before
+ public void setup() {
+ this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
+ }
+
+ @Test
+ public void testSendingMailRequest() throws Exception {
+ this.mockMvc.perform(post("/api/mail").content("{ \"to\":[\"foo@bar.com\"],\"text\":\"bar\", \"subject\":\"subject\" }").contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isCreated())
+ .andExpect(header().string("Location", NotNull.NOT_NULL));
+ }
+}
diff --git a/src/test/java/com/maciejwalkowiak/mercury/core/message/MessageServiceTest.java b/src/test/java/com/maciejwalkowiak/mercury/core/message/MessageServiceTest.java
new file mode 100644
index 0000000..6cf324d
--- /dev/null
+++ b/src/test/java/com/maciejwalkowiak/mercury/core/message/MessageServiceTest.java
@@ -0,0 +1,58 @@
+package com.maciejwalkowiak.mercury.core.message;
+
+import com.maciejwalkowiak.mercury.mail.common.SendMailRequest;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.runners.MockitoJUnitRunner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.verify;
+
+@RunWith(MockitoJUnitRunner.class)
+public class MessageServiceTest {
+ @InjectMocks
+ private MessageService service;
+
+ @Mock
+ private MessageNotifier messageNotifier;
+
+ @Mock
+ private MessageRepository repository;
+
+ private final SendMailRequest request = new SendMailRequest.Builder()
+ .to("foo@bar.com")
+ .subject("subject")
+ .text("content")
+ .build();
+
+ @Test
+ public void shouldChangeStatusAndSaveWhenSent() {
+ // given
+ Message message = Message.queued(request);
+
+ // when
+ service.messageSent(message);
+
+ // then
+ assertThat(message.getStatus()).isEqualTo(Message.Status.SENT);
+ verify(repository).save(eq(message));
+ }
+
+ @Test
+ public void shouldChangeStatusAndSaveWhenFailed() {
+ // given
+ Message message = Message.queued(request);
+ String errorMessage = "some error";
+
+ // when
+ service.deliveryFailed(message, errorMessage);
+
+ // then
+ assertThat(message.getStatus()).isEqualTo(Message.Status.FAILED);
+ assertThat(message.getErrorMessage()).isEqualTo(errorMessage);
+ verify(repository).save(eq(message));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/maciejwalkowiak/mercury/core/message/MessengerTest.java b/src/test/java/com/maciejwalkowiak/mercury/core/message/MessengerTest.java
new file mode 100644
index 0000000..e2fffad
--- /dev/null
+++ b/src/test/java/com/maciejwalkowiak/mercury/core/message/MessengerTest.java
@@ -0,0 +1,54 @@
+package com.maciejwalkowiak.mercury.core.message;
+
+import com.maciejwalkowiak.mercury.mail.common.SendMailRequest;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.runners.MockitoJUnitRunner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.verify;
+
+@RunWith(MockitoJUnitRunner.class)
+public class MessengerTest {
+ @InjectMocks
+ private Messenger messenger;
+ @Mock
+ private MessageService messageService;
+ @Mock
+ private MessageNotifier messageNotifier;
+
+ private final SendMailRequest request = new SendMailRequest.Builder()
+ .to("foo@bar.com")
+ .subject("subject")
+ .text("content")
+ .build();
+
+ @Test
+ public void shouldSaveMessage() {
+ messenger.publish(request);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Message.class);
+
+ verify(messageService).save(captor.capture());
+ assertThat(captor.getValue().getRequest()).isEqualTo(request);
+ assertThat(captor.getValue().getStatus()).isEqualTo(Message.Status.QUEUED);
+ }
+
+ @Test
+ public void shouldPublishMessage() {
+ // when
+ messenger.publish(request);
+
+ // then
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Message.class);
+
+ verify(messageNotifier).notifyConsumers(captor.capture());
+
+ Message message = captor.getValue();
+
+ assertThat(message.getRequest()).isEqualTo(request);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/maciejwalkowiak/mercury/mail/common/MailConsumerTest.java b/src/test/java/com/maciejwalkowiak/mercury/mail/common/MailConsumerTest.java
new file mode 100644
index 0000000..9e7d215
--- /dev/null
+++ b/src/test/java/com/maciejwalkowiak/mercury/mail/common/MailConsumerTest.java
@@ -0,0 +1,63 @@
+package com.maciejwalkowiak.mercury.mail.common;
+
+import com.maciejwalkowiak.mercury.core.message.Message;
+import com.maciejwalkowiak.mercury.core.message.MessageService;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.runners.MockitoJUnitRunner;
+
+import java.util.Optional;
+
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.*;
+
+@RunWith(MockitoJUnitRunner.class)
+public class MailConsumerTest {
+ private MailConsumer mailConsumer;
+
+ @Mock
+ private MailingService mailingService;
+
+ @Mock
+ private MessageService messageService;
+
+ @Test
+ public void shouldFailWhenMailingServiceNotPresent() {
+ mailConsumer = new MailConsumer(Optional.empty(), messageService);
+
+ Message message = mock(Message.class);
+
+ mailConsumer.consume(message);
+
+ verify(messageService).deliveryFailed(eq(message), anyString());
+ }
+
+ @Test
+ public void shouldSendMessage() throws SendMailException {
+ mailConsumer = new MailConsumer(Optional.of(mailingService), messageService);
+
+ Message message = mock(Message.class);
+
+ mailConsumer.consume(message);
+
+ verify(mailingService).send(eq(message.getRequest()));
+ verify(messageService).messageSent(eq(message));
+ }
+
+ @Test
+ public void shouldFailWhenMailingServiceFailed() throws SendMailException {
+ mailConsumer = new MailConsumer(Optional.of(mailingService), messageService);
+
+ Message message = mock(Message.class);
+ doThrow(new SendMailException("some message")).when(mailingService).send(any());
+
+ mailConsumer.consume(message);
+
+ verify(messageService).deliveryFailed(eq(message), eq("some message"));
+ }
+
+
+
+}
\ No newline at end of file
diff --git a/src/test/java/com/maciejwalkowiak/mercury/mail/javamail/JavaMailMailingServiceTest.java b/src/test/java/com/maciejwalkowiak/mercury/mail/javamail/JavaMailMailingServiceTest.java
new file mode 100644
index 0000000..7009910
--- /dev/null
+++ b/src/test/java/com/maciejwalkowiak/mercury/mail/javamail/JavaMailMailingServiceTest.java
@@ -0,0 +1,66 @@
+package com.maciejwalkowiak.mercury.mail.javamail;
+
+import com.maciejwalkowiak.mercury.mail.common.SendMailException;
+import com.maciejwalkowiak.mercury.mail.common.SendMailRequest;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.runners.MockitoJUnitRunner;
+import org.springframework.mail.MailSender;
+import org.springframework.mail.SimpleMailMessage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+
+@RunWith(MockitoJUnitRunner.class)
+public class JavaMailMailingServiceTest {
+ @InjectMocks
+ private JavaMailMailingService javaMailMailingService;
+ @Mock
+ private MailSender mailSender;
+
+ private final SendMailRequest request = new SendMailRequest.Builder()
+ .to("foo@bar.com")
+ .subject("subject")
+ .text("content")
+ .build();
+
+ @Test
+ public void convertsToJavaMailMessage() {
+ // when
+ SimpleMailMessage simpleMailMessage = javaMailMailingService.toMailMessage(request);
+
+ // then
+ areEqual(simpleMailMessage, request);
+ }
+
+ @Test
+ public void shouldSendMessage() throws SendMailException {
+ javaMailMailingService.send(request);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class);
+
+ verify(mailSender).send(captor.capture());
+
+ areEqual(captor.getValue(), request);
+ }
+
+ @Test(expected = SendMailException.class)
+ public void shouldThrowExceptionOnError() throws SendMailException {
+ doThrow(Exception.class).when(mailSender).send((SimpleMailMessage) any());
+
+ javaMailMailingService.send(request);
+ }
+
+ private void areEqual(SimpleMailMessage mailMessage, SendMailRequest request) {
+ assertThat(mailMessage.getTo()).containsAll(request.getTo());
+ assertThat(mailMessage.getCc()).containsAll(request.getCc());
+ assertThat(mailMessage.getBcc()).containsAll(request.getBcc());
+ assertThat(mailMessage.getSubject()).isEqualTo(request.getSubject());
+ assertThat(mailMessage.getText()).isEqualTo(request.getText());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridConfigurationTest.java b/src/test/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridConfigurationTest.java
new file mode 100644
index 0000000..faeb80f
--- /dev/null
+++ b/src/test/java/com/maciejwalkowiak/mercury/mail/sendgrid/SendGridConfigurationTest.java
@@ -0,0 +1,49 @@
+package com.maciejwalkowiak.mercury.mail.sendgrid;
+
+import com.maciejwalkowiak.mercury.MercuryApplication;
+import com.maciejwalkowiak.mercury.mail.common.MailingService;
+import org.junit.After;
+import org.junit.Test;
+import org.springframework.beans.factory.NoSuchBeanDefinitionException;
+import org.springframework.boot.test.EnvironmentTestUtils;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class SendGridConfigurationTest {
+
+ private AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
+
+ @After
+ public void closeContext() {
+ this.context.close();
+ }
+
+ @Test
+ public void sendGridMailSenderIsCreatedOnProperty() {
+ EnvironmentTestUtils.addEnvironment(context, "sendgrid.username");
+ this.context.register(MercuryApplication.class);
+ this.context.refresh();
+
+ assertThat(context.getBean(SendGridMailingService.class)).isNotNull();
+ }
+
+ @Test(expected = NoSuchBeanDefinitionException.class)
+ public void sendGridMailSenderIsNotDefinedByDefault() {
+ this.context.register(MercuryApplication.class);
+ this.context.refresh();
+
+ context.getBean(SendGridMailingService.class);
+ }
+
+ @Test
+ public void sendGridMailSenderIsDefinedWhenBothProvidersAreDefined() {
+ EnvironmentTestUtils.addEnvironment(context, "sendgrid.username");
+ EnvironmentTestUtils.addEnvironment(context, "spring.mail.host");
+ this.context.register(MercuryApplication.class);
+ this.context.refresh();
+
+ assertThat(context.getBean(MailingService.class)).isNotNull().isInstanceOf(SendGridMailingService.class);
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml
new file mode 100644
index 0000000..a081f76
--- /dev/null
+++ b/src/test/resources/logback-test.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file