Почтовый кабинет: регистрация, папки, черновики, вложения, грид @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:
2026-11-05 16:41:29 +01:00
parent f605fbfd85
commit 29b1beabe0
70 changed files with 3468 additions and 1737 deletions
@@ -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("&nbsp;", " ").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);
}
}
@@ -0,0 +1,10 @@
-- Mail cabinet: several recipients and copy, system vs own folders
ALTER TABLE messages ALTER COLUMN recipient_email TYPE TEXT;
ALTER TABLE messages ADD COLUMN cc_email TEXT;
ALTER TABLE folders ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE folders SET is_system = TRUE WHERE name IN ('INBOX', 'Sent', 'Drafts', 'Trash', 'Spam');
CREATE UNIQUE INDEX idx_folders_user_name ON folders(user_id, LOWER(name));
-- the attachments entity always had content_id (inline images, cid:) — the column was missing
ALTER TABLE attachments ADD COLUMN content_id VARCHAR(255);