Безопасность, SMTP, очередь отправки, DKIM, деплой на Jambotron (Jambotron v0.2.3 75.1–75.3)

- SMTP-сервер переписан: разбор по строкам, AUTH PLAIN/LOGIN, нет открытого релея,
  MAIL FROM только свой после входа, 250 только после сохранения (иначе 451), лимит размера
- отправка наружу: очередь outbound_queue (V2) с повторами ~22 ч и уведомлением о недоставке;
  Jakarta Mail, режимы off / mx / smarthost, байты письма не меняются (DKIM)
- DKIM: подпись utils-mail-dkim, проверка Apache jDKIM; ключ PKCS#8, старый PKCS#1 читается
- права: письма, папки, вложения — только владельцу (было: любой по номеру); имена вложений без ../
- секреты без значений по умолчанию (DB_PASSWORD, JWT_SECRET), регистрация закрыта,
  admin123 и /api/simulate/receive — только профиль dev
- веб-клиент внутри jar (относительный /api), Dockerfile в 3 этапа, не root, health;
  docker-compose.jambotron.yml: секреты создаются при первом запуске, наружу только 8080
- gradle-wrapper.jar был в .gitignore — сборка не запускалась
- тесты: 26 (SMTP реальным клиентом и сокетом, GreenMail, DKIM подпись+проверка, очередь)

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jambotron α
2026-11-05 03:37:43 +01:00
parent b6f6d41813
commit 165edf1a1e
44 changed files with 2626 additions and 1715 deletions
@@ -16,6 +16,10 @@ 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.PathVariable;
@@ -46,10 +50,18 @@ 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) {
public MailController(UserService userService, MailService mailService, SubmissionService submissionService,
MailDomains domains,
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled) {
this.userService = userService;
this.mailService = mailService;
this.submissionService = submissionService;
this.domains = domains;
this.registrationEnabled = registrationEnabled;
}
private Mono<String> getCurrentUserEmail(Mono<Authentication> authenticationMono) {
@@ -59,9 +71,15 @@ public class MailController {
// --- Users ---
@PostMapping("/register")
public Mono<?> register(@Valid @RequestBody Mono<RegisterRequest> requestMono) {
return requestMono.flatMap(request ->
userService.register(request.getEmail(), request.getPassword(), request.getDisplayName())
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 ->
@@ -124,25 +142,15 @@ public class MailController {
}
@PostMapping("/send")
public Mono<Object> sendMessage(
public Mono<MessageResponse> sendMessage(
Mono<Authentication> authenticationMono,
@Valid @RequestBody Mono<SendRequest> requestMono) {
return authenticationMono.zipWith(requestMono)
.flatMap(pair -> {
String email = pair.getT1().getName();
SendRequest request = pair.getT2();
return mailService.sendMessage(email, request)
.map(response -> (Object) org.springframework.http.ResponseEntity.ok(response))
.onErrorResume(e -> {
log.error("Ошибка при отправке письма: {}", e.getMessage(), e);
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage())));
});
});
.flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2()));
}
@PutMapping("/messages/{id}/read")
public Mono<?> markAsRead(@PathVariable Long id, Mono<Authentication> authenticationMono) {
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())
@@ -151,7 +159,7 @@ public class MailController {
}
@DeleteMapping("/messages/{id}")
public Mono<?> deleteMessage(@PathVariable Long id, Mono<Authentication> authenticationMono) {
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())
@@ -159,29 +167,10 @@ public class MailController {
);
}
// --- Simulate incoming message (для тестов) ---
@PostMapping("/simulate/receive")
public Mono<?> simulateReceive(@RequestBody Mono<Map<String, String>> bodyMono) {
return bodyMono.flatMap(body ->
mailService.receiveMessage(
body.get("senderEmail"),
body.get("senderName"),
body.get("recipientEmail"),
body.get("subject"),
body.get("body"),
"true".equals(body.get("html"))
)
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Message received"))))
.onErrorResume(e ->
Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))))
);
}
// --- Attachments ---
@PostMapping(value = "/messages/{messageId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Mono<?> uploadAttachment(
public Mono<ResponseEntity<?>> uploadAttachment(
@PathVariable Long messageId,
Mono<Authentication> authenticationMono,
@RequestBody Mono<Flux<FilePart>> filePartsMono) {
@@ -192,7 +181,7 @@ public class MailController {
.flatMapMany(user -> pair.getT2())
.next()
.flatMap(file -> {
String originalFilename = file.filename();
String originalFilename = safeFilename(file.filename());
String uniqueName = UUID.randomUUID() + "_" + originalFilename;
Path filePath = Paths.get(mailService.getAttachmentsDir(), uniqueName);
try {
@@ -214,7 +203,8 @@ public class MailController {
return userService.findByEmail(email)
.flatMap(user -> mailService.saveAttachmentReactive(
messageId, user.getId(),
originalFilename, file.headers().getContentType().toString(),
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));
});
@@ -233,39 +223,40 @@ public class MailController {
}
@GetMapping("/attachments/{attachmentId}/download")
public Mono<Object> downloadAttachment(
public Mono<ResponseEntity<byte[]>> downloadAttachment(
@PathVariable Long attachmentId,
Mono<Authentication> authenticationMono) {
return authenticationMono.flatMap(auth ->
userService.findByEmail(auth.getName())
.flatMap(user -> {
try {
AttachmentResponse info = mailService.getAttachmentInfo(attachmentId, user.getId());
Path filePath = Paths.get(mailService.getAttachmentFilePath(attachmentId, user.getId()));
byte[] bytes;
try {
bytes = Files.readAllBytes(filePath);
} catch (IOException e) {
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest()
.body(Map.of("error", "Failed to read file: " + e.getMessage())));
}
DataBuffer buffer = new DefaultDataBufferFactory().wrap(bytes);
return Mono.just((Object) org.springframework.http.ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + info.getFilename() + "\"")
.contentType(MediaType.parseMediaType(
info.getContentType() != null ? info.getContentType() : "application/octet-stream"))
.body(buffer)
);
} catch (IllegalArgumentException e) {
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())));
}
})
);
return authenticationMono.flatMap(auth -> userService.findByEmail(auth.getName()))
.flatMap(user -> mailService.getAttachment(attachmentId, user.getId()))
.flatMap(attachment -> Mono.fromCallable(() -> Files.readAllBytes(Paths.get(attachment.getFilePath())))
.subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic())
.map(bytes -> ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
.filename(attachment.getFilename(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
.contentType(contentType(attachment.getContentType()))
.body(bytes)));
}
private static MediaType contentType(String value) {
try {
return value == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(value);
} catch (org.springframework.util.InvalidMimeTypeException e) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
/** Only the last path segment, no control characters — a file name cannot leave the attachments folder. */
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("..")) {
return "file";
}
return base.length() > 200 ? base.substring(base.length() - 200) : base;
}
@DeleteMapping("/attachments/{attachmentId}")
public Mono<?> deleteAttachment(
public Mono<ResponseEntity<?>> deleteAttachment(
@PathVariable Long attachmentId,
Mono<Authentication> authenticationMono) {
return authenticationMono.flatMap(auth ->
@@ -333,7 +324,7 @@ public class MailController {
}
@DeleteMapping("/contacts/{id}")
public Mono<?> deleteContact(
public Mono<ResponseEntity<?>> deleteContact(
@PathVariable Long id,
Mono<Authentication> authenticationMono) {
return authenticationMono.flatMap(auth ->