Почтовый кабинет: регистрация, папки, черновики, вложения, грид @jambotron/aria-grid, интерфейс по synthwave-шаблону
Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма (прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия, вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных — в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой. Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html. α Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -51,7 +51,7 @@ public class SecurityConfig {
|
||||
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.authorizeExchange(exchange -> exchange
|
||||
.pathMatchers("/api/login", "/api/register", "/actuator/health").permitAll()
|
||||
.pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll()
|
||||
.pathMatchers("/api/**", "/actuator/**").authenticated()
|
||||
// the web client (static files, SPA routes)
|
||||
.anyExchange().permitAll()
|
||||
|
||||
@@ -7,7 +7,10 @@ import org.springframework.web.server.WebFilter;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/** Angular routes (/inbox, /message/5, …) are pages of the web client: serve index.html for them. */
|
||||
/**
|
||||
* Angular routes (/inbox, /message/5, /compose;to=a@b.c …) are pages of the web client: serve index.html for them.
|
||||
* A last segment with a dot (before matrix parameters) is a file.
|
||||
*/
|
||||
@Component
|
||||
@Order(-100)
|
||||
public class SpaWebFilter implements WebFilter {
|
||||
@@ -17,7 +20,7 @@ public class SpaWebFilter implements WebFilter {
|
||||
String path = exchange.getRequest().getPath().pathWithinApplication().value();
|
||||
boolean page = exchange.getRequest().getMethod().matches("GET")
|
||||
&& !path.startsWith("/api") && !path.startsWith("/actuator")
|
||||
&& !path.equals("/") && !path.substring(path.lastIndexOf('/') + 1).contains(".");
|
||||
&& !path.equals("/") && !path.substring(path.lastIndexOf('/') + 1).split(";")[0].contains(".");
|
||||
if (page) {
|
||||
return chain.filter(exchange.mutate().request(exchange.getRequest().mutate().path("/index.html").build()).build());
|
||||
}
|
||||
|
||||
@@ -1,36 +1,68 @@
|
||||
package com.mailserver.controller;
|
||||
|
||||
import com.mailserver.config.MailDomains;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.dto.LoginRequest;
|
||||
import com.mailserver.dto.LoginResponse;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.dto.PasswordChangeRequest;
|
||||
import com.mailserver.dto.ProfileResponse;
|
||||
import com.mailserver.dto.ProfileUpdateRequest;
|
||||
import com.mailserver.dto.RegisterRequest;
|
||||
import com.mailserver.service.MailService;
|
||||
import com.mailserver.service.UserService;
|
||||
import com.mailserver.util.JwtUtils;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Login, registration, the logged-in user's profile and password; what the login page needs to know. */
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class AuthenticationController {
|
||||
|
||||
private final UserService userService;
|
||||
private final JwtUtils jwtUtils;
|
||||
/** Service addresses nobody can register (RFC 2142 and ours). */
|
||||
static final Set<String> RESERVED = Set.of("postmaster", "abuse", "mailer-daemon", "hostmaster", "webmaster",
|
||||
"root", "admin", "noreply", "no-reply", "security", "support");
|
||||
|
||||
public AuthenticationController(UserService userService, JwtUtils jwtUtils) {
|
||||
private final UserService userService;
|
||||
private final MailService mailService;
|
||||
private final JwtUtils jwtUtils;
|
||||
private final MailDomains domains;
|
||||
private final boolean registrationEnabled;
|
||||
|
||||
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
|
||||
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled) {
|
||||
this.userService = userService;
|
||||
this.mailService = mailService;
|
||||
this.jwtUtils = jwtUtils;
|
||||
this.domains = domains;
|
||||
this.registrationEnabled = registrationEnabled;
|
||||
}
|
||||
|
||||
/** Public: is registration open, and in which domains mailboxes can be created. */
|
||||
@GetMapping("/config")
|
||||
public Map<String, Object> config() {
|
||||
return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList());
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public Mono<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> requestMono) {
|
||||
return requestMono.flatMap(request ->
|
||||
userService.findByEmail(request.getEmail())
|
||||
userService.findByEmail(request.getEmail().trim().toLowerCase(Locale.ROOT))
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("Invalid credentials")))
|
||||
.flatMap(user -> {
|
||||
if (!user.isActive()) {
|
||||
@@ -39,15 +71,74 @@ public class AuthenticationController {
|
||||
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));
|
||||
return Mono.just(ResponseEntity.ok(token(user)));
|
||||
})
|
||||
.onErrorResume(IllegalArgumentException.class, e ->
|
||||
Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** New mailbox in one of our domains; logs in right away (the answer carries the token). */
|
||||
@PostMapping("/register")
|
||||
public Mono<ResponseEntity<?>> register(@Valid @RequestBody Mono<RegisterRequest> requestMono) {
|
||||
if (!registrationEnabled) {
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", "Registration is closed")));
|
||||
}
|
||||
return requestMono.flatMap(request -> {
|
||||
String email = request.getEmail().trim().toLowerCase(Locale.ROOT);
|
||||
String local = email.substring(0, email.indexOf('@'));
|
||||
if (!domains.isLocal(email)) {
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "Mailboxes can only be created in: " + String.join(", ", domains.domains()))));
|
||||
}
|
||||
if (RESERVED.contains(local) || !local.matches("[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?")) {
|
||||
return Mono.just(ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "This name is not available: letters, digits, dot, dash, underscore")));
|
||||
}
|
||||
return userService.register(email, request.getPassword(), request.getDisplayName())
|
||||
.map(user -> ResponseEntity.status(HttpStatus.CREATED).body((Object) token(user)))
|
||||
.onErrorResume(IllegalArgumentException.class, e ->
|
||||
Mono.just(ResponseEntity.badRequest().body(Map.of("error", "This mailbox already exists"))));
|
||||
});
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public Mono<ProfileResponse> me(Mono<Authentication> auth) {
|
||||
return user(auth).flatMap(this::profile);
|
||||
}
|
||||
|
||||
@PutMapping("/me")
|
||||
public Mono<ProfileResponse> updateMe(Mono<Authentication> auth, @Valid @RequestBody Mono<ProfileUpdateRequest> body) {
|
||||
return user(auth).zipWith(body)
|
||||
.flatMap(pair -> userService.updateDisplayName(pair.getT1(), pair.getT2().displayName()))
|
||||
.flatMap(this::profile);
|
||||
}
|
||||
|
||||
@PutMapping("/me/password")
|
||||
public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> body) {
|
||||
return user(auth).zipWith(body)
|
||||
.flatMap(pair -> userService.changePassword(pair.getT1(), pair.getT2().currentPassword(), pair.getT2().newPassword()))
|
||||
.thenReturn(Map.of("message", "Password changed"));
|
||||
}
|
||||
|
||||
private Mono<ProfileResponse> profile(User user) {
|
||||
return mailService.usedBytes(user.getId())
|
||||
.map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used,
|
||||
user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt()));
|
||||
}
|
||||
|
||||
private Mono<User> user(Mono<Authentication> auth) {
|
||||
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
|
||||
.flatMap(a -> userService.findByEmail(a.getName()))
|
||||
.onErrorMap(IllegalArgumentException.class, e -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
|
||||
}
|
||||
|
||||
private LoginResponse token(User user) {
|
||||
LoginResponse response = new LoginResponse();
|
||||
response.setToken(jwtUtils.generateToken(user.getEmail()));
|
||||
response.setEmail(user.getEmail());
|
||||
return response;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,31 +3,30 @@ 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.DraftRequest;
|
||||
import com.mailserver.dto.FolderRequest;
|
||||
import com.mailserver.dto.MessageUpdateRequest;
|
||||
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 com.mailserver.config.MailDomains;
|
||||
import com.mailserver.service.SubmissionService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
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.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -51,165 +50,148 @@ public class MailController {
|
||||
private final UserService userService;
|
||||
private final MailService mailService;
|
||||
private final SubmissionService submissionService;
|
||||
private final MailDomains domains;
|
||||
private final boolean registrationEnabled;
|
||||
|
||||
public MailController(UserService userService, MailService mailService, SubmissionService submissionService,
|
||||
MailDomains domains,
|
||||
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled) {
|
||||
public MailController(UserService userService, MailService mailService, SubmissionService submissionService) {
|
||||
this.userService = userService;
|
||||
this.mailService = mailService;
|
||||
this.submissionService = submissionService;
|
||||
this.domains = domains;
|
||||
this.registrationEnabled = registrationEnabled;
|
||||
}
|
||||
|
||||
private Mono<String> getCurrentUserEmail(Mono<Authentication> authenticationMono) {
|
||||
return authenticationMono.map(Authentication::getName);
|
||||
private Mono<com.mailserver.domain.User> user(Mono<Authentication> auth) {
|
||||
return auth.flatMap(a -> userService.findByEmail(a.getName()));
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
@PostMapping("/register")
|
||||
public Mono<ResponseEntity<?>> register(@Valid @RequestBody Mono<RegisterRequest> requestMono) {
|
||||
if (!registrationEnabled) {
|
||||
return Mono.just(org.springframework.http.ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "Registration is closed")));
|
||||
}
|
||||
return requestMono.flatMap(request -> !domains.isLocal(request.getEmail())
|
||||
? Mono.just(org.springframework.http.ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "Mailboxes can only be created in: " + String.join(", ", domains.domains()))))
|
||||
: userService.register(request.getEmail().trim().toLowerCase(java.util.Locale.ROOT), 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()))))
|
||||
);
|
||||
}
|
||||
// --- Folders ---
|
||||
|
||||
@GetMapping("/folders")
|
||||
public Flux<FolderResponse> getFolders(Mono<Authentication> authenticationMono) {
|
||||
return authenticationMono.flatMapMany(auth ->
|
||||
userService.findByEmail(auth.getName())
|
||||
.flatMapMany(user -> userService.getFolders(user.getId()))
|
||||
);
|
||||
public Flux<FolderResponse> getFolders(Mono<Authentication> auth) {
|
||||
return user(auth).flatMapMany(user -> mailService.folders(user.getId()));
|
||||
}
|
||||
|
||||
@PostMapping("/folders")
|
||||
public Mono<ResponseEntity<FolderResponse>> createFolder(Mono<Authentication> auth, @Valid @RequestBody Mono<FolderRequest> body) {
|
||||
return user(auth).zipWith(body)
|
||||
.flatMap(pair -> mailService.createFolder(pair.getT1().getId(), pair.getT2().name()))
|
||||
.map(folder -> ResponseEntity.status(HttpStatus.CREATED).body(folder));
|
||||
}
|
||||
|
||||
@PutMapping("/folders/{id}")
|
||||
public Mono<Map<String, String>> renameFolder(@PathVariable Long id, Mono<Authentication> auth,
|
||||
@Valid @RequestBody Mono<FolderRequest> body) {
|
||||
return user(auth).zipWith(body)
|
||||
.flatMap(pair -> mailService.renameFolder(id, pair.getT1().getId(), pair.getT2().name()))
|
||||
.thenReturn(Map.of("message", "Folder renamed"));
|
||||
}
|
||||
|
||||
/** Own folder only; its messages go to the Trash. */
|
||||
@DeleteMapping("/folders/{id}")
|
||||
public Mono<Map<String, String>> deleteFolder(@PathVariable Long id, Mono<Authentication> auth) {
|
||||
return user(auth).flatMap(user -> mailService.deleteFolder(id, user.getId()))
|
||||
.thenReturn(Map.of("message", "Folder deleted"));
|
||||
}
|
||||
|
||||
/** Trash or Spam: removes all its messages for good. */
|
||||
@DeleteMapping("/folders/{id}/messages")
|
||||
public Mono<Map<String, Object>> emptyFolder(@PathVariable Long id, Mono<Authentication> auth) {
|
||||
return user(auth).flatMap(user -> mailService.emptyFolder(id, user.getId()))
|
||||
.map(count -> Map.<String, Object>of("deleted", count));
|
||||
}
|
||||
|
||||
// --- Messages ---
|
||||
|
||||
/** Newest first, bodies cut to a preview. */
|
||||
@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))
|
||||
);
|
||||
public Flux<MessageResponse> getMessages(Mono<Authentication> auth, @RequestParam Long folderId,
|
||||
@RequestParam(defaultValue = "" + MailService.LIST_LIMIT) int limit) {
|
||||
return user(auth).flatMapMany(user -> mailService.listMessages(folderId, user.getId(), limit));
|
||||
}
|
||||
|
||||
@GetMapping("/messages/search")
|
||||
public Flux<MessageResponse> searchMessages(
|
||||
Mono<Authentication> authenticationMono,
|
||||
Mono<Authentication> auth,
|
||||
@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);
|
||||
})
|
||||
);
|
||||
@RequestParam(required = false) String recipient) {
|
||||
return user(auth).flatMapMany(user -> keyword != null && !keyword.isBlank()
|
||||
? mailService.searchMessages(folderId, user.getId(), keyword.trim(), 0, MailService.LIST_LIMIT)
|
||||
: mailService.searchMessagesWithFilters(folderId, user.getId(), subject, body, sender, recipient,
|
||||
0, MailService.LIST_LIMIT));
|
||||
}
|
||||
|
||||
@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()))
|
||||
);
|
||||
public Mono<MessageResponse> getMessage(@PathVariable Long id, Mono<Authentication> auth) {
|
||||
return user(auth).flatMap(user -> mailService.getMessage(id, user.getId()));
|
||||
}
|
||||
|
||||
@PostMapping("/send")
|
||||
public Mono<MessageResponse> sendMessage(
|
||||
Mono<Authentication> authenticationMono,
|
||||
@Valid @RequestBody Mono<SendRequest> requestMono) {
|
||||
return authenticationMono.zipWith(requestMono)
|
||||
.flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2()));
|
||||
/** read / flagged / folderId — only the fields that are set. */
|
||||
@PatchMapping("/messages/{id}")
|
||||
public Mono<MessageResponse> updateMessage(@PathVariable Long id, Mono<Authentication> auth,
|
||||
@RequestBody Mono<MessageUpdateRequest> body) {
|
||||
return user(auth).zipWith(body)
|
||||
.flatMap(pair -> mailService.updateMessage(id, pair.getT1().getId(), pair.getT2()));
|
||||
}
|
||||
|
||||
@PutMapping("/messages/{id}/read")
|
||||
public Mono<ResponseEntity<?>> 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")))))
|
||||
);
|
||||
public Mono<Map<String, String>> markAsRead(@PathVariable Long id, Mono<Authentication> auth) {
|
||||
return user(auth).flatMap(user -> mailService.markAsRead(id, user.getId()))
|
||||
.thenReturn(Map.of("message", "Marked as read"));
|
||||
}
|
||||
|
||||
/** Into the Trash; from the Trash — for good. */
|
||||
@DeleteMapping("/messages/{id}")
|
||||
public Mono<ResponseEntity<?>> 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")))))
|
||||
);
|
||||
public Mono<Map<String, String>> deleteMessage(@PathVariable Long id, Mono<Authentication> auth) {
|
||||
return user(auth).flatMap(user -> mailService.deleteMessage(id, user.getId()))
|
||||
.thenReturn(Map.of("message", "Message deleted"));
|
||||
}
|
||||
|
||||
@PostMapping("/send")
|
||||
public Mono<MessageResponse> sendMessage(Mono<Authentication> auth, @Valid @RequestBody Mono<SendRequest> requestMono) {
|
||||
return auth.zipWith(requestMono)
|
||||
.flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2()));
|
||||
}
|
||||
|
||||
// --- Drafts ---
|
||||
|
||||
@PostMapping("/drafts")
|
||||
public Mono<MessageResponse> createDraft(Mono<Authentication> auth, @Valid @RequestBody Mono<DraftRequest> body) {
|
||||
return user(auth).zipWith(body).flatMap(pair -> mailService.saveDraft(pair.getT1(), null, pair.getT2()));
|
||||
}
|
||||
|
||||
@PutMapping("/drafts/{id}")
|
||||
public Mono<MessageResponse> updateDraft(@PathVariable Long id, Mono<Authentication> auth,
|
||||
@Valid @RequestBody Mono<DraftRequest> body) {
|
||||
return user(auth).zipWith(body).flatMap(pair -> mailService.saveDraft(pair.getT1(), id, pair.getT2()));
|
||||
}
|
||||
|
||||
// --- Attachments ---
|
||||
|
||||
/** A file for a draft (form field "file"); stored on disk, the draft gets the record. */
|
||||
@PostMapping(value = "/messages/{messageId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public Mono<ResponseEntity<?>> uploadAttachment(
|
||||
public Mono<ResponseEntity<AttachmentResponse>> 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 = safeFilename(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() != null ? file.headers().getContentType().toString() : "application/octet-stream",
|
||||
bytes.length, filePath.toString()))
|
||||
.map(response -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).body(response));
|
||||
});
|
||||
});
|
||||
});
|
||||
Mono<Authentication> auth,
|
||||
@RequestPart("file") Mono<FilePart> fileMono) {
|
||||
return user(auth).zipWith(fileMono).flatMap(pair -> {
|
||||
var user = pair.getT1();
|
||||
FilePart file = pair.getT2();
|
||||
String originalFilename = safeFilename(file.filename());
|
||||
Path filePath = Paths.get(mailService.getAttachmentsDir(), UUID.randomUUID() + "_" + originalFilename);
|
||||
String contentType = file.headers().getContentType() != null
|
||||
? file.headers().getContentType().toString() : "application/octet-stream";
|
||||
// the draft must be the user's before anything is written
|
||||
return mailService.ownedDraft(messageId, user.getId())
|
||||
.then(Mono.fromCallable(() -> Files.createDirectories(filePath.getParent()))
|
||||
.subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic()))
|
||||
.then(file.transferTo(filePath))
|
||||
.then(Mono.fromCallable(() -> Files.size(filePath))
|
||||
.subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic()))
|
||||
.flatMap(size -> mailService.saveAttachmentReactive(messageId, user.getId(), originalFilename,
|
||||
contentType, size, filePath.toString()))
|
||||
.map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
|
||||
});
|
||||
}
|
||||
|
||||
@GetMapping("/messages/{messageId}/attachments")
|
||||
@@ -246,7 +228,7 @@ public class MailController {
|
||||
}
|
||||
|
||||
/** Only the last path segment, no control characters — a file name cannot leave the attachments folder. */
|
||||
static String safeFilename(String name) {
|
||||
public static String safeFilename(String name) {
|
||||
String base = name == null ? "" : name.replace('\\', '/');
|
||||
base = base.substring(base.lastIndexOf('/') + 1).replaceAll("[\\p{Cntrl}\"]", "").trim();
|
||||
if (base.isEmpty() || base.equals(".") || base.equals("..")) {
|
||||
|
||||
@@ -24,6 +24,9 @@ public class Folder {
|
||||
@Column("unread_count")
|
||||
private Integer unreadCount = 0;
|
||||
|
||||
@Column("is_system")
|
||||
private boolean system;
|
||||
|
||||
@Column("created_at")
|
||||
private Instant createdAt;
|
||||
|
||||
@@ -42,6 +45,9 @@ public class Folder {
|
||||
public Integer getUnreadCount() { return unreadCount; }
|
||||
public void setUnreadCount(Integer unreadCount) { this.unreadCount = unreadCount; }
|
||||
|
||||
public boolean isSystem() { return system; }
|
||||
public void setSystem(boolean system) { this.system = system; }
|
||||
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ public class Message {
|
||||
@Column("recipient_email")
|
||||
private String recipientEmail;
|
||||
|
||||
@Column("cc_email")
|
||||
private String ccEmail;
|
||||
|
||||
@Column("subject")
|
||||
private String subject;
|
||||
|
||||
@@ -66,6 +69,9 @@ public class Message {
|
||||
public String getRecipientEmail() { return recipientEmail; }
|
||||
public void setRecipientEmail(String recipientEmail) { this.recipientEmail = recipientEmail; }
|
||||
|
||||
public String getCcEmail() { return ccEmail; }
|
||||
public void setCcEmail(String ccEmail) { this.ccEmail = ccEmail; }
|
||||
|
||||
public String getSubject() { return subject; }
|
||||
public void setSubject(String subject) { this.subject = subject; }
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.mailserver.dto;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/** A draft: everything optional, addresses are not checked until it is sent. */
|
||||
public record DraftRequest(@Size(max = 5000) String to, @Size(max = 5000) String cc,
|
||||
@Size(max = 1000) String subject, @Size(max = 1_000_000) String body, boolean html) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.mailserver.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record FolderRequest(@NotBlank @Size(max = 50) String name) {
|
||||
}
|
||||
@@ -1,23 +1,5 @@
|
||||
package com.mailserver.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public class FolderResponse {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private Integer messageCount;
|
||||
private Integer unreadCount;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
|
||||
public Integer getMessageCount() { return messageCount; }
|
||||
public void setMessageCount(Integer messageCount) { this.messageCount = messageCount; }
|
||||
|
||||
public Integer getUnreadCount() { return unreadCount; }
|
||||
public void setUnreadCount(Integer unreadCount) { this.unreadCount = unreadCount; }
|
||||
/** A folder with live counters; system = INBOX, Drafts, Sent, Spam, Trash (cannot be renamed or deleted). */
|
||||
public record FolderResponse(Long id, String name, boolean system, long messageCount, long unreadCount) {
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ public class MessageResponse {
|
||||
private String senderEmail;
|
||||
private String senderName;
|
||||
private String recipientEmail;
|
||||
private String ccEmail;
|
||||
private String subject;
|
||||
private String body;
|
||||
private boolean html;
|
||||
@@ -32,6 +33,9 @@ public class MessageResponse {
|
||||
public String getRecipientEmail() { return recipientEmail; }
|
||||
public void setRecipientEmail(String recipientEmail) { this.recipientEmail = recipientEmail; }
|
||||
|
||||
public String getCcEmail() { return ccEmail; }
|
||||
public void setCcEmail(String ccEmail) { this.ccEmail = ccEmail; }
|
||||
|
||||
public String getSubject() { return subject; }
|
||||
public void setSubject(String subject) { this.subject = subject; }
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.mailserver.dto;
|
||||
|
||||
/** Partial update of a message: only the fields that are set change. */
|
||||
public record MessageUpdateRequest(Boolean read, Boolean flagged, Long folderId) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.mailserver.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record PasswordChangeRequest(@NotBlank String currentPassword, @NotBlank @Size(min = 8, max = 100) String newPassword) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.mailserver.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.mailserver.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record ProfileUpdateRequest(@NotBlank @Size(max = 100) String displayName) {
|
||||
}
|
||||
@@ -12,7 +12,7 @@ public class RegisterRequest {
|
||||
private String email;
|
||||
|
||||
@NotBlank
|
||||
@Size(min = 6, max = 100)
|
||||
@Size(min = 8, max = 100)
|
||||
private String password;
|
||||
|
||||
@Size(max = 100)
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
package com.mailserver.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* "Send" from the web client. {@code to} and {@code cc}: one or more addresses separated by commas, semicolons or
|
||||
* spaces. {@code draftId}: the draft being sent — its attachments go with the message, the draft is removed.
|
||||
*/
|
||||
public class SendRequest {
|
||||
|
||||
@NotBlank
|
||||
@Email
|
||||
@Size(max = 5000)
|
||||
private String to;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 5000)
|
||||
private String cc;
|
||||
|
||||
@Size(max = 1000)
|
||||
private String subject;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 1_000_000)
|
||||
private String body;
|
||||
|
||||
private boolean html = false;
|
||||
|
||||
private Long draftId;
|
||||
|
||||
public String getTo() { return to; }
|
||||
public void setTo(String to) { this.to = to; }
|
||||
|
||||
public String getSubject() { return subject; }
|
||||
public String getCc() { return cc; }
|
||||
public void setCc(String cc) { this.cc = cc; }
|
||||
|
||||
public String getSubject() { return subject == null ? "" : subject; }
|
||||
public void setSubject(String subject) { this.subject = subject; }
|
||||
|
||||
public String getBody() { return body; }
|
||||
public String getBody() { return body == null ? "" : body; }
|
||||
public void setBody(String body) { this.body = body; }
|
||||
|
||||
public boolean isHtml() { return html; }
|
||||
public void setHtml(boolean html) { this.html = html; }
|
||||
|
||||
public Long getDraftId() { return draftId; }
|
||||
public void setDraftId(Long draftId) { this.draftId = draftId; }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
package com.mailserver.repository;
|
||||
|
||||
import com.mailserver.domain.Attachment;
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface AttachmentRepository extends R2dbcRepository<Attachment, Long> {
|
||||
Flux<Attachment> findByMessageId(Long messageId);
|
||||
|
||||
Mono<Long> countByMessageId(Long messageId);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE attachments SET message_id = :#{#to} WHERE message_id = :#{#from}")
|
||||
Mono<Integer> moveAll(Long from, Long to);
|
||||
|
||||
@Query("SELECT COALESCE(SUM(a.size_bytes), 0) FROM attachments a JOIN messages m ON m.id = a.message_id " +
|
||||
"JOIN folders f ON f.id = m.folder_id WHERE f.user_id = :#{#userId}")
|
||||
Mono<Long> sumSizeByUserId(Long userId);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.mailserver.repository;
|
||||
|
||||
import com.mailserver.domain.Message;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
@@ -9,19 +10,33 @@ import reactor.core.publisher.Mono;
|
||||
|
||||
public interface MessageRepository extends R2dbcRepository<Message, Long> {
|
||||
|
||||
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} ORDER BY received_at DESC LIMIT :#{#limit}")
|
||||
Flux<Message> findLatest(Long folderId, int limit);
|
||||
|
||||
Flux<Message> findByFolderId(Long folderId);
|
||||
|
||||
/** IMAP (switched off): the whole folder, newest first. */
|
||||
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} ORDER BY received_at DESC")
|
||||
Flux<Message> findByFolderId(Long folderId, Pageable pageable);
|
||||
|
||||
@Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId}")
|
||||
Mono<Long> countByFolderId(Long folderId);
|
||||
|
||||
@Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId} AND is_read = false")
|
||||
Mono<Long> countUnreadByFolderId(Long folderId);
|
||||
|
||||
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND is_read = false ORDER BY received_at DESC")
|
||||
Flux<Message> findUnreadByFolderId(Long folderId, Pageable pageable);
|
||||
@Modifying
|
||||
@Query("UPDATE messages SET folder_id = :#{#to} WHERE folder_id = :#{#from}")
|
||||
Mono<Integer> moveAll(Long from, Long to);
|
||||
|
||||
@Query("SELECT COALESCE(SUM(m.message_size), 0) FROM messages m JOIN folders f ON f.id = m.folder_id WHERE f.user_id = :#{#userId}")
|
||||
Mono<Long> sumSizeByUserId(Long userId);
|
||||
|
||||
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND " +
|
||||
"(LOWER(subject) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
|
||||
"LOWER(sender_email) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
|
||||
"LOWER(sender_name) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
|
||||
"LOWER(recipient_email) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
|
||||
"LOWER(body) LIKE '%' || LOWER(:#{#keyword}) || '%') " +
|
||||
"ORDER BY received_at DESC")
|
||||
Flux<Message> searchInFolder(Long folderId, String keyword, Pageable pageable);
|
||||
|
||||
@@ -9,6 +9,7 @@ import jakarta.mail.Part;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import jakarta.mail.internet.MimeUtility;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -18,6 +19,8 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
@@ -71,7 +74,61 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
|
||||
dkim.signed() ? (dkim.passed() ? "pass " + dkim.domain() : "fail") : "none", spf, dmarc, spam ? " → spam" : "");
|
||||
}
|
||||
|
||||
mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam).block();
|
||||
com.mailserver.domain.Message stored =
|
||||
mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam,
|
||||
addresses(mime, jakarta.mail.Message.RecipientType.TO),
|
||||
addresses(mime, jakarta.mail.Message.RecipientType.CC)).block();
|
||||
if (stored != null) {
|
||||
for (FilePart file : attachments(mime)) {
|
||||
mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "a@x, b@y" from a header, or null. A broken header is ignored, not a reason to lose the message. */
|
||||
static String addresses(MimeMessage mime, jakarta.mail.Message.RecipientType type) {
|
||||
try {
|
||||
jakarta.mail.Address[] list = mime.getRecipients(type);
|
||||
if (list == null || list.length == 0) {
|
||||
return null;
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
for (jakarta.mail.Address address : list) {
|
||||
result.add(address instanceof InternetAddress internet ? internet.getAddress() : address.toString());
|
||||
}
|
||||
String joined = String.join(", ", result);
|
||||
return joined.length() > 5000 ? joined.substring(0, 5000) : joined;
|
||||
} catch (MessagingException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
record FilePart(String name, String contentType, byte[] bytes) {
|
||||
}
|
||||
|
||||
/** Parts with a file name (attachments and inline images), nested multiparts walked. */
|
||||
static List<FilePart> attachments(Part part) throws MessagingException, IOException {
|
||||
List<FilePart> files = new ArrayList<>();
|
||||
collect(part, files);
|
||||
return files;
|
||||
}
|
||||
|
||||
private static void collect(Part part, List<FilePart> files) throws MessagingException, IOException {
|
||||
if (part.isMimeType("multipart/*") && part.getContent() instanceof Multipart multipart) {
|
||||
for (int i = 0; i < multipart.getCount(); i++) {
|
||||
collect(multipart.getBodyPart(i), files);
|
||||
}
|
||||
return;
|
||||
}
|
||||
String name = part.getFileName();
|
||||
if (name == null && !Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) {
|
||||
return;
|
||||
}
|
||||
String decoded = name == null ? "file" : MimeUtility.decodeText(name);
|
||||
String type = part.getContentType() == null ? "application/octet-stream" : part.getContentType().split(";")[0].trim();
|
||||
try (InputStream in = part.getInputStream()) {
|
||||
files.add(new FilePart(decoded, type.toLowerCase(java.util.Locale.ROOT), in.readAllBytes()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-delivery report into the sender's inbox — only when the sender is our mailbox. */
|
||||
@@ -101,7 +158,7 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
|
||||
}
|
||||
|
||||
private static String find(Part part, String type) throws MessagingException, IOException {
|
||||
if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) {
|
||||
if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition()) || part.getFileName() != null) {
|
||||
return null;
|
||||
}
|
||||
if (part.isMimeType(type)) {
|
||||
|
||||
@@ -6,6 +6,9 @@ import com.mailserver.domain.Folder;
|
||||
import com.mailserver.domain.Message;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.dto.AttachmentResponse;
|
||||
import com.mailserver.dto.DraftRequest;
|
||||
import com.mailserver.dto.FolderResponse;
|
||||
import com.mailserver.dto.MessageUpdateRequest;
|
||||
import com.mailserver.dto.MessageResponse;
|
||||
import com.mailserver.repository.AttachmentRepository;
|
||||
import com.mailserver.repository.ContactRepository;
|
||||
@@ -30,6 +33,8 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Locale;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
@@ -63,16 +68,114 @@ public class MailService {
|
||||
this.spamFilterService = spamFilterService;
|
||||
}
|
||||
|
||||
/** System folders in the order the web client shows them; the rest — own folders, by name. */
|
||||
public static final List<String> SYSTEM_FOLDERS = List.of("INBOX", "Drafts", "Sent", "Spam", "Trash");
|
||||
|
||||
/** Messages per folder in one list — the web client sorts and pages them itself. */
|
||||
public static final int LIST_LIMIT = 1000;
|
||||
|
||||
private static final int PREVIEW_CHARS = 200;
|
||||
|
||||
// --- Folders ---
|
||||
|
||||
public Flux<FolderResponse> folders(Long userId) {
|
||||
return folderRepository.findByUserId(userId)
|
||||
.flatMap(folder -> Mono.zip(messageRepository.countByFolderId(folder.getId()),
|
||||
messageRepository.countUnreadByFolderId(folder.getId()))
|
||||
.map(counts -> new FolderResponse(folder.getId(), folder.getName(), folder.isSystem(),
|
||||
counts.getT1(), counts.getT2())))
|
||||
.sort(Comparator.comparingInt(MailService::folderOrder)
|
||||
.thenComparing(folder -> folder.name().toLowerCase(Locale.ROOT)));
|
||||
}
|
||||
|
||||
private static int folderOrder(FolderResponse folder) {
|
||||
int index = SYSTEM_FOLDERS.indexOf(folder.name());
|
||||
return folder.system() && index >= 0 ? index : SYSTEM_FOLDERS.size();
|
||||
}
|
||||
|
||||
public Mono<FolderResponse> createFolder(Long userId, String rawName) {
|
||||
String name = folderName(rawName);
|
||||
return checkFolderNameFree(userId, name, null)
|
||||
.then(Mono.defer(() -> {
|
||||
Folder folder = new Folder();
|
||||
folder.setUserId(userId);
|
||||
folder.setName(name);
|
||||
folder.setCreatedAt(Instant.now());
|
||||
return folderRepository.save(folder);
|
||||
}))
|
||||
.map(folder -> new FolderResponse(folder.getId(), folder.getName(), false, 0, 0));
|
||||
}
|
||||
|
||||
public Mono<Void> renameFolder(Long folderId, Long userId, String rawName) {
|
||||
String name = folderName(rawName);
|
||||
return ownFolder(folderId, userId)
|
||||
.flatMap(folder -> checkFolderNameFree(userId, name, folder.getId())
|
||||
.then(Mono.defer(() -> {
|
||||
folder.setName(name);
|
||||
return folderRepository.save(folder);
|
||||
})))
|
||||
.then();
|
||||
}
|
||||
|
||||
/** An own folder: its messages go to the Trash, then the folder is removed. */
|
||||
public Mono<Void> deleteFolder(Long folderId, Long userId) {
|
||||
return ownFolder(folderId, userId)
|
||||
.flatMap(folder -> systemFolder(userId, "Trash")
|
||||
.flatMap(trash -> messageRepository.moveAll(folder.getId(), trash.getId()))
|
||||
.then(folderRepository.delete(folder)));
|
||||
}
|
||||
|
||||
/** Trash and Spam only: removes every message for good. */
|
||||
public Mono<Integer> emptyFolder(Long folderId, Long userId) {
|
||||
return ownedFolder(folderId, userId)
|
||||
.filter(folder -> folder.isSystem() && ("Trash".equals(folder.getName()) || "Spam".equals(folder.getName())))
|
||||
.switchIfEmpty(Mono.error(badRequest("Only Trash and Spam can be emptied")))
|
||||
.flatMapMany(folder -> messageRepository.findByFolderId(folder.getId()))
|
||||
.concatMap(message -> purge(message).thenReturn(1))
|
||||
.reduce(0, Integer::sum);
|
||||
}
|
||||
|
||||
private static String folderName(String rawName) {
|
||||
String name = rawName == null ? "" : rawName.trim().replaceAll("\\s+", " ");
|
||||
if (name.isEmpty() || name.length() > 50) {
|
||||
throw badRequest("Folder name: 1 to 50 characters");
|
||||
}
|
||||
if (SYSTEM_FOLDERS.stream().anyMatch(name::equalsIgnoreCase)) {
|
||||
throw badRequest("This name belongs to a system folder");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private Mono<Void> checkFolderNameFree(Long userId, String name, Long exceptId) {
|
||||
return folderRepository.findByUserId(userId)
|
||||
.filter(folder -> folder.getName().equalsIgnoreCase(name) && !folder.getId().equals(exceptId))
|
||||
.hasElements()
|
||||
.flatMap(taken -> taken ? Mono.error(badRequest("A folder with this name already exists")) : Mono.empty());
|
||||
}
|
||||
|
||||
private Mono<Folder> ownFolder(Long folderId, Long userId) {
|
||||
return ownedFolder(folderId, userId)
|
||||
.filter(folder -> !folder.isSystem())
|
||||
.switchIfEmpty(Mono.error(badRequest("System folders cannot be renamed or deleted")));
|
||||
}
|
||||
|
||||
private Mono<Folder> systemFolder(Long userId, String name) {
|
||||
return folderRepository.findByUserIdAndName(userId, name)
|
||||
.switchIfEmpty(Mono.error(new IllegalStateException(name + " folder not found")));
|
||||
}
|
||||
|
||||
// --- Messages ---
|
||||
|
||||
/** Copy of a message we sent, in the sender's "Sent" folder. */
|
||||
public Mono<MessageResponse> saveSent(User fromUser, String to, String subject, String body, boolean html, String messageId) {
|
||||
return folderRepository.findByUserIdAndName(fromUser.getId(), "Sent")
|
||||
.switchIfEmpty(Mono.error(new IllegalStateException("Sent folder not found")))
|
||||
public Mono<Message> saveSent(User fromUser, String to, String cc, String subject, String body, boolean html, String messageId) {
|
||||
return systemFolder(fromUser.getId(), "Sent")
|
||||
.flatMap(sentFolder -> {
|
||||
Message message = new Message();
|
||||
message.setFolderId(sentFolder.getId());
|
||||
message.setSenderEmail(fromUser.getEmail());
|
||||
message.setSenderName(fromUser.getDisplayName());
|
||||
message.setRecipientEmail(to);
|
||||
message.setCcEmail(cc == null || cc.isBlank() ? null : cc);
|
||||
message.setSubject(subject);
|
||||
message.setBody(body);
|
||||
message.setHtml(html);
|
||||
@@ -81,59 +184,111 @@ public class MailService {
|
||||
message.setMessageId(messageId);
|
||||
message.setMessageSize((long) (subject.length() + body.length()));
|
||||
return messageRepository.save(message);
|
||||
})
|
||||
.map(this::toResponse);
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||
/** Creates or updates a draft of the user (only a message in their Drafts folder can be updated). */
|
||||
public Mono<MessageResponse> saveDraft(User user, Long draftId, DraftRequest request) {
|
||||
Mono<Message> target = draftId == null
|
||||
? systemFolder(user.getId(), "Drafts").map(drafts -> {
|
||||
Message message = new Message();
|
||||
message.setFolderId(drafts.getId());
|
||||
message.setRead(true);
|
||||
message.setMessageId(UUID.randomUUID().toString());
|
||||
return message;
|
||||
})
|
||||
: ownedDraft(draftId, user.getId());
|
||||
return target.flatMap(message -> {
|
||||
String subject = request.subject() == null ? "" : request.subject();
|
||||
String body = request.body() == null ? "" : request.body();
|
||||
message.setSenderEmail(user.getEmail());
|
||||
message.setSenderName(user.getDisplayName());
|
||||
message.setRecipientEmail(request.to() == null ? "" : request.to().trim());
|
||||
message.setCcEmail(request.cc() == null || request.cc().isBlank() ? null : request.cc().trim());
|
||||
message.setSubject(subject);
|
||||
message.setBody(body);
|
||||
message.setHtml(request.html());
|
||||
message.setReceivedAt(Instant.now());
|
||||
message.setMessageSize((long) (subject.length() + body.length()));
|
||||
return messageRepository.save(message);
|
||||
})
|
||||
.flatMap(this::withAttachmentCount);
|
||||
}
|
||||
|
||||
/** The user's draft, or 404. */
|
||||
public Mono<Message> ownedDraft(Long draftId, Long userId) {
|
||||
return ownedMessage(draftId, userId)
|
||||
.filterWhen(message -> folderRepository.findById(message.getFolderId())
|
||||
.map(folder -> folder.isSystem() && "Drafts".equals(folder.getName())))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Draft not found")));
|
||||
}
|
||||
|
||||
/** After sending: the draft's attachments move to the copy in Sent, the draft itself is removed. */
|
||||
public Mono<Void> finishDraft(Long draftId, Long sentMessageId) {
|
||||
return attachmentRepository.moveAll(draftId, sentMessageId)
|
||||
.then(messageRepository.deleteById(draftId));
|
||||
}
|
||||
|
||||
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||
String subject, String body, boolean isHtml) {
|
||||
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, false);
|
||||
}
|
||||
|
||||
public Mono<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||
String subject, String body, boolean isHtml, boolean externalSpamFlag) {
|
||||
return userRepository.findByEmail(recipientEmail)
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail)))
|
||||
.flatMap(recipient ->
|
||||
folderRepository.findByUserIdAndName(recipient.getId(), "INBOX")
|
||||
.switchIfEmpty(Mono.error(new IllegalStateException("INBOX not found")))
|
||||
.flatMap(inbox ->
|
||||
spamFilterService.isBlacklisted(recipient.getId(), senderEmail)
|
||||
.flatMap(isBlacklisted -> {
|
||||
boolean isSpam = isBlacklisted || externalSpamFlag;
|
||||
Message message = new Message();
|
||||
message.setFolderId(inbox.getId());
|
||||
message.setSenderEmail(senderEmail);
|
||||
message.setSenderName(senderName);
|
||||
message.setRecipientEmail(recipientEmail);
|
||||
message.setSubject(subject);
|
||||
message.setBody(body);
|
||||
message.setHtml(isHtml);
|
||||
message.setRead(false);
|
||||
message.setSpam(isSpam);
|
||||
message.setReceivedAt(Instant.now());
|
||||
message.setMessageId(UUID.randomUUID().toString());
|
||||
message.setMessageSize((long) (subject.length() + body.length()));
|
||||
return messageRepository.save(message)
|
||||
.then(folderRepository.save(inbox));
|
||||
})
|
||||
)
|
||||
)
|
||||
.then();
|
||||
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, externalSpamFlag, null, null);
|
||||
}
|
||||
|
||||
public Flux<MessageResponse> getMessages(Long folderId, Long userId, int page, int size) {
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||
/**
|
||||
* Stores an incoming message: into INBOX, or into Spam when it failed the checks or the sender is blacklisted.
|
||||
* {@code toHeader}/{@code ccHeader}: addresses from the message headers (shown, used by "reply all");
|
||||
* without them — the mailbox itself.
|
||||
*/
|
||||
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||
String subject, String body, boolean isHtml, boolean externalSpamFlag,
|
||||
String toHeader, String ccHeader) {
|
||||
return userRepository.findByEmail(recipientEmail)
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail)))
|
||||
.flatMap(recipient -> spamFilterService.isBlacklisted(recipient.getId(), senderEmail)
|
||||
.flatMap(blacklisted -> {
|
||||
boolean isSpam = blacklisted || externalSpamFlag;
|
||||
return systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX")
|
||||
.flatMap(folder -> {
|
||||
Message message = new Message();
|
||||
message.setFolderId(folder.getId());
|
||||
message.setSenderEmail(senderEmail);
|
||||
message.setSenderName(senderName);
|
||||
message.setRecipientEmail(toHeader == null || toHeader.isBlank() ? recipientEmail : toHeader);
|
||||
message.setCcEmail(ccHeader == null || ccHeader.isBlank() ? null : ccHeader);
|
||||
message.setSubject(subject);
|
||||
message.setBody(body);
|
||||
message.setHtml(isHtml);
|
||||
message.setRead(false);
|
||||
message.setSpam(isSpam);
|
||||
message.setReceivedAt(Instant.now());
|
||||
message.setMessageId(UUID.randomUUID().toString());
|
||||
message.setMessageSize((long) (subject.length() + body.length()));
|
||||
return messageRepository.save(message);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/** Newest first; bodies cut to a preview — the full message comes from {@link #getMessage}. */
|
||||
public Flux<MessageResponse> listMessages(Long folderId, Long userId, int limit) {
|
||||
int safeLimit = Math.max(1, Math.min(limit, LIST_LIMIT));
|
||||
return ownedFolder(folderId, userId)
|
||||
.flatMapMany(folder -> messageRepository.findByFolderId(folder.getId(), pageable))
|
||||
.map(this::toResponse);
|
||||
.flatMapMany(folder -> messageRepository.findLatest(folder.getId(), safeLimit))
|
||||
.concatMap(this::withAttachmentCount)
|
||||
.map(MailService::preview);
|
||||
}
|
||||
|
||||
public Flux<MessageResponse> searchMessages(Long folderId, Long userId, String keyword, int page, int size) {
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||
return ownedFolder(folderId, userId)
|
||||
.flatMapMany(folder -> messageRepository.searchInFolder(folder.getId(), keyword, pageable))
|
||||
.map(this::toResponse);
|
||||
.take(LIST_LIMIT)
|
||||
.concatMap(this::withAttachmentCount)
|
||||
.map(MailService::preview);
|
||||
}
|
||||
|
||||
public Flux<MessageResponse> searchMessagesWithFilters(Long folderId, Long userId,
|
||||
@@ -143,23 +298,74 @@ public class MailService {
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||
return ownedFolder(folderId, userId)
|
||||
.flatMapMany(folder -> messageRepository.searchWithFilters(folder.getId(), subject, body, sender, recipient, pageable))
|
||||
.map(this::toResponse);
|
||||
.take(LIST_LIMIT)
|
||||
.concatMap(this::withAttachmentCount)
|
||||
.map(MailService::preview);
|
||||
}
|
||||
|
||||
public Mono<MessageResponse> getMessage(Long messageId, Long userId) {
|
||||
return ownedMessage(messageId, userId).map(this::toResponse);
|
||||
return ownedMessage(messageId, userId).flatMap(this::withAttachmentCount);
|
||||
}
|
||||
|
||||
public Mono<Void> markAsRead(Long messageId, Long userId) {
|
||||
return ownedMessage(messageId, userId)
|
||||
.flatMap(message -> {
|
||||
message.setRead(true);
|
||||
return messageRepository.save(message).then();
|
||||
});
|
||||
return updateMessage(messageId, userId, new MessageUpdateRequest(true, null, null)).then();
|
||||
}
|
||||
|
||||
/** Read/unread, flag, move to another folder of the user (moving into Spam marks it as spam, out of it — not). */
|
||||
public Mono<MessageResponse> updateMessage(Long messageId, Long userId, MessageUpdateRequest update) {
|
||||
return ownedMessage(messageId, userId)
|
||||
.flatMap(message -> {
|
||||
if (update.read() != null) {
|
||||
message.setRead(update.read());
|
||||
}
|
||||
if (update.flagged() != null) {
|
||||
message.setFlagged(update.flagged());
|
||||
}
|
||||
if (update.folderId() == null || update.folderId().equals(message.getFolderId())) {
|
||||
return messageRepository.save(message);
|
||||
}
|
||||
return ownedFolder(update.folderId(), userId).flatMap(target -> {
|
||||
message.setFolderId(target.getId());
|
||||
message.setSpam(target.isSystem() && "Spam".equals(target.getName()));
|
||||
return messageRepository.save(message);
|
||||
});
|
||||
})
|
||||
.flatMap(this::withAttachmentCount);
|
||||
}
|
||||
|
||||
/** Into the Trash; a message already in the Trash is removed for good (with its attachment files). */
|
||||
public Mono<Void> deleteMessage(Long messageId, Long userId) {
|
||||
return ownedMessage(messageId, userId).flatMap(messageRepository::delete);
|
||||
return ownedMessage(messageId, userId)
|
||||
.flatMap(message -> folderRepository.findById(message.getFolderId())
|
||||
.flatMap(folder -> {
|
||||
if (folder.isSystem() && "Trash".equals(folder.getName())) {
|
||||
return purge(message);
|
||||
}
|
||||
return systemFolder(userId, "Trash").flatMap(trash -> {
|
||||
message.setFolderId(trash.getId());
|
||||
return messageRepository.save(message).then();
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
private Mono<Void> purge(Message message) {
|
||||
return attachmentRepository.findByMessageId(message.getId())
|
||||
.doOnNext(attachment -> deleteFile(attachment.getFilePath()))
|
||||
.then(messageRepository.delete(message));
|
||||
}
|
||||
|
||||
private static void deleteFile(String path) {
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get(path));
|
||||
} catch (IOException e) {
|
||||
log.warn("Attachment file {} not deleted: {}", path, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Bytes used by the mailbox: message texts plus attachments. */
|
||||
public Mono<Long> usedBytes(Long userId) {
|
||||
return Mono.zip(messageRepository.sumSizeByUserId(userId), attachmentRepository.sumSizeByUserId(userId))
|
||||
.map(sizes -> sizes.getT1() + sizes.getT2());
|
||||
}
|
||||
|
||||
/** The folder, when it belongs to the user; otherwise 404 — never someone else's mail. */
|
||||
@@ -184,12 +390,40 @@ public class MailService {
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found")));
|
||||
}
|
||||
|
||||
private Mono<MessageResponse> withAttachmentCount(Message message) {
|
||||
return attachmentRepository.countByMessageId(message.getId())
|
||||
.defaultIfEmpty(0L)
|
||||
.map(count -> {
|
||||
MessageResponse response = toResponse(message);
|
||||
response.setAttachmentCount(count.intValue());
|
||||
response.setHasAttachments(count > 0);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private static MessageResponse preview(MessageResponse response) {
|
||||
String body = response.getBody();
|
||||
if (body == null) {
|
||||
return response;
|
||||
}
|
||||
String text = response.isHtml() ? body.replaceAll("(?s)<style.*?</style>|<[^>]+>", " ") : body;
|
||||
text = text.replace(" ", " ").replaceAll("\\s+", " ").trim();
|
||||
response.setBody(text.length() > PREVIEW_CHARS ? text.substring(0, PREVIEW_CHARS) + "…" : text);
|
||||
response.setHtml(false);
|
||||
return response;
|
||||
}
|
||||
|
||||
static ResponseStatusException badRequest(String reason) {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, reason);
|
||||
}
|
||||
|
||||
private MessageResponse toResponse(Message message) {
|
||||
MessageResponse resp = new MessageResponse();
|
||||
resp.setId(message.getId());
|
||||
resp.setSenderEmail(message.getSenderEmail());
|
||||
resp.setSenderName(message.getSenderName());
|
||||
resp.setRecipientEmail(message.getRecipientEmail());
|
||||
resp.setCcEmail(message.getCcEmail());
|
||||
resp.setSubject(message.getSubject());
|
||||
resp.setBody(message.getBody());
|
||||
resp.setHtml(message.isHtml());
|
||||
@@ -202,10 +436,30 @@ public class MailService {
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** Stores a file of an incoming message (blocking: called from the SMTP worker thread). */
|
||||
public void storeIncomingAttachment(Long messageId, String filename, String contentType, byte[] bytes) throws IOException {
|
||||
String safeName = com.mailserver.controller.MailController.safeFilename(filename);
|
||||
Path path = Paths.get(attachmentsDir, UUID.randomUUID() + "_" + safeName);
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.write(path, bytes);
|
||||
Attachment attachment = new Attachment();
|
||||
attachment.setMessageId(messageId);
|
||||
attachment.setFilename(safeName);
|
||||
attachment.setContentType(contentType);
|
||||
attachment.setSizeBytes((long) bytes.length);
|
||||
attachment.setFilePath(path.toString());
|
||||
attachmentRepository.save(attachment).block();
|
||||
}
|
||||
|
||||
/** Attachments of the user's draft, for building the message to send. */
|
||||
public Flux<Attachment> draftAttachments(Long draftId, Long userId) {
|
||||
return ownedDraft(draftId, userId).flatMapMany(draft -> attachmentRepository.findByMessageId(draft.getId()));
|
||||
}
|
||||
|
||||
public Mono<AttachmentResponse> saveAttachmentReactive(Long messageId, Long userId,
|
||||
String originalFilename, String contentType,
|
||||
long sizeBytes, String filePath) {
|
||||
return ownedMessage(messageId, userId)
|
||||
return ownedDraft(messageId, userId)
|
||||
.flatMap(message -> {
|
||||
Attachment attachment = new Attachment();
|
||||
attachment.setMessageId(messageId);
|
||||
@@ -236,11 +490,7 @@ public class MailService {
|
||||
public Mono<Void> deleteAttachment(Long attachmentId, Long userId) {
|
||||
return ownedAttachment(attachmentId, userId)
|
||||
.flatMap(attachment -> {
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get(attachment.getFilePath()));
|
||||
} catch (IOException e) {
|
||||
log.warn("Attachment file {} not deleted: {}", attachment.getFilePath(), e.getMessage());
|
||||
}
|
||||
deleteFile(attachment.getFilePath());
|
||||
return attachmentRepository.delete(attachment);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,32 +1,51 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import com.mailserver.domain.Attachment;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.dto.MessageResponse;
|
||||
import com.mailserver.dto.SendRequest;
|
||||
import com.mailserver.repository.UserRepository;
|
||||
import com.mailserver.smtp.MailRouter;
|
||||
import com.mailserver.smtp.SmtpBackend;
|
||||
import jakarta.mail.Message;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.AddressException;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeBodyPart;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import jakarta.mail.internet.MimeMultipart;
|
||||
import jakarta.mail.internet.MimeUtility;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** "Send" in the web client: builds the MIME message and routes it like an authenticated SMTP submission. */
|
||||
/**
|
||||
* "Send" in the web client: builds the MIME message (with the draft's attachments) and routes it like an
|
||||
* authenticated SMTP submission — to every To and Cc address.
|
||||
*/
|
||||
@Service
|
||||
public class SubmissionService {
|
||||
|
||||
/** One message, at most this many addresses (To + Cc). */
|
||||
static final int MAX_RECIPIENTS = 50;
|
||||
|
||||
private final UserRepository users;
|
||||
private final MailService mailService;
|
||||
private final MailRouter router;
|
||||
@@ -41,34 +60,109 @@ public class SubmissionService {
|
||||
}
|
||||
|
||||
public Mono<MessageResponse> send(String fromEmail, SendRequest request) {
|
||||
String to = request.getTo().trim().toLowerCase(Locale.ROOT);
|
||||
List<String> to = addresses(request.getTo());
|
||||
List<String> cc = addresses(request.getCc());
|
||||
cc.removeAll(to);
|
||||
if (to.isEmpty()) {
|
||||
return Mono.error(MailService.badRequest("No recipient"));
|
||||
}
|
||||
if (to.size() + cc.size() > MAX_RECIPIENTS) {
|
||||
return Mono.error(MailService.badRequest("At most " + MAX_RECIPIENTS + " recipients"));
|
||||
}
|
||||
Long draftId = request.getDraftId();
|
||||
return users.findByEmail(fromEmail)
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
|
||||
.flatMap(user -> Mono.fromCallable(() -> {
|
||||
SmtpBackend.Route route = router.route(to, user.getEmail());
|
||||
switch (route) {
|
||||
case LOCAL, OUTBOUND -> { }
|
||||
case UNKNOWN_USER -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No such mailbox: " + to);
|
||||
case OUTBOUND_DISABLED -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Sending outside is disabled on this server");
|
||||
case RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay");
|
||||
}
|
||||
String messageId = UUID.randomUUID() + "@" + hostname;
|
||||
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()));
|
||||
mime.setFrom(new InternetAddress(user.getEmail(), user.getDisplayName(), "UTF-8"));
|
||||
mime.setRecipient(Message.RecipientType.TO, new InternetAddress(to));
|
||||
mime.setSubject(request.getSubject(), "UTF-8");
|
||||
mime.setText(request.getBody(), "UTF-8", request.isHtml() ? "html" : "plain");
|
||||
mime.setSentDate(new Date());
|
||||
mime.saveChanges();
|
||||
mime.setHeader("Message-ID", "<" + messageId + ">");
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
mime.writeTo(bytes);
|
||||
router.accept(new SmtpBackend.Envelope("127.0.0.1", hostname, user.getEmail(), user.getEmail(),
|
||||
List.of(new SmtpBackend.Recipient(to, route))), bytes.toByteArray());
|
||||
return messageId;
|
||||
}).subscribeOn(Schedulers.boundedElastic())
|
||||
.flatMap(messageId -> mailService.saveSent(user, to, request.getSubject(), request.getBody(),
|
||||
request.isHtml(), messageId)));
|
||||
.flatMap(user -> (draftId == null ? Flux.<Attachment>empty() : mailService.draftAttachments(draftId, user.getId()))
|
||||
.collectList()
|
||||
.flatMap(attachments -> Mono.fromCallable(() -> submit(user, to, cc, request, attachments))
|
||||
.subscribeOn(Schedulers.boundedElastic()))
|
||||
.flatMap(messageId -> mailService.saveSent(user, String.join(", ", to), String.join(", ", cc),
|
||||
request.getSubject(), request.getBody(), request.isHtml(), messageId))
|
||||
.flatMap(sent -> (draftId == null ? Mono.<Void>empty() : mailService.finishDraft(draftId, sent.getId()))
|
||||
.then(mailService.getMessage(sent.getId(), user.getId()))));
|
||||
}
|
||||
|
||||
private String submit(User user, List<String> to, List<String> cc, SendRequest request, List<Attachment> attachments)
|
||||
throws Exception {
|
||||
List<SmtpBackend.Recipient> recipients = new ArrayList<>();
|
||||
for (String address : concat(to, cc)) {
|
||||
SmtpBackend.Route route = router.route(address, user.getEmail());
|
||||
switch (route) {
|
||||
case LOCAL, OUTBOUND -> recipients.add(new SmtpBackend.Recipient(address, route));
|
||||
case UNKNOWN_USER -> throw MailService.badRequest("No such mailbox: " + address);
|
||||
case OUTBOUND_DISABLED -> throw MailService.badRequest("Sending outside is disabled on this server: " + address);
|
||||
case RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay");
|
||||
}
|
||||
}
|
||||
String messageId = UUID.randomUUID() + "@" + hostname;
|
||||
MimeMessage mime = mime(user, to, cc, request, attachments);
|
||||
mime.setHeader("Message-ID", "<" + messageId + ">");
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
mime.writeTo(bytes);
|
||||
router.accept(new SmtpBackend.Envelope("127.0.0.1", hostname, user.getEmail(), user.getEmail(), recipients),
|
||||
bytes.toByteArray());
|
||||
return messageId;
|
||||
}
|
||||
|
||||
static MimeMessage mime(User user, List<String> to, List<String> cc, SendRequest request, List<Attachment> attachments)
|
||||
throws MessagingException, IOException {
|
||||
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()));
|
||||
mime.setFrom(new InternetAddress(user.getEmail(), user.getDisplayName(), "UTF-8"));
|
||||
mime.setRecipients(Message.RecipientType.TO, InternetAddress.parse(String.join(",", to)));
|
||||
if (!cc.isEmpty()) {
|
||||
mime.setRecipients(Message.RecipientType.CC, InternetAddress.parse(String.join(",", cc)));
|
||||
}
|
||||
mime.setSubject(request.getSubject(), "UTF-8");
|
||||
String subtype = request.isHtml() ? "html" : "plain";
|
||||
if (attachments.isEmpty()) {
|
||||
mime.setText(request.getBody(), "UTF-8", subtype);
|
||||
} else {
|
||||
MimeMultipart mixed = new MimeMultipart("mixed");
|
||||
MimeBodyPart text = new MimeBodyPart();
|
||||
text.setText(request.getBody(), "UTF-8", subtype);
|
||||
mixed.addBodyPart(text);
|
||||
for (Attachment attachment : attachments) {
|
||||
MimeBodyPart file = new MimeBodyPart();
|
||||
file.attachFile(new File(attachment.getFilePath()),
|
||||
attachment.getContentType() == null ? "application/octet-stream" : attachment.getContentType(), null);
|
||||
file.setFileName(MimeUtility.encodeText(attachment.getFilename(), "UTF-8", null));
|
||||
mixed.addBodyPart(file);
|
||||
}
|
||||
mime.setContent(mixed);
|
||||
}
|
||||
mime.setSentDate(new Date());
|
||||
mime.saveChanges();
|
||||
return mime;
|
||||
}
|
||||
|
||||
/** "a@x, b@y; c@z" → lower-case addresses without repeats; a malformed one → 400. */
|
||||
static List<String> addresses(String raw) {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
if (raw == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
for (String part : raw.split("[,;\\s]+")) {
|
||||
String address = part.trim().toLowerCase(Locale.ROOT);
|
||||
if (address.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
new InternetAddress(address, true).validate();
|
||||
} catch (AddressException e) {
|
||||
throw MailService.badRequest("Invalid address: " + address);
|
||||
}
|
||||
if (!address.contains("@")) {
|
||||
throw MailService.badRequest("Invalid address: " + address);
|
||||
}
|
||||
result.add(address);
|
||||
}
|
||||
return new ArrayList<>(result);
|
||||
}
|
||||
|
||||
private static List<String> concat(List<String> a, List<String> b) {
|
||||
List<String> all = new ArrayList<>(a);
|
||||
all.addAll(b);
|
||||
return all;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.mailserver.service;
|
||||
|
||||
import com.mailserver.domain.Folder;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.dto.FolderResponse;
|
||||
import com.mailserver.repository.FolderRepository;
|
||||
import com.mailserver.repository.UserRepository;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
@@ -35,18 +34,18 @@ public class UserService {
|
||||
User user = new User();
|
||||
user.setEmail(email);
|
||||
user.setPassword(passwordEncoder.encode(password));
|
||||
user.setDisplayName(displayName);
|
||||
user.setDisplayName(displayName == null || displayName.isBlank() ? email.substring(0, email.indexOf('@')) : displayName.trim());
|
||||
user.setCreatedAt(Instant.now());
|
||||
return userRepository.save(user);
|
||||
})
|
||||
.flatMap(user -> {
|
||||
// Создаём стандартные папки
|
||||
String[] defaultFolders = {"INBOX", "Sent", "Drafts", "Trash", "Spam"};
|
||||
return Flux.fromArray(defaultFolders)
|
||||
.flatMap(name -> {
|
||||
return Flux.fromIterable(MailService.SYSTEM_FOLDERS)
|
||||
.concatMap(name -> {
|
||||
Folder folder = new Folder();
|
||||
folder.setUserId(user.getId());
|
||||
folder.setName(name);
|
||||
folder.setSystem(true);
|
||||
folder.setCreatedAt(Instant.now());
|
||||
return folderRepository.save(folder);
|
||||
})
|
||||
@@ -66,15 +65,17 @@ public class UserService {
|
||||
return passwordEncoder.matches(rawPassword, user.getPassword());
|
||||
}
|
||||
|
||||
public Flux<FolderResponse> getFolders(Long userId) {
|
||||
return folderRepository.findByUserId(userId)
|
||||
.map(f -> {
|
||||
FolderResponse resp = new FolderResponse();
|
||||
resp.setId(f.getId());
|
||||
resp.setName(f.getName());
|
||||
resp.setMessageCount(f.getMessageCount());
|
||||
resp.setUnreadCount(f.getUnreadCount());
|
||||
return resp;
|
||||
});
|
||||
/** New password, only with the current one. */
|
||||
public Mono<Void> changePassword(User user, String currentPassword, String newPassword) {
|
||||
if (!checkPassword(user, currentPassword)) {
|
||||
return Mono.error(MailService.badRequest("Current password is wrong"));
|
||||
}
|
||||
user.setPassword(passwordEncoder.encode(newPassword));
|
||||
return userRepository.save(user).then();
|
||||
}
|
||||
|
||||
public Mono<User> updateDisplayName(User user, String displayName) {
|
||||
user.setDisplayName(displayName.trim());
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user