1. Overview
Let's continue moving forward the Reddit application from our ongoing case study.
2. Send Email Notifications on Post Comments
Reddit is missing email notifications – plain and simple. What I'd like to see is – whenever someone comments on one of my posts, I get a short email notification with the comment.
So – simply put – that's the goal of this feature here – email notifications on comments.
We'll implement a simple scheduler that checks:
- which users should receive email notification with posts' replies
- if the user got any post replies into their Reddit inbox
It will then simply send out an email notification with unread post replies.
2.1. User Preferences
First, we will need to modify our Preference entity and DTO by adding:
private boolean sendEmailReplies;
To allow users to choose if they want to receive an email notification with posts' replies.
2.2. Notification Scheduler
Next, here is our simple scheduler:
@Component
public class NotificationRedditScheduler {
@Autowired
private INotificationRedditService notificationRedditService;
@Autowired
private PreferenceRepository preferenceRepository;
@Scheduled(fixedRate = 60 * 60 * 1000)
public void checkInboxUnread() {
List<Preference> preferences = preferenceRepository.findBySendEmailRepliesTrue();
for (Preference preference : preferences) {
notificationRedditService.checkAndNotify(preference);
}
}
}
Notice that the scheduler runs every hour – but we can of course go with a much shorter cadence if we want to.
2.3. The Notification Service
Now, let's discuss our notification service:
@Service
public class NotificationRedditService implements INotificationRedditService {
private Logger logger = LoggerFactory.getLogger(getClass());
private static String NOTIFICATION_TEMPLATE = "You have %d unread post replies.";
private static String MESSAGE_TEMPLATE = "%s replied on your post %s : %s";
@Autowired
@Qualifier("schedulerRedditTemplate")
private OAuth2RestTemplate redditRestTemplate;
@Autowired
private ApplicationEventPublisher eventPublisher;
@Autowired
private UserRepository userRepository;
@Override
public void checkAndNotify(Preference preference) {
try {
checkAndNotifyInternal(preference);
} catch (Exception e) {
logger.error(
"Error occurred while checking and notifying = " + preference.getEmail(), e);
}
}
private void checkAndNotifyInternal(Preference preference) {
User user = userRepository.findByPreference(preference);
if ((user == null) || (user.getAccessToken() == null)) {
return;
}
DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(user.getAccessToken());
token.setRefreshToken(new DefaultOAuth2RefreshToken((user.getRefreshToken())));
token.setExpiration(user.getTokenExpiration());
redditRestTemplate.getOAuth2ClientContext().setAccessToken(token);
JsonNode node = redditRestTemplate.getForObject(
"https://oauth.reddit.com/message/selfreply?mark=false", JsonNode.class);
parseRepliesNode(preference.getEmail(), node);
}
private void parseRepliesNode(String email, JsonNode node) {
JsonNode allReplies = node.get("data").get("children");
int unread = 0;
for (JsonNode msg : allReplies) {
if (msg.get("data").get("new").asBoolean()) {
unread++;
}
}
if (unread == 0) {
return;
}
JsonNode firstMsg = allReplies.get(0).get("data");
String author = firstMsg.get("author").asText();
String postTitle = firstMsg.get("link_title").asText();
String content = firstMsg.get("body").asText();
StringBuilder builder = new StringBuilder();
builder.append(String.format(NOTIFICATION_TEMPLATE, unread));
builder.append("\n");
builder.append(String.format(MESSAGE_TEMPLATE, author, postTitle, content));
builder.append("\n");
builder.append("Check all new replies at ");
builder.append("https://www.reddit.com/message/unread/");
eventPublisher.publishEvent(new OnNewPostReplyEvent(email, builder.toString()));
}
}
Note that:
- We call Reddit API and get all replies then check them one by one to see if it is new “unread”.
- If there is unread replies, we fire an event to send this user an email notification.
2.4. New Reply Event
Here is our simple event:
public class OnNewPostReplyEvent extends ApplicationEvent {
private String email;
private String content;
public OnNewPostReplyEvent(String email, String content) {
super(email);
this.email = email;
this.content = content;
}
}
2.5. Reply Listener
Finally, here is our listener:
@Component
public class ReplyListener implements ApplicationListener<OnNewPostReplyEvent> {
@Autowired
private JavaMailSender mailSender;
@Autowired
private Environment env;
@Override
public void onApplicationEvent(OnNewPostReplyEvent event) {
SimpleMailMessage email = constructEmailMessage(event);
mailSender.send(email);
}
private SimpleMailMessage constructEmailMessage(OnNewPostReplyEvent event) {
String recipientAddress = event.getEmail();
String subject = "New Post Replies";
SimpleMailMessage email = new SimpleMailMessage();
email.setTo(recipientAddress);
email.setSubject(subject);
email.setText(event.getContent());
email.setFrom(env.getProperty("support.email"));
return email;
}
}
3. Session Concurrency Control
Next, let's set up some stricter rules regarding the number of concurrent sessions the application allows. More to the point – let's not allow concurrent sessions:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true);
}
Note that – as we are using a custom UserDetails implementation – we need to override equals() and hashcode() because the session controls strategy stores all principals in a map and needs to be able to retrieve them:
public class UserPrincipal implements UserDetails {
private User user;
@Override
public int hashCode() {
int prime = 31;
int result = 1;
result = (prime * result) + ((user == null) ? 0 : user.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
UserPrincipal other = (UserPrincipal) obj;
if (user == null) {
if (other.user != null) {
return false;
}
} else if (!user.equals(other.user)) {
return false;
}
return true;
}
}
4. Separate API Servlet
The application is now serving both the front end as well as the API out of the same servlet – which is not ideal.
Let's now split these two major responsibilities apart and pull them into two different servlets:
@Bean
public ServletRegistrationBean frontendServlet() {
ServletRegistrationBean registration =
new ServletRegistrationBean(new DispatcherServlet(), "/*");
Map<String, String> params = new HashMap<String, String>();
params.put("contextClass",
"org.springframework.web.context.support.AnnotationConfigWebApplicationContext");
params.put("contextConfigLocation", "org.baeldung.config.frontend");
registration.setInitParameters(params);
registration.setName("FrontendServlet");
registration.setLoadOnStartup(1);
return registration;
}
@Bean
public ServletRegistrationBean apiServlet() {
ServletRegistrationBean registration =
new ServletRegistrationBean(new DispatcherServlet(), "/api/*");
Map<String, String> params = new HashMap<String, String>();
params.put("contextClass",
"org.springframework.web.context.support.AnnotationConfigWebApplicationContext");
params.put("contextConfigLocation", "org.baeldung.config.api");
registration.setInitParameters(params);
registration.setName("ApiServlet");
registration.setLoadOnStartup(2);
return registration;
}
@Override
protected SpringApplicationBuilder configure(final SpringApplicationBuilder application) {
application.sources(Application.class);
return application;
}
Note how we now have a front-end servlet that handles all front end requests and only bootstraps a Spring context specific for the front end; and then we have the API Servlet – bootstrapping an entirely different Spring context for the API.
Also – very important – these two servlet Spring contexts are child contexts. The parent context – created by SpringApplicationBuilder – scans the root package for common configuration like persistence, service, … etc.
Here is our WebFrontendConfig:
@Configuration
@EnableWebMvc
@ComponentScan({ "org.baeldung.web.controller.general" })
public class WebFrontendConfig implements WebMvcConfigurer {
@Bean
public static PropertySourcesPlaceholderConfigurer
propertySourcesPlaceholderConfigurer() {
return new PropertySourcesPlaceholderConfigurer();
}
@Bean
public ViewResolver viewResolver() {
InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
viewResolver.setPrefix("/WEB-INF/jsp/");
viewResolver.setSuffix(".jsp");
return viewResolver;
}
@Override
public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
configurer.enable();
}
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/home");
...
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/resources/**").addResourceLocations("/resources/");
}
}
And WebApiConfig:
@Configuration
@EnableWebMvc
@ComponentScan({ "org.baeldung.web.controller.rest", "org.baeldung.web.dto" })
public class WebApiConfig implements WebMvcConfigurer {
@Bean
public ModelMapper modelMapper() {
return new ModelMapper();
}
}
5. Unshorten Feeds URL
Finally – we're going to make working with RSS better.
Sometimes, RSS feeds are shortened or redirected through an external service such as Feedburner – so when we're loading the URL of a feed in the application – we need to make sure we follow that URL through all the redirects until we reach the main URL we actually care about.
So – when we post the article's link to Reddit, we actually post the correct, original URL:
@RequestMapping(value = "/url/original")
@ResponseBody
public String getOriginalLink(@RequestParam("url") String sourceUrl) {
try {
List<String> visited = new ArrayList<String>();
String currentUrl = sourceUrl;
while (!visited.contains(currentUrl)) {
visited.add(currentUrl);
currentUrl = getOriginalUrl(currentUrl);
}
return currentUrl;
} catch (Exception ex) {
// log the exception
return sourceUrl;
}
}
private String getOriginalUrl(String oldUrl) throws IOException {
URL url = new URL(oldUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setInstanceFollowRedirects(false);
String originalUrl = connection.getHeaderField("Location");
connection.disconnect();
if (originalUrl == null) {
return oldUrl;
}
if (originalUrl.indexOf("?") != -1) {
return originalUrl.substring(0, originalUrl.indexOf("?"));
}
return originalUrl;
}
A few things to take note of with this implementation:
- We're handling multiple levels of redirection
- We're also keeping track of all visited URLs to avoid redirect loops
6. Conclusion
And that's it – a few solid improvements to make the Reddit application better. The next step is to do some performance testing of the API and see how it behaves in a production scenario.