Skip to content

Commit e7daf7f

Browse files
committed
Load emails at runtime from html templates
1 parent 69dd45d commit e7daf7f

13 files changed

Lines changed: 313 additions & 47 deletions

File tree

src/inttest/resources/config/application.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,12 @@ faf-api:
6868
password-reset:
6969
password-reset-url-format: "http://localhost:8010/users/claimPasswordResetToken/%s"
7070
subject: "Integration test password reset"
71-
html-format: "Integration test password reset html body"
71+
mail-template-path: "src/inttest/resources/mail/password-reset.html"
7272
registration:
7373
activation-url-format: "http://localhost/users/activate?token=%s"
7474
subject: "Integration test registration"
75-
html-format: "Integration test registration html body"
75+
activation-mail-template-path: "src/inttest/resources/mail/account-activation.html"
76+
welcome-mail-template-path: "src/inttest/resources/mail/welcome-to-faf.html"
7677
steam:
7778
realm: "http://localhost"
7879
api-key: "banana"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{{username}}
2+
3+
{{activationUrl}}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{{username}}
2+
3+
{{passwordResetUrl}}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{{username}}

src/main/java/com/faforever/api/config/FafApiProperties.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,16 @@ public static class Registration {
177177
private long linkExpirationSeconds = Duration.ofDays(7).getSeconds();
178178
private String activationUrlFormat;
179179
private String subject;
180-
private String htmlFormat;
180+
private String activationMailTemplatePath;
181+
private String welcomeMailTemplatePath;
181182
}
182183

183184
@Data
184185
public static class PasswordReset {
185186
private long linkExpirationSeconds = Duration.ofDays(7).getSeconds();
186187
private String passwordResetUrlFormat;
187188
private String subject;
188-
private String htmlFormat;
189+
private String mailTemplatePath;
189190
}
190191

191192
@Data
Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,60 @@
11
package com.faforever.api.email;
22

33
import com.faforever.api.config.FafApiProperties;
4-
import com.faforever.api.config.FafApiProperties.PasswordReset;
5-
import com.faforever.api.config.FafApiProperties.Registration;
64
import com.faforever.api.error.ApiException;
7-
import com.faforever.api.error.Error;
85
import com.faforever.api.error.ErrorCode;
9-
import lombok.SneakyThrows;
6+
import lombok.RequiredArgsConstructor;
107
import lombok.extern.slf4j.Slf4j;
118
import org.springframework.stereotype.Service;
129
import org.springframework.transaction.annotation.Transactional;
1310

14-
import java.text.MessageFormat;
11+
import java.io.IOException;
1512
import java.util.regex.Pattern;
1613

1714
@Service
1815
@Slf4j
16+
@RequiredArgsConstructor
1917
public class EmailService {
2018
private static final Pattern EMAIL_PATTERN = Pattern.compile(".+@.+\\..+$");
2119
private final DomainBlacklistRepository domainBlacklistRepository;
2220
private final FafApiProperties properties;
2321
private final EmailSender emailSender;
24-
25-
public EmailService(DomainBlacklistRepository domainBlacklistRepository, FafApiProperties properties, EmailSender emailSender) {
26-
this.domainBlacklistRepository = domainBlacklistRepository;
27-
this.properties = properties;
28-
this.emailSender = emailSender;
29-
}
22+
private final MailBodyBuilder mailBodyBuilder;
3023

3124
/**
3225
* Checks whether the specified email address as a valid format and its domain is not blacklisted.
3326
*/
3427
@Transactional(readOnly = true)
3528
public void validateEmailAddress(String email) {
3629
if (!EMAIL_PATTERN.matcher(email).matches()) {
37-
throw new ApiException(new Error(ErrorCode.EMAIL_INVALID, email));
30+
throw ApiException.of(ErrorCode.EMAIL_INVALID, email);
3831
}
3932
if (domainBlacklistRepository.existsByDomain(email.substring(email.lastIndexOf('@') + 1))) {
40-
throw new ApiException(new Error(ErrorCode.EMAIL_BLACKLISTED, email));
33+
throw ApiException.of(ErrorCode.EMAIL_BLACKLISTED, email);
4134
}
4235
}
4336

44-
@SneakyThrows
45-
public void sendActivationMail(String username, String email, String activationUrl) {
46-
Registration registration = properties.getRegistration();
37+
public void sendActivationMail(String username, String email, String activationUrl) throws IOException {
38+
final var mailBody = mailBodyBuilder.buildAccountActivationBody(username, activationUrl);
39+
4740
emailSender.sendMail(
4841
properties.getMail().getFromEmailAddress(),
4942
properties.getMail().getFromEmailName(),
5043
email,
51-
registration.getSubject(),
52-
MessageFormat.format(registration.getHtmlFormat(), username, activationUrl)
44+
properties.getRegistration().getSubject(),
45+
mailBody
5346
);
5447
}
5548

56-
@SneakyThrows
57-
public void sendPasswordResetMail(String username, String email, String passwordResetUrl) {
58-
PasswordReset passwordReset = properties.getPasswordReset();
49+
public void sendPasswordResetMail(String username, String email, String passwordResetUrl) throws IOException {
50+
final var mailBody = mailBodyBuilder.buildPasswordResetBody(username, passwordResetUrl);
51+
5952
emailSender.sendMail(
6053
properties.getMail().getFromEmailAddress(),
6154
properties.getMail().getFromEmailName(),
6255
email,
63-
passwordReset.getSubject(),
64-
MessageFormat.format(passwordReset.getHtmlFormat(), username, passwordResetUrl)
56+
properties.getPasswordReset().getSubject(),
57+
mailBody
6558
);
6659
}
6760
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package com.faforever.api.email;
2+
3+
import com.faforever.api.config.FafApiProperties;
4+
import lombok.Getter;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.beans.factory.InitializingBean;
8+
import org.springframework.stereotype.Component;
9+
10+
import java.io.IOException;
11+
import java.nio.file.Files;
12+
import java.nio.file.Path;
13+
import java.util.Map;
14+
import java.util.Set;
15+
import java.util.stream.Collectors;
16+
17+
import static java.text.MessageFormat.format;
18+
19+
@Component
20+
@RequiredArgsConstructor
21+
@Slf4j
22+
public class MailBodyBuilder implements InitializingBean {
23+
24+
@Getter
25+
public enum Template {
26+
ACCOUNT_ACTIVATION("username", "activationUrl"),
27+
WELCOME_TO_FAF("username"),
28+
PASSWORD_RESET("username", "passwordResetUrl");
29+
30+
private final Set<String> variables;
31+
32+
Template(String... variables) {
33+
this.variables = Set.of(variables);
34+
}
35+
}
36+
37+
private final FafApiProperties properties;
38+
39+
private Path getTemplateFilePath(Template template) {
40+
final var path = switch (template) {
41+
case ACCOUNT_ACTIVATION -> properties.getRegistration().getActivationMailTemplatePath();
42+
case WELCOME_TO_FAF -> properties.getRegistration().getWelcomeMailTemplatePath();
43+
case PASSWORD_RESET -> properties.getPasswordReset().getMailTemplatePath();
44+
};
45+
46+
return Path.of(path);
47+
}
48+
49+
@Override
50+
public void afterPropertiesSet(){
51+
boolean templateError = false;
52+
53+
for (final var template : Template.values()) {
54+
var path = getTemplateFilePath(template);
55+
56+
if (Files.exists(path)) {
57+
log.debug("Template {} has template file present at {}", template, path);
58+
} else {
59+
templateError = true;
60+
log.error("Template {} is missing file at configurate destination: {}", template, path);
61+
}
62+
63+
try {
64+
loadAndValidateTemplate(template);
65+
} catch (Exception e) {
66+
log.error("Template {} has invalid template file at {}. Error: {}", template, path, e.getMessage());
67+
templateError = true;
68+
}
69+
}
70+
71+
if (templateError) {
72+
throw new IllegalStateException("At least one template file is not available or inconsistent.");
73+
}
74+
75+
log.info("All template files present.");
76+
}
77+
78+
private String loadAndValidateTemplate(Template template) throws IOException {
79+
final var templateBody = Files.readString(getTemplateFilePath(template));
80+
81+
final var missingVariables = template.variables.stream()
82+
.map(variable -> "{{" + variable + "}}")
83+
.filter(variable -> !templateBody.contains(variable))
84+
.collect(Collectors.joining(", "));
85+
86+
if (missingVariables.length() > 0) {
87+
throw new IllegalStateException(format("Template file for {0} is missing variables: {1}", template, missingVariables));
88+
}
89+
90+
return templateBody;
91+
}
92+
93+
private void validateVariables(Template template, Set<String> variables) {
94+
final var missingVariables = template.variables.stream()
95+
.filter(variable -> !variables.contains(variable))
96+
.collect(Collectors.joining(", "));
97+
98+
final var unknownVariables = variables.stream()
99+
.filter(variable -> !template.variables.contains(variable))
100+
.collect(Collectors.joining(", "));
101+
102+
if (unknownVariables.length() > 0) {
103+
log.warn("Unknown variable(s) handed over for template {}: {}", template, unknownVariables);
104+
}
105+
106+
if (missingVariables.length() > 0) {
107+
throw new IllegalArgumentException("Variable(s) not assigned: " + missingVariables);
108+
}
109+
}
110+
111+
private String populate(Template template, Map<String, String> variables) throws IOException {
112+
validateVariables(template, variables.keySet());
113+
114+
var templateBody = loadAndValidateTemplate(template);
115+
116+
log.trace("Raw template body: {}", templateBody);
117+
118+
for (var entry : variables.entrySet()) {
119+
final var variable = "{{" + entry.getKey() + "}}";
120+
final var value = entry.getValue();
121+
122+
log.trace("Replacing {} with {}", variable, value);
123+
templateBody = templateBody.replace(variable, value);
124+
}
125+
126+
log.trace("Replaced template body: {}", templateBody);
127+
128+
return templateBody;
129+
}
130+
131+
public String buildAccountActivationBody(String username, String activationUrl) throws IOException {
132+
return populate(Template.ACCOUNT_ACTIVATION, Map.of(
133+
"username", username,
134+
"activationUrl", activationUrl
135+
));
136+
}
137+
138+
public String buildPasswordResetBody(String username, String passwordResetUrl) throws IOException {
139+
return populate(Template.PASSWORD_RESET, Map.of(
140+
"username", username,
141+
"passwordResetUrl", passwordResetUrl
142+
));
143+
}
144+
}

src/main/java/com/faforever/api/user/UserService.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737

3838
import static com.faforever.api.error.ErrorCode.TOKEN_INVALID;
3939
import static com.faforever.api.error.ErrorCode.UNKNOWN_STEAM_ID;
40+
import static com.github.nocatch.NoCatch.noCatch;
4041

4142
@Service
4243
@Slf4j
@@ -146,8 +147,8 @@ void register(String username, String email) {
146147

147148
String activationUrl = String.format(properties.getRegistration().getActivationUrlFormat(), username, token);
148149

149-
emailService.sendActivationMail(username, email, activationUrl);
150-
150+
// There is no recovery if the email can't be sent. The user can't continue without the mail, we need to inform the caller.
151+
noCatch(() -> emailService.sendActivationMail(username, email, activationUrl));
151152

152153
userRegistrationCounter.increment();
153154
}
@@ -309,7 +310,9 @@ void requestPasswordReset(String identifier) {
309310

310311
String passwordResetUrl = String.format(properties.getPasswordReset().getPasswordResetUrlFormat(), user.getLogin(), token);
311312

312-
emailService.sendPasswordResetMail(user.getLogin(), user.getEmail(), passwordResetUrl);
313+
// There is no recovery if the email can't be sent. The user can't continue without the mail, we need to inform the caller.
314+
noCatch(() -> emailService.sendPasswordResetMail(user.getLogin(), user.getEmail(), passwordResetUrl));
315+
313316
userPasswordResetRequestCounter.increment();
314317
}
315318

src/main/mjml/readme.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
This folder contains emails in the MailJet Markup language (see https://mjml.io/).
2+
3+
**They need to be processed with the MJML tools (e.g. IDE integrations). There is not automation currently.**
4+
5+
Once "compiled", the resulting html needs to be put into the right configuration places.
6+
As of now these are (by default):
7+
8+
* /config/mail/account-activation.html
9+
* /config/mail/welcome.html
10+
* /config/mail/password-reset.html

src/main/resources/config/application.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ faf-api:
6363
password-reset:
6464
password-reset-url-format: ${PASSWORD_RESET_URL_FORMAT:https://www.${FAF_DOMAIN}/account/password/confirmReset?username=%s&token=%s}
6565
subject: ${PASSWORD_RESET_EMAIL_SUBJECT:FAF password reset}
66-
html-format: ${PASSWORD_RESET_EMAIL_BODY:Reset email body for user {0} with reset link {1}}
66+
mail-template-path: ${PASSWORD_RESET_MAIL_TEMPLATE_PATH:/config/mail/password-reset.html}
6767
rating:
6868
default-mean: 1500
6969
default-deviation: 500
@@ -74,6 +74,8 @@ faf-api:
7474
activation-url-format: ${ACTIVATION_URL_FORMAT:https://www.${FAF_DOMAIN}/account/activate?username=%s&token=%s}
7575
subject: ${REGISTRATION_EMAIL_SUBJECT:FAF user registration}
7676
html-format: ${REGISTRATION_EMAIL_BODY:"Registration email body for user {0} with activation link {1}"}
77+
activation-mail-template-path: ${ACCOUNT_ACTIVATION_MAIL_TEMPLATE_PATH:/config/mail/account-activation.html}
78+
welcome-mail-template-path: ${WELCOME_MAIL_TEMPLATE_PATH:/config/mail/welcome-to-faf.html}
7779
replay:
7880
download-url-format: ${REPLAY_DOWNLOAD_URL_FORMAT:https://replays.${FAF_DOMAIN}/%s}
7981
steam:

0 commit comments

Comments
 (0)