diff --git a/application.properties b/application.properties index e5a2708..5f088b3 100644 --- a/application.properties +++ b/application.properties @@ -6,3 +6,8 @@ spring.datasource.username=sa spring.datasource.password=password #- #spring.jpa.hibernate.ddl-auto=create +#- +de.ph87.tools.email.host=mail.ph87.de +de.ph87.tools.email.username=_noreply_@ph87.de +de.ph87.tools.email.password=zOt4EmTTTGdjcNtb5hfN +de.ph87.tools.email.from=_noreply_@ph87.de diff --git a/pom.xml b/pom.xml index 2067740..bedd888 100644 --- a/pom.xml +++ b/pom.xml @@ -50,6 +50,12 @@ postgresql + + com.sun.mail + jakarta.mail + 2.0.1 + + org.projectlombok lombok diff --git a/src/main/angular/src/app/api/User/UserPrivate.ts b/src/main/angular/src/app/api/User/UserPrivate.ts index 55f91e8..bece487 100644 --- a/src/main/angular/src/app/api/User/UserPrivate.ts +++ b/src/main/angular/src/app/api/User/UserPrivate.ts @@ -1,6 +1,12 @@ import {validateBoolean, validateDate, validateString} from "../common/validators"; import {UserPublic} from "./UserPublic"; +export enum EmailStatus { + NOT_SET = 'NOT_SET', + CONFIRMATION_NEEDED = 'CONFIRMATION_NEEDED', + CONFIRMED = 'CONFIRMED', +} + export class UserPrivate extends UserPublic { constructor( @@ -10,6 +16,7 @@ export class UserPrivate extends UserPublic { name: string, readonly password: boolean, readonly email: string, + readonly emailConfirmed: boolean, admin: boolean, ) { super(publicUuid, name, admin); @@ -23,6 +30,7 @@ export class UserPrivate extends UserPublic { validateString(json['name']), validateBoolean(json['password']), validateString(json['email']), + validateBoolean(json['emailConfirmed']), validateBoolean(json['admin']), ); } diff --git a/src/main/angular/src/app/api/User/user.service.ts b/src/main/angular/src/app/api/User/user.service.ts index 67b11c1..a908bc5 100644 --- a/src/main/angular/src/app/api/User/user.service.ts +++ b/src/main/angular/src/app/api/User/user.service.ts @@ -11,6 +11,7 @@ import {Numbers} from "../tools/Numbers/Numbers"; import {GroupDeletedEvent} from "../group/events/GroupDeletedEvent"; import {GroupLeftEvent} from "../group/events/GroupLeftEvent"; import {UserLogoutEvent} from "./events/UserLogoutEvent"; +import {validateBoolean} from "../common/validators"; function userPushMessageFromJson(json: any): object { const type = json['_type_']; @@ -93,6 +94,10 @@ export class UserService { this.api.postSingle(['User', 'changeEmail'], email, UserPrivate.fromJson, next); } + confirmEmail(emailConfirmation: string, next?: Next) { + this.api.postSingle(['User', 'confirmEmail'], emailConfirmation, validateBoolean, next); + } + goto(user: UserPublic) { this.router.navigate(['User', user.publicUuid]); } diff --git a/src/main/angular/src/app/app.routes.ts b/src/main/angular/src/app/app.routes.ts index 73233e7..56129d6 100644 --- a/src/main/angular/src/app/app.routes.ts +++ b/src/main/angular/src/app/app.routes.ts @@ -7,6 +7,7 @@ import {UserComponent} from "./pages/user/user.component"; import {GroupsComponent} from "./pages/group/groups/groups.component"; import {GroupComponent} from "./pages/group/group/group.component"; import {NumbersComponent} from "./pages/tools/numbers/numbers.component"; +import {EmailConfirmationComponent} from "./pages/email-confirmation/email-confirmation.component"; export const routes: Routes = [ {path: 'SolarSystemPrintout', component: SolarSystemPrintoutComponent}, @@ -22,6 +23,7 @@ export const routes: Routes = [ {path: 'User/:publicUuid', component: UserComponent}, {path: 'Profile', component: ProfileComponent}, + {path: 'emailConfirmation/:emailConfirmation', component: EmailConfirmationComponent}, // fallback {path: '**', redirectTo: '/SolarSystem'}, diff --git a/src/main/angular/src/app/pages/email-confirmation/email-confirmation.component.html b/src/main/angular/src/app/pages/email-confirmation/email-confirmation.component.html new file mode 100644 index 0000000..562731a --- /dev/null +++ b/src/main/angular/src/app/pages/email-confirmation/email-confirmation.component.html @@ -0,0 +1,11 @@ +
+ Bestätigung läuft... +
+ +
+ Bestätigung fehlgeschlagen +
+ +
+ Bestätigung erfolgreich +
diff --git a/src/main/angular/src/app/pages/email-confirmation/email-confirmation.component.less b/src/main/angular/src/app/pages/email-confirmation/email-confirmation.component.less new file mode 100644 index 0000000..e31d3b3 --- /dev/null +++ b/src/main/angular/src/app/pages/email-confirmation/email-confirmation.component.less @@ -0,0 +1,17 @@ +.emailConfirmation { + text-align: center; + margin: 1em; + font-size: 200%; +} + +.emailConfirmationPending { + color: gray; +} + +.emailConfirmationFailed { + color: indianred; +} + +.emailConfirmationSuccess { + color: green; +} diff --git a/src/main/angular/src/app/pages/email-confirmation/email-confirmation.component.ts b/src/main/angular/src/app/pages/email-confirmation/email-confirmation.component.ts new file mode 100644 index 0000000..86847cc --- /dev/null +++ b/src/main/angular/src/app/pages/email-confirmation/email-confirmation.component.ts @@ -0,0 +1,50 @@ +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {Subscription} from "rxjs"; +import {ActivatedRoute, Router} from "@angular/router"; +import {GroupService} from "../../api/group/group.service"; +import {UserService} from "../../api/User/user.service"; +import {NgIf} from "@angular/common"; +import {EmailStatus} from "../../api/User/UserPrivate"; + +@Component({ + selector: 'app-email-confirmation', + standalone: true, + imports: [ + NgIf + ], + templateUrl: './email-confirmation.component.html', + styleUrl: './email-confirmation.component.less' +}) +export class EmailConfirmationComponent implements OnInit, OnDestroy { + + protected readonly subs: Subscription[] = []; + + protected confirmed: boolean | null = null; + + constructor( + protected readonly router: Router, + protected readonly activatedRoute: ActivatedRoute, + protected readonly groupService: GroupService, + protected readonly userService: UserService, + ) { + // - + } + + ngOnInit(): void { + this.subs.push(this.activatedRoute.params.subscribe(params => { + const emailConfirmation = params['emailConfirmation']; + if (emailConfirmation) { + this.userService.confirmEmail(emailConfirmation, confirmed => { + this.confirmed = confirmed; + }); + } + })); + } + + ngOnDestroy(): void { + this.subs.forEach(sub => sub.unsubscribe()); + this.subs.length = 0; + } + + protected readonly EmailStatus = EmailStatus; +} diff --git a/src/main/angular/src/app/pages/profile/profile.component.html b/src/main/angular/src/app/pages/profile/profile.component.html index 1ddd67c..af13952 100644 --- a/src/main/angular/src/app/pages/profile/profile.component.html +++ b/src/main/angular/src/app/pages/profile/profile.component.html @@ -36,9 +36,13 @@
E-Mail + (nicht gesetzt)) + (nicht bestätigt)) + (bestätigt)
+
diff --git a/src/main/angular/src/app/pages/profile/profile.component.ts b/src/main/angular/src/app/pages/profile/profile.component.ts index 65aa9ed..5c92ee9 100644 --- a/src/main/angular/src/app/pages/profile/profile.component.ts +++ b/src/main/angular/src/app/pages/profile/profile.component.ts @@ -7,7 +7,7 @@ import {GroupListComponent} from "../group/shared/group-list/group-list.componen import {Group} from "../../api/group/Group"; import {Subscription} from "rxjs"; import {GroupService} from "../../api/group/group.service"; -import {UserPrivate} from "../../api/User/UserPrivate"; +import {EmailStatus, UserPrivate} from "../../api/User/UserPrivate"; const USER_NAME_MIN_LENGTH = 2; @@ -117,4 +117,5 @@ export class ProfileComponent implements OnInit, OnDestroy { }); } + protected readonly EmailStatus = EmailStatus; } diff --git a/src/main/java/de/ph87/tools/email/Email.java b/src/main/java/de/ph87/tools/email/Email.java new file mode 100644 index 0000000..d60e679 --- /dev/null +++ b/src/main/java/de/ph87/tools/email/Email.java @@ -0,0 +1,74 @@ +package de.ph87.tools.email; + +import de.ph87.tools.user.User; +import jakarta.annotation.Nullable; +import jakarta.persistence.*; +import lombok.*; + +import java.time.ZonedDateTime; +import java.util.UUID; + +@Entity +@Getter +@ToString +@Table(name = "`email`") +@NoArgsConstructor +public class Email { + + @Id + private String uuid = UUID.randomUUID().toString(); + + @NonNull + @Column(nullable = false) + private ZonedDateTime queued = ZonedDateTime.now(); + + @NonNull + @ManyToOne(optional = false) + private User user; + + @NonNull + @Column(nullable = false) + private String receiver; + + @NonNull + @Column(nullable = false) + private String subject; + + @Lob + @NonNull + @ToString.Exclude + @Column(nullable = false) + private String content; + + @Setter + @Column + @Nullable + private ZonedDateTime tried; + + @Setter + @Column + private int tries = 0; + + @Setter + @Column + @Nullable + private ZonedDateTime sent; + + @Setter + @NonNull + @Column(nullable = false) + private String status = ""; + + public Email(@NonNull final User user, @NonNull final String subject, @NonNull final String content) { + this.user = user; + this.receiver = user.getEmail(); + this.subject = subject; + this.content = content; + } + + public void addTry() { + tried = ZonedDateTime.now(); + tries++; + } + +} diff --git a/src/main/java/de/ph87/tools/email/EmailConfig.java b/src/main/java/de/ph87/tools/email/EmailConfig.java new file mode 100644 index 0000000..33423fb --- /dev/null +++ b/src/main/java/de/ph87/tools/email/EmailConfig.java @@ -0,0 +1,36 @@ +package de.ph87.tools.email; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.Properties; + +@Data +@Component +@ConfigurationProperties(prefix = "de.ph87.tools.email") +public class EmailConfig { + + private String host; + + private int port = 465; + + private boolean ssl = true; + + private String username; + + private String password; + + private String from; + + private String hrefBase = "http://localhost:4200"; + + public Properties getProperties() { + final Properties properties = new Properties(); + properties.put("mail.smtp.host", host); + properties.put("mail.smtp.port", port); + properties.put("mail.smtp.ssl.enable", ssl); + return properties; + } + +} diff --git a/src/main/java/de/ph87/tools/email/EmailDto.java b/src/main/java/de/ph87/tools/email/EmailDto.java new file mode 100644 index 0000000..cd226bf --- /dev/null +++ b/src/main/java/de/ph87/tools/email/EmailDto.java @@ -0,0 +1,52 @@ +package de.ph87.tools.email; + +import de.ph87.tools.user.User; +import jakarta.annotation.Nullable; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +import java.time.ZonedDateTime; + +@Getter +@ToString +public class EmailDto { + + @NonNull + private final String uuid; + + @NonNull + private final ZonedDateTime queued; + + @NonNull + private final User user; + + @NonNull + private final String receiver; + + @NonNull + private final String subject; + + @NonNull + @ToString.Exclude + private final String content; + + @Nullable + private final ZonedDateTime sent; + + @NonNull + @ToString.Exclude + private final String status; + + public EmailDto(@NonNull final Email email) { + this.uuid = email.getUuid(); + this.queued = email.getQueued(); + this.user = email.getUser(); + this.receiver = "%s <%s>".formatted(user.getName(), user.getEmail()); + this.subject = email.getSubject(); + this.content = email.getContent(); + this.sent = email.getSent(); + this.status = email.getStatus(); + } + +} diff --git a/src/main/java/de/ph87/tools/email/EmailRepository.java b/src/main/java/de/ph87/tools/email/EmailRepository.java new file mode 100644 index 0000000..3d2c708 --- /dev/null +++ b/src/main/java/de/ph87/tools/email/EmailRepository.java @@ -0,0 +1,18 @@ +package de.ph87.tools.email; + +import lombok.NonNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.ListCrudRepository; +import org.springframework.data.repository.PagingAndSortingRepository; + +import java.time.ZonedDateTime; + +public interface EmailRepository extends ListCrudRepository, PagingAndSortingRepository { + + @NonNull + @Query("select e from Email e where e.sent is null and (e.tried is null or e.tried <= :earliest) and e.tries < 5") + Page findNextToSend(@NonNull final ZonedDateTime earliest, @NonNull Pageable pageable); + +} diff --git a/src/main/java/de/ph87/tools/email/EmailSender.java b/src/main/java/de/ph87/tools/email/EmailSender.java new file mode 100644 index 0000000..3ff06f0 --- /dev/null +++ b/src/main/java/de/ph87/tools/email/EmailSender.java @@ -0,0 +1,103 @@ +package de.ph87.tools.email; + +import de.ph87.tools.email.events.EmailQueuedEvent; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.mail.*; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeBodyPart; +import jakarta.mail.internet.MimeMessage; +import jakarta.mail.internet.MimeMultipart; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailSender { + + private final EmailService emailService; + + private final EmailConfig emailConfig; + + @NonNull + private final Thread thread = new Thread(this::run, getClass().getSimpleName()); + + private boolean stop = false; + + @PostConstruct + public void postConstruct() { + thread.start(); + } + + @PreDestroy + public void preDestroy() throws InterruptedException { + synchronized (thread) { + stop = true; + wakeUp(); + } + thread.join(); + } + + private void run() { + while (!stop) { + sendAllUnsent(); + sleep(); + } + } + + private void sendAllUnsent() { + while (true) { + final Page page = emailService.findNextToSend(10); + for (final EmailDto emailDto : page.getContent()) { + send(emailDto); + } + if (page.getTotalElements() - page.getNumberOfElements() <= 0) { + break; + } + } + } + + private void sleep() { + try { + synchronized (thread) { + thread.wait(); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + private void send(@NonNull EmailDto emailDto) { + try { + final Session session = Session.getInstance(emailConfig.getProperties(), null); + final Message message = new MimeMessage(session); + message.setFrom(new InternetAddress(emailConfig.getFrom())); + message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(emailDto.getReceiver())); + message.setSubject(emailDto.getSubject()); + final MimeBodyPart mimeBodyPart = new MimeBodyPart(); + mimeBodyPart.setContent(emailDto.getContent(), "text/html; charset=utf-8"); + final Multipart multipart = new MimeMultipart(); + multipart.addBodyPart(mimeBodyPart); + message.setContent(multipart); + Transport.send(message, emailConfig.getUsername(), emailConfig.getPassword()); + emailDto = emailService.markSuccess(emailDto.getUuid()); + log.info("Email sent: email={}", emailDto); + } catch (MessagingException e) { + emailDto = emailService.markFailure(emailDto.getUuid(), e.toString()); + log.error("Failed to send email: email={}", emailDto, e); + } + } + + @TransactionalEventListener(EmailQueuedEvent.class) + public void wakeUp() { + synchronized (thread) { + thread.notifyAll(); + } + } + +} diff --git a/src/main/java/de/ph87/tools/email/EmailService.java b/src/main/java/de/ph87/tools/email/EmailService.java new file mode 100644 index 0000000..2cfd12a --- /dev/null +++ b/src/main/java/de/ph87/tools/email/EmailService.java @@ -0,0 +1,83 @@ +package de.ph87.tools.email; + +import de.ph87.tools.email.events.EmailQueuedEvent; +import de.ph87.tools.user.User; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.function.Consumer; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class EmailService { + + private static final String EMAIL_CONTENT = "

E-Mail Adresse bestätigen

Hallo %USERNAME%,
bitte klicke auf folgenden Link um deine E-Mail Adresse zu bestätigen: BESTÄTIGEN

Alternativ kannst Du auch folgenden Code auf deiner Profilseite eingeben: %EMAIL_CONFIRMATION%

"; + + private final ApplicationEventPublisher applicationEventPublisher; + + private final EmailRepository emailRepository; + + private final EmailConfig emailConfig; + + public void queue(@NonNull final User user, @NonNull final String subject, @NonNull final String content) { + final Email email = emailRepository.save(new Email(user, subject, content)); + log.info("Email queued: {}", email); + applicationEventPublisher.publishEvent(new EmailQueuedEvent()); + } + + @NonNull + public Page findNextToSend(final int maxCount) { + return emailRepository.findNextToSend(ZonedDateTime.now().minusMinutes(1), PageRequest.of(0, maxCount)).map(EmailDto::new); + } + + @NonNull + public EmailDto markSuccess(@NonNull final String uuid) { + return modify(uuid, email -> { + email.addTry(); + email.setSent(email.getTried()); + email.setStatus("OK"); + }); + } + + @NonNull + public EmailDto markFailure(@NonNull final String uuid, @NonNull final String error) { + return modify(uuid, email -> { + if (email.getSent() != null) { + log.error("Cannot mark email failure, because email is already successfully sent: email={}", email); + return; + } + email.addTry(); + email.setStatus(error); + }); + } + + @NonNull + private EmailDto modify(@NonNull final String uuid, @NonNull final Consumer modifier) { + final Email email = emailRepository.findById(uuid).orElseThrow(); + modifier.accept(email); + return new EmailDto(email); + } + + public void queueEmailConfirmationEmail(@NonNull final User user) { + final String content = createEmailContent(user); + queue(user, "Patrix Tools: Email-Adresse bestätigen", content); + } + + @NonNull + private String createEmailContent(@NonNull final User user) { + return EMAIL_CONTENT + .replaceAll("%USERNAME%", user.getName()) + .replaceAll("%HREF_BASE%", emailConfig.getHrefBase()) + .replaceAll("%EMAIL_CONFIRMATION%", user.getEmailConfirmation()); + } + +} diff --git a/src/main/java/de/ph87/tools/email/events/EmailQueuedEvent.java b/src/main/java/de/ph87/tools/email/events/EmailQueuedEvent.java new file mode 100644 index 0000000..d9b9a80 --- /dev/null +++ b/src/main/java/de/ph87/tools/email/events/EmailQueuedEvent.java @@ -0,0 +1,10 @@ +package de.ph87.tools.email.events; + +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class EmailQueuedEvent { + +} diff --git a/src/main/java/de/ph87/tools/tools/numbers/NumbersService.java b/src/main/java/de/ph87/tools/tools/numbers/NumbersService.java index 88e72ab..375e94c 100644 --- a/src/main/java/de/ph87/tools/tools/numbers/NumbersService.java +++ b/src/main/java/de/ph87/tools/tools/numbers/NumbersService.java @@ -1,8 +1,8 @@ package de.ph87.tools.tools.numbers; +import de.ph87.tools.group.GroupMapper; import de.ph87.tools.group.access.GroupAccess; import de.ph87.tools.group.access.GroupAccessService; -import de.ph87.tools.group.GroupMapper; import de.ph87.tools.group.dto.GroupDto; import de.ph87.tools.group.uuid.GroupUuid; import de.ph87.tools.tools.numbers.uuid.NumbersUuid; diff --git a/src/main/java/de/ph87/tools/user/User.java b/src/main/java/de/ph87/tools/user/User.java index fb6916f..82ebf59 100644 --- a/src/main/java/de/ph87/tools/user/User.java +++ b/src/main/java/de/ph87/tools/user/User.java @@ -72,12 +72,20 @@ public class User extends UserPublicAbstract { @Column(nullable = false) private String password = ""; - @Setter @NonNull @ToString.Exclude @Column(nullable = false) private String email = ""; + @NonNull + @ToString.Exclude + @Column(nullable = false) + private String emailConfirmation = ""; + + @Setter + @Column(nullable = false) + private boolean emailConfirmed = false; + public User(@NonNull final String name) { this.name = name; } @@ -90,4 +98,17 @@ public class User extends UserPublicAbstract { this.password = passwordEncoder.encode(password); } + public void setEmail(@NonNull final String email) { + if (this.email.equals(email)) { + return; + } + this.email = email; + this.emailConfirmed = false; + if (this.email.isEmpty()) { + this.emailConfirmation = ""; + } else { + this.emailConfirmation = UUID.randomUUID().toString(); + } + } + } diff --git a/src/main/java/de/ph87/tools/user/UserController.java b/src/main/java/de/ph87/tools/user/UserController.java index b3f4c8b..7dc20d0 100644 --- a/src/main/java/de/ph87/tools/user/UserController.java +++ b/src/main/java/de/ph87/tools/user/UserController.java @@ -55,6 +55,11 @@ public class UserController { return userService.changeEmail(userUuid, email); } + @PostMapping("confirmEmail") + public boolean confirmEmail(@NonNull @RequestBody final String emailConfirmation) { + return userService.confirmEmail(emailConfirmation); + } + @GetMapping("delete") public void delete(@NonNull final UserPrivateUuid userUuid, @NonNull final HttpServletResponse response) { userService.delete(userUuid, response); diff --git a/src/main/java/de/ph87/tools/user/UserPrivateDto.java b/src/main/java/de/ph87/tools/user/UserPrivateDto.java index fb10c8f..e2b5b12 100644 --- a/src/main/java/de/ph87/tools/user/UserPrivateDto.java +++ b/src/main/java/de/ph87/tools/user/UserPrivateDto.java @@ -39,6 +39,8 @@ public class UserPrivateDto extends UserPublicAbstract { private final String email; + private final boolean emailConfirmed; + private final boolean admin; public UserPrivateDto(@NonNull final User user) { @@ -48,6 +50,7 @@ public class UserPrivateDto extends UserPublicAbstract { this.created = user.getCreated(); this.password = !user.getPassword().isEmpty(); this.email = obfuscateEmail(user.getEmail()); + this.emailConfirmed = user.isEmailConfirmed(); this.admin = user.isAdmin(); } diff --git a/src/main/java/de/ph87/tools/user/UserRepository.java b/src/main/java/de/ph87/tools/user/UserRepository.java index 1d85e2e..05ff2ff 100644 --- a/src/main/java/de/ph87/tools/user/UserRepository.java +++ b/src/main/java/de/ph87/tools/user/UserRepository.java @@ -17,4 +17,7 @@ public interface UserRepository extends ListCrudRepository { @NonNull Optional findByPrivateUuid(@NonNull String privateUuid); + @NonNull + Optional findByEmailConfirmation(@NonNull String emailConfirmation); + } diff --git a/src/main/java/de/ph87/tools/user/UserService.java b/src/main/java/de/ph87/tools/user/UserService.java index b1707cb..ba68e7d 100644 --- a/src/main/java/de/ph87/tools/user/UserService.java +++ b/src/main/java/de/ph87/tools/user/UserService.java @@ -1,6 +1,7 @@ package de.ph87.tools.user; import de.ph87.tools.common.uuid.AbstractUuid; +import de.ph87.tools.email.EmailService; import de.ph87.tools.group.GroupMapper; import de.ph87.tools.group.GroupRepository; import de.ph87.tools.user.push.UserPushService; @@ -57,6 +58,8 @@ public class UserService { private final UserPushService userPushService; + private final EmailService emailService; + @NonNull public UserPrivateDto login(@NonNull final UserLoginRequest loginRequest, @NonNull final HttpServletResponse response) { final User user = userRepository.findByName(loginRequest.name).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); @@ -93,7 +96,7 @@ public class UserService { @NonNull public UserPrivateDto changeName(@NonNull final UserPrivateUuid privateUuid, @NonNull final String name) { - return modify(privateUuid, user -> { + return modifyToDto(privateUuid, user -> { if (user.getName().equals(name)) { return; } @@ -118,7 +121,7 @@ public class UserService { @NonNull public UserPrivateDto changePassword(@NonNull final UserPrivateUuid privateUuid, @NonNull final String password) { - return modify(privateUuid, user -> { + return modifyToDto(privateUuid, user -> { if (password.length() < PASSWORD_MIN_LENGTH) { log.warn("Cannot change User password: too short: length={}/{}, user={}", password.length(), PASSWORD_MIN_LENGTH, user); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); @@ -134,16 +137,34 @@ public class UserService { @NonNull public UserPrivateDto changeEmail(@NonNull final UserPrivateUuid privateUuid, @NonNull final String email) { - return modify(privateUuid, user -> { + return modifyToDto(privateUuid, user -> { if (!isEmailValid(email)) { log.warn("Cannot change User email: not valid, user={}", user); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } + if (user.getEmail().equals(email)) { + return; + } user.setEmail(email); log.info("User email changed: user={}", user); + emailService.queueEmailConfirmationEmail(user); }); } + public boolean confirmEmail(@NonNull final String emailConfirmation) { + final User user = userRepository.findByEmailConfirmation(emailConfirmation).orElse(null); + if (user == null) { + log.warn("Failed to confirm email: emailConfirmation={}", emailConfirmation); + return false; + } + if (!user.isEmailConfirmed()) { + user.setEmailConfirmed(true); + log.info("User email confirmed: user={}", user); + push(user); + } + return true; + } + public void delete(@NonNull final UserPrivateUuid privateUuid, @NonNull final HttpServletResponse response) { final User user = userRepository.findByPrivateUuid(privateUuid.uuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); deleteUnchecked(response, user); @@ -171,15 +192,19 @@ public class UserService { } @NonNull - private UserPrivateDto modify(@NonNull final UserPrivateUuid privateUuid, @NonNull Consumer modifier) { + private UserPrivateDto modifyToDto(@NonNull final UserPrivateUuid privateUuid, @NonNull Consumer modifier) { final User user = userAccessService.access(privateUuid); modifier.accept(user); + return push(user); + } + + private @NonNull UserPrivateDto push(@NonNull final User user) { final UserPrivateDto privateDto = new UserPrivateDto(user); userPushService.push(user, privateDto); return privateDto; } - private void deleteUnchecked(final HttpServletResponse response, final User user) { + private void deleteUnchecked(@NonNull final HttpServletResponse response, @NonNull final User user) { userRepository.delete(user); log.info("User DELETED: {}", user); writeUserUuidCookie(response, null);