Skip to content

Commit 0280ed1

Browse files
committed
added providers page
1 parent 60c37f2 commit 0280ed1

4 files changed

Lines changed: 614 additions & 1 deletion

File tree

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
package com.openmailer.openmailer.controller;
2+
3+
import com.openmailer.openmailer.exception.ValidationException;
4+
import com.openmailer.openmailer.model.Domain;
5+
import com.openmailer.openmailer.model.EmailProvider;
6+
import com.openmailer.openmailer.model.ProviderType;
7+
import com.openmailer.openmailer.security.CustomUserDetails;
8+
import com.openmailer.openmailer.service.domain.DomainService;
9+
import com.openmailer.openmailer.service.email.provider.ProviderFactory;
10+
import com.openmailer.openmailer.service.provider.EmailProviderService;
11+
import com.openmailer.openmailer.service.security.EncryptionService;
12+
import jakarta.validation.Valid;
13+
import jakarta.validation.constraints.NotBlank;
14+
import org.springframework.stereotype.Controller;
15+
import org.springframework.ui.Model;
16+
import org.springframework.validation.BindingResult;
17+
import org.springframework.web.bind.annotation.GetMapping;
18+
import org.springframework.web.bind.annotation.ModelAttribute;
19+
import org.springframework.web.bind.annotation.PathVariable;
20+
import org.springframework.web.bind.annotation.PostMapping;
21+
import org.springframework.web.bind.annotation.RequestMapping;
22+
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
23+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
24+
25+
import java.time.LocalDateTime;
26+
import java.time.format.DateTimeFormatter;
27+
import java.util.Comparator;
28+
import java.util.HashMap;
29+
import java.util.List;
30+
import java.util.Locale;
31+
import java.util.Map;
32+
33+
@Controller
34+
@RequestMapping("/providers")
35+
public class ProvidersController {
36+
37+
private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm");
38+
39+
private final EmailProviderService providerService;
40+
private final DomainService domainService;
41+
private final EncryptionService encryptionService;
42+
private final ProviderFactory providerFactory;
43+
44+
public ProvidersController(
45+
EmailProviderService providerService,
46+
DomainService domainService,
47+
EncryptionService encryptionService,
48+
ProviderFactory providerFactory
49+
) {
50+
this.providerService = providerService;
51+
this.domainService = domainService;
52+
this.encryptionService = encryptionService;
53+
this.providerFactory = providerFactory;
54+
}
55+
56+
@GetMapping
57+
public String list(@AuthenticationPrincipal CustomUserDetails userDetails, Model model) {
58+
String userId = userDetails.getUser().getId();
59+
List<ProviderListItemView> providers = providerService.findByUserId(userId).stream()
60+
.map(this::toListItemView)
61+
.sorted(Comparator
62+
.comparing(ProviderListItemView::isDefault, Comparator.reverseOrder())
63+
.thenComparing(ProviderListItemView::createdAtRaw, Comparator.reverseOrder()))
64+
.toList();
65+
List<Domain> verifiedDomains = domainService.findByUserId(userId).stream()
66+
.filter(domain -> "VERIFIED".equalsIgnoreCase(domain.getStatus()))
67+
.toList();
68+
69+
model.addAttribute("pageTitle", "Providers - OpenMailer");
70+
model.addAttribute("providerForm", new ProviderForm());
71+
model.addAttribute("providers", providers);
72+
model.addAttribute("domainOptions", verifiedDomains);
73+
model.addAttribute("totalProviders", providers.size());
74+
model.addAttribute("activeProviders", providers.stream().filter(ProviderListItemView::active).count());
75+
model.addAttribute("defaultProviderCount", providers.stream().filter(ProviderListItemView::isDefault).count());
76+
return "providers/list";
77+
}
78+
79+
@PostMapping
80+
public String create(
81+
@AuthenticationPrincipal CustomUserDetails userDetails,
82+
@Valid @ModelAttribute("providerForm") ProviderForm form,
83+
BindingResult bindingResult,
84+
Model model,
85+
RedirectAttributes redirectAttributes
86+
) {
87+
if (bindingResult.hasErrors()) {
88+
repopulate(userDetails, model);
89+
model.addAttribute("pageTitle", "Providers - OpenMailer");
90+
return "providers/list";
91+
}
92+
93+
try {
94+
EmailProvider provider = buildProvider(form, userDetails.getUser().getId());
95+
provider.setUser(userDetails.getUser());
96+
providerService.createProvider(provider);
97+
redirectAttributes.addFlashAttribute("successMessage", "Provider created successfully.");
98+
return "redirect:/providers";
99+
} catch (ValidationException ex) {
100+
bindValidationError(bindingResult, ex);
101+
repopulate(userDetails, model);
102+
model.addAttribute("pageTitle", "Providers - OpenMailer");
103+
return "providers/list";
104+
}
105+
}
106+
107+
@PostMapping("/{id}/toggle")
108+
public String toggle(
109+
@PathVariable String id,
110+
@AuthenticationPrincipal CustomUserDetails userDetails,
111+
RedirectAttributes redirectAttributes
112+
) {
113+
try {
114+
EmailProvider provider = providerService.findByIdAndUserId(id, userDetails.getUser().getId());
115+
providerService.setActiveStatus(id, userDetails.getUser().getId(), !Boolean.TRUE.equals(provider.getIsActive()));
116+
redirectAttributes.addFlashAttribute("successMessage", "Provider status updated.");
117+
} catch (RuntimeException ex) {
118+
redirectAttributes.addFlashAttribute("errorMessage", ex.getMessage());
119+
}
120+
return "redirect:/providers";
121+
}
122+
123+
@PostMapping("/{id}/default")
124+
public String setDefault(
125+
@PathVariable String id,
126+
@AuthenticationPrincipal CustomUserDetails userDetails,
127+
RedirectAttributes redirectAttributes
128+
) {
129+
try {
130+
providerService.setAsDefault(id, userDetails.getUser().getId());
131+
redirectAttributes.addFlashAttribute("successMessage", "Default provider updated.");
132+
} catch (RuntimeException ex) {
133+
redirectAttributes.addFlashAttribute("errorMessage", ex.getMessage());
134+
}
135+
return "redirect:/providers";
136+
}
137+
138+
@PostMapping("/{id}/test")
139+
public String test(
140+
@PathVariable String id,
141+
@AuthenticationPrincipal CustomUserDetails userDetails,
142+
RedirectAttributes redirectAttributes
143+
) {
144+
try {
145+
EmailProvider provider = providerService.findByIdAndUserId(id, userDetails.getUser().getId());
146+
boolean configured = providerFactory.isProviderValid(provider);
147+
redirectAttributes.addFlashAttribute(
148+
configured ? "successMessage" : "errorMessage",
149+
configured ? "Provider configuration looks valid." : "Provider configuration is invalid."
150+
);
151+
} catch (RuntimeException ex) {
152+
redirectAttributes.addFlashAttribute("errorMessage", ex.getMessage());
153+
}
154+
return "redirect:/providers";
155+
}
156+
157+
@PostMapping("/{id}/delete")
158+
public String delete(
159+
@PathVariable String id,
160+
@AuthenticationPrincipal CustomUserDetails userDetails,
161+
RedirectAttributes redirectAttributes
162+
) {
163+
try {
164+
providerService.deleteProvider(id, userDetails.getUser().getId());
165+
redirectAttributes.addFlashAttribute("successMessage", "Provider deleted successfully.");
166+
} catch (RuntimeException ex) {
167+
redirectAttributes.addFlashAttribute("errorMessage", ex.getMessage());
168+
}
169+
return "redirect:/providers";
170+
}
171+
172+
private void repopulate(CustomUserDetails userDetails, Model model) {
173+
String userId = userDetails.getUser().getId();
174+
List<ProviderListItemView> providers = providerService.findByUserId(userId).stream()
175+
.map(this::toListItemView)
176+
.sorted(Comparator
177+
.comparing(ProviderListItemView::isDefault, Comparator.reverseOrder())
178+
.thenComparing(ProviderListItemView::createdAtRaw, Comparator.reverseOrder()))
179+
.toList();
180+
List<Domain> verifiedDomains = domainService.findByUserId(userId).stream()
181+
.filter(domain -> "VERIFIED".equalsIgnoreCase(domain.getStatus()))
182+
.toList();
183+
model.addAttribute("providers", providers);
184+
model.addAttribute("domainOptions", verifiedDomains);
185+
model.addAttribute("totalProviders", providers.size());
186+
model.addAttribute("activeProviders", providers.stream().filter(ProviderListItemView::active).count());
187+
model.addAttribute("defaultProviderCount", providers.stream().filter(ProviderListItemView::isDefault).count());
188+
}
189+
190+
private EmailProvider buildProvider(ProviderForm form, String userId) {
191+
ProviderType providerType = parseType(form.getProviderType());
192+
Map<String, String> configuration = buildConfiguration(form, providerType);
193+
194+
EmailProvider provider = new EmailProvider();
195+
provider.setProviderName(form.getName().trim());
196+
provider.setProviderType(providerType);
197+
provider.setConfigurationMap(encryptSensitiveConfig(configuration));
198+
provider.setIsActive(true);
199+
provider.setIsDefault(form.isDefaultProvider());
200+
provider.setUserId(userId);
201+
provider.setEmailsSent(0);
202+
provider.setEmailsFailed(0);
203+
provider.setStatus("ACTIVE");
204+
205+
if (form.getDailyLimit() != null && !form.getDailyLimit().isBlank()) {
206+
provider.setDailyLimit(Integer.parseInt(form.getDailyLimit().trim()));
207+
}
208+
if (form.getMonthlyLimit() != null && !form.getMonthlyLimit().isBlank()) {
209+
provider.setMonthlyLimit(Integer.parseInt(form.getMonthlyLimit().trim()));
210+
}
211+
212+
return provider;
213+
}
214+
215+
private ProviderType parseType(String rawType) {
216+
try {
217+
return ProviderType.valueOf(rawType.trim().toUpperCase(Locale.ROOT));
218+
} catch (RuntimeException ex) {
219+
throw new ValidationException("Select a valid provider type.", "providerType");
220+
}
221+
}
222+
223+
private Map<String, String> buildConfiguration(ProviderForm form, ProviderType providerType) {
224+
Map<String, String> configuration = new HashMap<>();
225+
switch (providerType) {
226+
case SMTP -> {
227+
require(form.getSmtpHost(), "smtpHost", "SMTP host is required.");
228+
require(form.getSmtpUsername(), "smtpUsername", "SMTP username is required.");
229+
require(form.getSmtpPassword(), "smtpPassword", "SMTP password is required.");
230+
configuration.put("host", form.getSmtpHost().trim());
231+
configuration.put("port", blank(form.getSmtpPort()) ? "587" : form.getSmtpPort().trim());
232+
configuration.put("username", form.getSmtpUsername().trim());
233+
configuration.put("password", form.getSmtpPassword().trim());
234+
configuration.put("encryption", blank(form.getSmtpEncryption()) ? "TLS" : form.getSmtpEncryption().trim().toUpperCase(Locale.ROOT));
235+
}
236+
case SENDGRID -> {
237+
require(form.getSendgridApiKey(), "sendgridApiKey", "SendGrid API key is required.");
238+
configuration.put("apiKey", form.getSendgridApiKey().trim());
239+
}
240+
case AWS_SES -> {
241+
require(form.getAwsAccessKey(), "awsAccessKey", "AWS access key is required.");
242+
require(form.getAwsSecretKey(), "awsSecretKey", "AWS secret key is required.");
243+
configuration.put("accessKey", form.getAwsAccessKey().trim());
244+
configuration.put("secretKey", form.getAwsSecretKey().trim());
245+
configuration.put("region", blank(form.getAwsRegion()) ? "us-east-1" : form.getAwsRegion().trim());
246+
}
247+
}
248+
249+
if (!blank(form.getFromEmail())) {
250+
configuration.put("fromEmail", form.getFromEmail().trim());
251+
}
252+
if (!blank(form.getFromName())) {
253+
configuration.put("fromName", form.getFromName().trim());
254+
}
255+
return configuration;
256+
}
257+
258+
private Map<String, String> encryptSensitiveConfig(Map<String, String> config) {
259+
Map<String, String> encrypted = new HashMap<>();
260+
for (Map.Entry<String, String> entry : config.entrySet()) {
261+
if (isSensitive(entry.getKey()) && entry.getValue() != null && !entry.getValue().isBlank()) {
262+
encrypted.put(entry.getKey(), encryptionService.encrypt(entry.getValue()));
263+
} else {
264+
encrypted.put(entry.getKey(), entry.getValue());
265+
}
266+
}
267+
return encrypted;
268+
}
269+
270+
private boolean isSensitive(String key) {
271+
String normalized = key.toLowerCase(Locale.ROOT);
272+
return normalized.contains("password")
273+
|| normalized.contains("secret")
274+
|| normalized.contains("token")
275+
|| normalized.contains("apikey")
276+
|| normalized.contains("accesskey");
277+
}
278+
279+
private void require(String value, String field, String message) {
280+
if (blank(value)) {
281+
throw new ValidationException(message, field);
282+
}
283+
}
284+
285+
private boolean blank(String value) {
286+
return value == null || value.isBlank();
287+
}
288+
289+
private void bindValidationError(BindingResult bindingResult, ValidationException ex) {
290+
if (ex.getField() != null && !ex.getField().isBlank()) {
291+
bindingResult.rejectValue(ex.getField(), ex.getField() + ".invalid", ex.getMessage());
292+
} else {
293+
bindingResult.reject("provider.invalid", ex.getMessage());
294+
}
295+
}
296+
297+
private ProviderListItemView toListItemView(EmailProvider provider) {
298+
return new ProviderListItemView(
299+
provider.getId(),
300+
provider.getProviderName(),
301+
provider.getProviderType().name(),
302+
Boolean.TRUE.equals(provider.getIsActive()),
303+
Boolean.TRUE.equals(provider.getIsDefault()),
304+
provider.getDailyLimit(),
305+
provider.getMonthlyLimit(),
306+
provider.getEmailsSent() != null ? provider.getEmailsSent() : 0,
307+
provider.getEmailsFailed() != null ? provider.getEmailsFailed() : 0,
308+
provider.getLastUsedAt() != null ? provider.getLastUsedAt().format(DATE_TIME_FORMAT) : "Never",
309+
provider.getCreatedAt() != null ? provider.getCreatedAt().format(DATE_TIME_FORMAT) : "Just now",
310+
provider.getCreatedAt() != null ? provider.getCreatedAt() : LocalDateTime.MIN
311+
);
312+
}
313+
314+
public record ProviderListItemView(
315+
String id,
316+
String name,
317+
String type,
318+
boolean active,
319+
boolean isDefault,
320+
Integer dailyLimit,
321+
Integer monthlyLimit,
322+
int emailsSent,
323+
int emailsFailed,
324+
String lastUsedAt,
325+
String createdAt,
326+
LocalDateTime createdAtRaw
327+
) { }
328+
329+
public static class ProviderForm {
330+
@NotBlank(message = "Provider name is required.")
331+
private String name;
332+
@NotBlank(message = "Select a provider type.")
333+
private String providerType = "SMTP";
334+
private String smtpHost;
335+
private String smtpPort = "587";
336+
private String smtpUsername;
337+
private String smtpPassword;
338+
private String smtpEncryption = "TLS";
339+
private String sendgridApiKey;
340+
private String awsAccessKey;
341+
private String awsSecretKey;
342+
private String awsRegion = "us-east-1";
343+
private String fromEmail;
344+
private String fromName;
345+
private String dailyLimit;
346+
private String monthlyLimit;
347+
private boolean defaultProvider;
348+
349+
public String getName() { return name; }
350+
public void setName(String name) { this.name = name; }
351+
public String getProviderType() { return providerType; }
352+
public void setProviderType(String providerType) { this.providerType = providerType; }
353+
public String getSmtpHost() { return smtpHost; }
354+
public void setSmtpHost(String smtpHost) { this.smtpHost = smtpHost; }
355+
public String getSmtpPort() { return smtpPort; }
356+
public void setSmtpPort(String smtpPort) { this.smtpPort = smtpPort; }
357+
public String getSmtpUsername() { return smtpUsername; }
358+
public void setSmtpUsername(String smtpUsername) { this.smtpUsername = smtpUsername; }
359+
public String getSmtpPassword() { return smtpPassword; }
360+
public void setSmtpPassword(String smtpPassword) { this.smtpPassword = smtpPassword; }
361+
public String getSmtpEncryption() { return smtpEncryption; }
362+
public void setSmtpEncryption(String smtpEncryption) { this.smtpEncryption = smtpEncryption; }
363+
public String getSendgridApiKey() { return sendgridApiKey; }
364+
public void setSendgridApiKey(String sendgridApiKey) { this.sendgridApiKey = sendgridApiKey; }
365+
public String getAwsAccessKey() { return awsAccessKey; }
366+
public void setAwsAccessKey(String awsAccessKey) { this.awsAccessKey = awsAccessKey; }
367+
public String getAwsSecretKey() { return awsSecretKey; }
368+
public void setAwsSecretKey(String awsSecretKey) { this.awsSecretKey = awsSecretKey; }
369+
public String getAwsRegion() { return awsRegion; }
370+
public void setAwsRegion(String awsRegion) { this.awsRegion = awsRegion; }
371+
public String getFromEmail() { return fromEmail; }
372+
public void setFromEmail(String fromEmail) { this.fromEmail = fromEmail; }
373+
public String getFromName() { return fromName; }
374+
public void setFromName(String fromName) { this.fromName = fromName; }
375+
public String getDailyLimit() { return dailyLimit; }
376+
public void setDailyLimit(String dailyLimit) { this.dailyLimit = dailyLimit; }
377+
public String getMonthlyLimit() { return monthlyLimit; }
378+
public void setMonthlyLimit(String monthlyLimit) { this.monthlyLimit = monthlyLimit; }
379+
public boolean isDefaultProvider() { return defaultProvider; }
380+
public void setDefaultProvider(boolean defaultProvider) { this.defaultProvider = defaultProvider; }
381+
}
382+
}

src/main/resources/templates/dashboard.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ <h3 class="mt-1 text-sm font-bold text-white">Delivery status</h3>
364364
</div>
365365
<div class="rounded-[13px] bg-[var(--card-pink)]/10 p-3.5 transition hover:bg-[var(--card-pink)]/16">
366366
<p class="text-[12px] font-semibold text-white/78">Email provider</p>
367-
<a href="/campaigns/create" class="mt-1 inline-flex text-[11px] font-bold text-[var(--card-pink)]/65 transition hover:text-[var(--card-pink)]">Review provider setup</a>
367+
<a href="/providers" class="mt-1 inline-flex text-[11px] font-bold text-[var(--card-pink)]/65 transition hover:text-[var(--card-pink)]">Add provider →</a>
368368
</div>
369369
<div class="rounded-[13px] bg-[var(--card-mauve)]/10 p-3.5 transition hover:bg-[var(--card-mauve)]/16">
370370
<p class="text-[12px] font-semibold text-white/78">Import audience</p>

0 commit comments

Comments
 (0)