Initial commit: mail-server with WebFlux backend and Angular v22 Synthwave frontend

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-11-05 01:07:29 +01:00
co-authored by Junie
commit 0339413d6e
78 changed files with 6808 additions and 0 deletions
@@ -0,0 +1,53 @@
package com.mailserver.controller;
import com.mailserver.dto.LoginRequest;
import com.mailserver.dto.LoginResponse;
import com.mailserver.domain.User;
import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class AuthenticationController {
private final UserService userService;
private final JwtUtils jwtUtils;
public AuthenticationController(UserService userService, JwtUtils jwtUtils) {
this.userService = userService;
this.jwtUtils = jwtUtils;
}
@PostMapping("/login")
public Mono<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> requestMono) {
return requestMono.flatMap(request ->
userService.findByEmail(request.getEmail())
.switchIfEmpty(Mono.error(new IllegalArgumentException("Invalid credentials")))
.flatMap(user -> {
if (!user.isActive()) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated")));
}
if (!userService.checkPassword(user, request.getPassword())) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")));
}
String token = jwtUtils.generateToken(user.getEmail());
LoginResponse response = new LoginResponse();
response.setToken(token);
response.setEmail(user.getEmail());
return Mono.just(ResponseEntity.ok(response));
})
.onErrorResume(IllegalArgumentException.class, e ->
Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")))
)
);
}
}
@@ -0,0 +1,279 @@
package com.mailserver.controller;
import com.mailserver.dto.AttachmentResponse;
import com.mailserver.dto.FolderResponse;
import com.mailserver.dto.MessageResponse;
import com.mailserver.dto.RegisterRequest;
import com.mailserver.dto.SendRequest;
import com.mailserver.service.MailService;
import com.mailserver.service.UserService;
import jakarta.validation.Valid;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api")
public class MailController {
private static final Logger log = LoggerFactory.getLogger(MailController.class);
private final UserService userService;
private final MailService mailService;
public MailController(UserService userService, MailService mailService) {
this.userService = userService;
this.mailService = mailService;
}
private Mono<String> getCurrentUserEmail(Mono<Authentication> authenticationMono) {
return authenticationMono.map(Authentication::getName);
}
// --- Users ---
@PostMapping("/register")
public Mono<?> register(@Valid @RequestBody Mono<RegisterRequest> requestMono) {
return requestMono.flatMap(request ->
userService.register(request.getEmail(), request.getPassword(), request.getDisplayName())
.map(user -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED)
.body(Map.of("message", "User registered")))
.onErrorResume(IllegalArgumentException.class, e ->
Mono.just(org.springframework.http.ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage()))))
);
}
@GetMapping("/folders")
public Flux<FolderResponse> getFolders(Mono<Authentication> authenticationMono) {
return authenticationMono.flatMapMany(auth ->
userService.findByEmail(auth.getName())
.flatMapMany(user -> userService.getFolders(user.getId()))
);
}
// --- Messages ---
@GetMapping("/messages")
public Flux<MessageResponse> getMessages(
Mono<Authentication> authenticationMono,
@RequestParam Long folderId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return authenticationMono.flatMapMany(auth ->
userService.findByEmail(auth.getName())
.flatMapMany(user -> mailService.getMessages(folderId, user.getId(), page, size))
);
}
@GetMapping("/messages/search")
public Flux<MessageResponse> searchMessages(
Mono<Authentication> authenticationMono,
@RequestParam Long folderId,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String subject,
@RequestParam(required = false) String body,
@RequestParam(required = false) String sender,
@RequestParam(required = false) String recipient,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return authenticationMono.flatMapMany(auth ->
userService.findByEmail(auth.getName())
.flatMapMany(user -> {
if (keyword != null && !keyword.trim().isEmpty()) {
return mailService.searchMessages(folderId, user.getId(), keyword.trim(), page, size);
}
return mailService.searchMessagesWithFilters(folderId, user.getId(),
subject, body, sender, recipient, page, size);
})
);
}
@GetMapping("/messages/{id}")
public Mono<MessageResponse> getMessage(@PathVariable Long id, Mono<Authentication> authenticationMono) {
return authenticationMono.flatMap(auth ->
userService.findByEmail(auth.getName())
.flatMap(user -> mailService.getMessage(id, user.getId()))
);
}
@PostMapping("/send")
public Mono<Object> sendMessage(
Mono<Authentication> authenticationMono,
@Valid @RequestBody Mono<SendRequest> requestMono) {
return authenticationMono.zipWith(requestMono)
.flatMap(pair -> {
String email = pair.getT1().getName();
SendRequest request = pair.getT2();
return mailService.sendMessage(email, request)
.map(response -> (Object) org.springframework.http.ResponseEntity.ok(response))
.onErrorResume(e -> {
log.error("Ошибка при отправке письма: {}", e.getMessage(), e);
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage())));
});
});
}
@PutMapping("/messages/{id}/read")
public Mono<?> markAsRead(@PathVariable Long id, Mono<Authentication> authenticationMono) {
return authenticationMono.flatMap(auth ->
userService.findByEmail(auth.getName())
.flatMap(user -> mailService.markAsRead(id, user.getId())
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Marked as read")))))
);
}
@DeleteMapping("/messages/{id}")
public Mono<?> deleteMessage(@PathVariable Long id, Mono<Authentication> authenticationMono) {
return authenticationMono.flatMap(auth ->
userService.findByEmail(auth.getName())
.flatMap(user -> mailService.deleteMessage(id, user.getId())
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Message deleted")))))
);
}
// --- Simulate incoming message (для тестов) ---
@PostMapping("/simulate/receive")
public Mono<?> simulateReceive(@RequestBody Mono<Map<String, String>> bodyMono) {
return bodyMono.flatMap(body ->
mailService.receiveMessage(
body.get("senderEmail"),
body.get("senderName"),
body.get("recipientEmail"),
body.get("subject"),
body.get("body"),
"true".equals(body.get("html"))
)
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Message received"))))
.onErrorResume(e ->
Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))))
);
}
// --- Attachments ---
@PostMapping(value = "/messages/{messageId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Mono<?> uploadAttachment(
@PathVariable Long messageId,
Mono<Authentication> authenticationMono,
@RequestBody Mono<Flux<FilePart>> filePartsMono) {
return authenticationMono.zipWith(filePartsMono)
.flatMap(pair -> {
String email = pair.getT1().getName();
return userService.findByEmail(email)
.flatMapMany(user -> pair.getT2())
.next()
.flatMap(file -> {
String originalFilename = file.filename();
String uniqueName = UUID.randomUUID() + "_" + originalFilename;
Path filePath = Paths.get(mailService.getAttachmentsDir(), uniqueName);
try {
Files.createDirectories(filePath.getParent());
} catch (IOException e) {
return Mono.error(new RuntimeException("Failed to create directory", e));
}
return DataBufferUtils.join(file.content())
.flatMap(dataBuffer -> {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
DataBufferUtils.release(dataBuffer);
try {
Files.write(filePath, bytes);
} catch (IOException e) {
return Mono.error(new RuntimeException("Failed to save file", e));
}
return userService.findByEmail(email)
.flatMap(user -> mailService.saveAttachmentReactive(
messageId, user.getId(),
originalFilename, file.headers().getContentType().toString(),
bytes.length, filePath.toString()))
.map(response -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).body(response));
});
});
});
}
@GetMapping("/messages/{messageId}/attachments")
public Flux<AttachmentResponse> getMessageAttachments(
@PathVariable Long messageId,
Mono<Authentication> authenticationMono) {
return authenticationMono.flatMapMany(auth ->
userService.findByEmail(auth.getName())
.flatMapMany(user -> mailService.getMessageAttachments(messageId, user.getId()))
);
}
@GetMapping("/attachments/{attachmentId}/download")
public Mono<Object> downloadAttachment(
@PathVariable Long attachmentId,
Mono<Authentication> authenticationMono) {
return authenticationMono.flatMap(auth ->
userService.findByEmail(auth.getName())
.flatMap(user -> {
try {
AttachmentResponse info = mailService.getAttachmentInfo(attachmentId, user.getId());
Path filePath = Paths.get(mailService.getAttachmentFilePath(attachmentId, user.getId()));
byte[] bytes;
try {
bytes = Files.readAllBytes(filePath);
} catch (IOException e) {
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest()
.body(Map.of("error", "Failed to read file: " + e.getMessage())));
}
DataBuffer buffer = new DefaultDataBufferFactory().wrap(bytes);
return Mono.just((Object) org.springframework.http.ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + info.getFilename() + "\"")
.contentType(MediaType.parseMediaType(
info.getContentType() != null ? info.getContentType() : "application/octet-stream"))
.body(buffer)
);
} catch (IllegalArgumentException e) {
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())));
}
})
);
}
@DeleteMapping("/attachments/{attachmentId}")
public Mono<?> deleteAttachment(
@PathVariable Long attachmentId,
Mono<Authentication> authenticationMono) {
return authenticationMono.flatMap(auth ->
userService.findByEmail(auth.getName())
.flatMap(user -> mailService.deleteAttachment(attachmentId, user.getId())
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Attachment deleted")))))
.onErrorResume(IllegalArgumentException.class, e ->
Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))))
);
}
}
@@ -0,0 +1,61 @@
package com.mailserver.controller;
import com.mailserver.domain.BlacklistEntry;
import com.mailserver.service.SpamFilterService;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Map;
/**
* Контроллер для управления чёрным списком отправителей.
*/
@RestController
@RequestMapping("/api/spam")
public class SpamController {
private final SpamFilterService spamFilterService;
public SpamController(SpamFilterService spamFilterService) {
this.spamFilterService = spamFilterService;
}
private String getCurrentUserEmail(Authentication authentication) {
return authentication.getName();
}
@GetMapping("/blacklist")
public Flux<BlacklistEntry> getBlacklist(Mono<Authentication> authenticationMono) {
return authenticationMono.flatMapMany(auth ->
spamFilterService.getBlacklist(getCurrentUserEmail(auth))
);
}
@PostMapping("/blacklist")
public Mono<BlacklistEntry> addToBlacklist(Mono<Authentication> authenticationMono, @RequestBody Mono<Map<String, String>> requestMono) {
return authenticationMono.zipWith(requestMono).flatMap(pair -> {
String email = getCurrentUserEmail(pair.getT1());
String senderEmail = pair.getT2().get("senderEmail");
String reason = pair.getT2().get("reason");
if (senderEmail == null || senderEmail.trim().isEmpty()) {
return Mono.error(new IllegalArgumentException("senderEmail is required"));
}
return spamFilterService.addToBlacklist(email, senderEmail.trim(), reason);
});
}
@DeleteMapping("/blacklist")
public Mono<Void> removeFromBlacklist(Mono<Authentication> authenticationMono, @RequestParam String senderEmail) {
return authenticationMono.flatMap(auth ->
spamFilterService.removeFromBlacklist(getCurrentUserEmail(auth), senderEmail)
);
}
}