Рассылки, папки по +метке, ловушка только для чужих адресов, служебное API для Jambotron
continuous-integration/drone/pr Build is passing

- V6 mail_lists + mail_list_members: письмо на alerts-prod@ → каждому участнику своя копия в папку рассылки;
  закрытая рассылка — только от вошедших отправителей (550 снаружи)
- bob+prod@ → ящик bob@, папка prod (создаётся сама; системные папки нельзя)
- TRAP: чужие адреса → одна копия в INBOX отправителя; наши ящики и рассылки доставляются
- /api/service/** (SERVICE_TOKEN из secrets-init): аккаунты (пароль один раз, выключение → вон из рассылок), рассылки
- MailDirectory: одно пространство адресов для ящиков и рассылок, без «+» в именах
- Настройки → LISTS для администраторов
- 86 тестов; живой прогон на Postgres 17: миграции V1–V6, API, SMTP (рассылка/метка/ловушка)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 14:15:24 +01:00
co-authored by Claude Opus 5.5
parent 3603b9d315
commit cf84797e68
28 changed files with 1013 additions and 71 deletions
@@ -44,6 +44,24 @@ public class MailDomains {
return domains;
}
/**
* Address without its "+tag": {@code bob+prod@x} → {@code bob@x}. Mailbox and list names never contain "+",
* so the part after it is a label chosen by the sender — the folder the message goes to.
*/
public static String baseOf(String address) {
String lower = address.toLowerCase(Locale.ROOT);
int at = lower.lastIndexOf('@');
int plus = lower.indexOf('+');
return plus > 0 && plus < at ? lower.substring(0, plus) + lower.substring(at) : lower;
}
/** The "+tag" of an address, or null: {@code bob+prod@x} → {@code prod}. */
public static String tagOf(String address) {
int at = address.lastIndexOf('@');
int plus = address.indexOf('+');
return plus > 0 && plus < at - 1 ? address.substring(plus + 1, at) : null;
}
public static String domainOf(String address) {
int at = address == null ? -1 : address.lastIndexOf('@');
return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT);
@@ -52,6 +52,8 @@ public class SecurityConfig {
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeExchange(exchange -> exchange
.pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll()
// service API for Jambotron: its own token, checked by ServiceController (not a mailbox login)
.pathMatchers("/api/service/**").permitAll()
.pathMatchers("/api/**", "/actuator/**").authenticated()
// the web client (static files, SPA routes)
.anyExchange().permitAll()
@@ -4,6 +4,8 @@ import com.mailserver.config.MailAdmins;
import com.mailserver.config.MailDomains;
import com.mailserver.domain.User;
import com.mailserver.dto.LoginResponse;
import com.mailserver.service.MailDirectory;
import com.mailserver.service.MailLists;
import com.mailserver.service.MailService;
import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils;
@@ -13,6 +15,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -25,12 +28,9 @@ import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.Comparator;
import java.util.Locale;
import java.util.regex.Pattern;
import java.util.List;
/**
* Mailboxes of this server for its administrators ({@link MailAdmins}): list, create (no-reply@, monitor@,
@@ -42,8 +42,7 @@ import java.util.regex.Pattern;
public class MailboxAdminController {
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
private static final Pattern LOCAL_PART = Pattern.compile("[a-z0-9](?:[a-z0-9._+-]{0,62}[a-z0-9])?");
private static final int MIN_PASSWORD = 8;
private static final int MIN_PASSWORD = MailDirectory.MIN_PASSWORD;
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap,
long usedBytes, long quotaBytes, Instant createdAt) {
@@ -72,9 +71,14 @@ public class MailboxAdminController {
private final MailDomains domains;
private final MailAdmins admins;
private final JwtUtils jwt;
private final MailDirectory directory;
private final MailLists lists;
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt) {
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt,
MailDirectory directory, MailLists lists) {
this.jwt = jwt;
this.directory = directory;
this.lists = lists;
this.users = users;
this.mail = mail;
this.domains = domains;
@@ -92,22 +96,13 @@ public class MailboxAdminController {
@ResponseStatus(HttpStatus.CREATED)
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
return admin(auth).then(body).flatMap(request -> {
String email = request.email() == null ? "" : request.email().trim().toLowerCase(Locale.ROOT);
int at = email.lastIndexOf('@');
if (at <= 0 || !LOCAL_PART.matcher(email.substring(0, at)).matches()) {
return Mono.error(bad("Not a mailbox address: " + email));
}
if (!domains.isLocal(email)) {
return Mono.error(bad("Mailboxes can be created only in " + String.join(", ", domains.domains().stream().sorted().toList())));
}
String chosen = request.password() == null ? "" : request.password();
if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) {
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one"));
}
String password = chosen.isEmpty() ? randomPassword() : chosen;
return users.register(email, password, request.displayName())
.onErrorMap(IllegalArgumentException.class, e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists"))
.doOnNext(user -> log.info("Mailbox created by administrator: {}", email))
return directory.createMailbox(request.email(), request.displayName(), password)
.doOnNext(user -> log.info("Mailbox created by administrator: {}", user.getEmail()))
.flatMap(this::view)
.map(view -> new Created(view, chosen.isEmpty() ? password : null));
});
@@ -166,6 +161,35 @@ public class MailboxAdminController {
}))).flatMap(this::view);
}
// --- Mailing lists (Settings → LISTS) ---
public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside,
List<String> members) {
}
@GetMapping("/lists")
public Flux<MailLists.MailList> lists(Mono<Authentication> auth) {
return admin(auth).thenMany(lists.all());
}
/** Creates or replaces the list {@code address} (settings + the exact member set). */
@PutMapping("/lists/{address}")
public Mono<MailLists.MailList> saveList(Mono<Authentication> auth, @PathVariable String address,
@RequestBody Mono<ListRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request ->
directory.saveList(address, request.folder(), request.description(), request.openToOutside(), request.members())
.doOnNext(list -> log.info("List {} saved by administrator {}: {} members", list.address(), me, list.members().size()))));
}
@DeleteMapping("/lists/{address}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> deleteList(Mono<Authentication> auth, @PathVariable String address) {
return admin(auth).flatMap(me -> lists.delete(address)
.flatMap(deleted -> deleted ? Mono.<Void>empty()
: Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "List not found")))
.doOnSuccess(v -> log.info("List {} deleted by administrator {}", address, me)));
}
private Mono<String> admin(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.map(Authentication::getName)
@@ -189,8 +213,6 @@ public class MailboxAdminController {
}
static String randomPassword() {
byte[] bytes = new byte[18];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
return MailDirectory.randomPassword();
}
}
@@ -0,0 +1,162 @@
package com.mailserver.controller;
import com.mailserver.domain.User;
import com.mailserver.service.MailDirectory;
import com.mailserver.service.MailLists;
import com.mailserver.service.UserService;
import jakarta.validation.constraints.Size;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
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;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
/**
* Service API for Jambotron (staff mailboxes): token {@code SERVICE_TOKEN} in {@code Authorization: Bearer …}.
* Without the token configured the API is off (404). Accounts: create (password returned ONCE), switch on/off,
* new password; lists: the exact settings and members, Jambotron is the source of truth for the lists it keeps.
*/
@RestController
@RequestMapping("/api/service")
public class ServiceController {
private static final Logger log = LoggerFactory.getLogger(ServiceController.class);
/** @param displayName null — unchanged (new account: the name before @) */
public record AccountRequest(@Size(max = 100) String displayName, Boolean active) {
}
public record AccountView(String email, String displayName, boolean active, List<String> lists) {
}
/** @param password generated, only when the account was just created or got a new password */
public record AccountResult(AccountView account, String password) {
}
public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside,
List<String> members) {
}
private final byte[] token;
private final UserService users;
private final MailLists lists;
private final MailDirectory directory;
public ServiceController(@Value("${mailserver.service-token:}") String token, UserService users, MailLists lists,
MailDirectory directory) {
this.token = token.isBlank() ? null : token.trim().getBytes(StandardCharsets.UTF_8);
this.users = users;
this.lists = lists;
this.directory = directory;
}
@GetMapping("/accounts/{email}")
public Mono<AccountView> account(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String email) {
check(auth);
return users.findOptional(email)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox")))
.flatMap(this::view);
}
/** Creates the account (201 + password once) or updates name / on-off (200). Switched off → out of every list. */
@PutMapping("/accounts/{email}")
public Mono<ResponseEntity<AccountResult>> saveAccount(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String email, @RequestBody Mono<AccountRequest> body) {
check(auth);
String address = directory.address(email);
return body.defaultIfEmpty(new AccountRequest(null, null)).flatMap(request -> users.findOptional(address)
.flatMap(user -> update(user, request).flatMap(this::view)
.map(view -> ResponseEntity.ok(new AccountResult(view, null))))
.switchIfEmpty(Mono.defer(() -> {
String password = MailDirectory.randomPassword();
return directory.createMailbox(address, request.displayName(), password)
.flatMap(user -> Boolean.FALSE.equals(request.active()) ? update(user, request) : Mono.just(user))
.doOnNext(user -> log.info("Mailbox {} created by Jambotron", user.getEmail()))
.flatMap(this::view)
.map(view -> ResponseEntity.status(HttpStatus.CREATED).body(new AccountResult(view, password)));
})));
}
/** New generated password, returned once (Jambotron profile → «new mail password»). */
@PostMapping("/accounts/{email}/password")
public Mono<AccountResult> password(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String email) {
check(auth);
String password = MailDirectory.randomPassword();
return users.findOptional(email)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox")))
.flatMap(user -> users.setPassword(user, password))
.doOnNext(user -> log.info("Password of {} set by Jambotron", user.getEmail()))
.flatMap(this::view)
.map(view -> new AccountResult(view, password));
}
@GetMapping("/lists")
public Flux<MailLists.MailList> lists(@RequestHeader(value = "Authorization", required = false) String auth) {
check(auth);
return lists.all();
}
@PutMapping("/lists/{address}")
public Mono<MailLists.MailList> saveList(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String address, @RequestBody Mono<ListRequest> body) {
check(auth);
return body.flatMap(request -> directory.saveList(address, request.folder(), request.description(),
request.openToOutside(), request.members()))
.doOnNext(list -> log.info("List {} saved by Jambotron: {}", list.address(), list.members()));
}
@DeleteMapping("/lists/{address}")
public Mono<ResponseEntity<Void>> deleteList(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String address) {
check(auth);
return lists.delete(address).map(deleted -> deleted
? ResponseEntity.noContent().<Void>build() : ResponseEntity.notFound().<Void>build());
}
private Mono<User> update(User user, AccountRequest request) {
Mono<User> named = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user)
: users.updateDisplayName(user, request.displayName());
if (request.active() == null || request.active() == user.isActive()) {
return named;
}
boolean on = request.active();
return named.flatMap(u -> users.setActive(u, on))
.flatMap(u -> on ? Mono.just(u) : lists.removeEverywhere(u.getId()).thenReturn(u))
.doOnNext(u -> log.info("Mailbox {} {} by Jambotron", u.getEmail(), on ? "switched on" : "switched off"));
}
private Mono<AccountView> view(User user) {
return lists.listsOf(user.getId()).collectList()
.map(in -> new AccountView(user.getEmail(), user.getDisplayName(), user.isActive(), in));
}
/** 404 when the API is off, 401 for a wrong token (constant-time compare). */
private void check(String auth) {
if (token == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
byte[] given = auth != null && auth.startsWith("Bearer ")
? auth.substring(7).trim().getBytes(StandardCharsets.UTF_8) : new byte[0];
if (!MessageDigest.isEqual(token, given)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
}
}
@@ -54,6 +54,12 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
/** Blocking: call from a worker thread. */
public void deliver(byte[] raw, String mailbox, String remoteIp, String envelopeFrom, boolean trusted)
throws MessagingException, IOException {
deliver(raw, mailbox, null, remoteIp, envelopeFrom, trusted);
}
/** @param folder own folder of the mailbox ("+tag", mailing list), created when missing; null — INBOX */
public void deliver(byte[] raw, String mailbox, String folder, String remoteIp, String envelopeFrom, boolean trusted)
throws MessagingException, IOException {
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw));
String senderEmail = envelopeFrom;
@@ -80,7 +86,7 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
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();
addresses(mime, jakarta.mail.Message.RecipientType.CC), folder).block();
if (stored != null) {
for (FilePart file : attachments(mime)) {
mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes());
@@ -0,0 +1,107 @@
package com.mailserver.service;
import com.mailserver.config.MailDomains;
import com.mailserver.domain.User;
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 java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Mailboxes and mailing lists share one address space: one rule for both, used by Settings (administrators)
* and by the service API (Jambotron). Names never contain "+" — the part after it is a folder label.
*/
@Service
public class MailDirectory {
private static final Pattern LOCAL_PART = Pattern.compile("[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?");
private static final Pattern FOLDER = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,49}");
public static final int MIN_PASSWORD = 8;
private final UserService users;
private final MailLists lists;
private final MailDomains domains;
public MailDirectory(UserService users, MailLists lists, MailDomains domains) {
this.users = users;
this.lists = lists;
this.domains = domains;
}
/** Lower-case address of our domain with an allowed name, or 400. */
public String address(String raw) {
String email = raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT);
int at = email.lastIndexOf('@');
if (at <= 0 || !LOCAL_PART.matcher(email.substring(0, at)).matches()) {
throw bad("Not a mailbox address: " + email + " (letters, digits, . _ - ; no +)");
}
if (!domains.isLocal(email)) {
throw bad("Only in " + String.join(", ", domains.domains().stream().sorted().toList()));
}
return email;
}
/** New mailbox; the address must not be taken by a mailbox or a list. 409 when taken. */
public Mono<User> createMailbox(String rawEmail, String displayName, String password) {
String email = address(rawEmail);
if (password == null || password.length() < MIN_PASSWORD) {
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters"));
}
return lists.exists(email).flatMap(isList -> isList
? Mono.<User>error(new ResponseStatusException(HttpStatus.CONFLICT, "This address is a mailing list"))
: users.register(email, password, displayName)
.onErrorMap(IllegalArgumentException.class,
e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists")));
}
/**
* Creates or replaces a list. Members: mailboxes of this server (addresses); unknown ones → 400 naming them.
*/
public Mono<MailLists.MailList> saveList(String rawAddress, String rawFolder, String description,
boolean openToOutside, List<String> members) {
String address = address(rawAddress);
String folder = rawFolder == null || rawFolder.isBlank() ? null : rawFolder.trim();
if (folder != null && (!FOLDER.matcher(folder).matches()
|| MailService.SYSTEM_FOLDERS.stream().anyMatch(folder::equalsIgnoreCase))) {
return Mono.error(bad("Folder: letters, digits, . _ - (up to 50), not a system folder; empty — INBOX"));
}
String about = description == null || description.isBlank() ? null : description.trim();
if (about != null && about.length() > 255) {
return Mono.error(bad("Description: up to 255 characters"));
}
List<String> wanted = members == null ? List.of()
: members.stream().map(m -> m.trim().toLowerCase(Locale.ROOT)).filter(m -> !m.isEmpty()).distinct().toList();
return users.findOptional(address).hasElement().flatMap(isMailbox -> {
if (isMailbox) {
return Mono.error(new ResponseStatusException(HttpStatus.CONFLICT, "This address is a mailbox"));
}
List<String> unknown = new ArrayList<>();
return Flux.fromIterable(wanted)
.concatMap(member -> users.findOptional(member)
.map(User::getId)
.switchIfEmpty(Mono.fromRunnable(() -> unknown.add(member))))
.collectList()
.flatMap(ids -> unknown.isEmpty()
? lists.save(address, folder, about, openToOutside, ids)
: Mono.error(bad("No such mailbox: " + String.join(", ", unknown))));
});
}
public static String randomPassword() {
byte[] bytes = new byte[18];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
public static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
}
@@ -0,0 +1,101 @@
package com.mailserver.service;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
/**
* Mailing lists (V6): {@code alerts-prod@} → every member gets their own copy in the list's folder.
* Kept by the administrators (Settings → LISTS) and by Jambotron through the service API.
*/
@Service
public class MailLists {
/** @param folder where members get the copy; null — INBOX */
public record MailList(Long id, String address, String folder, String description, boolean openToOutside,
List<String> members) {
}
private final DatabaseClient db;
public MailLists(DatabaseClient db) {
this.db = db;
}
public Mono<MailList> find(String address) {
return db.sql("SELECT id, address, folder, description, open_to_outside FROM mail_lists WHERE address = :a")
.bind("a", address.toLowerCase(Locale.ROOT))
.map((row, meta) -> new MailList(row.get("id", Long.class), row.get("address", String.class),
row.get("folder", String.class), row.get("description", String.class),
Boolean.TRUE.equals(row.get("open_to_outside", Boolean.class)), List.of()))
.one()
.flatMap(this::withMembers);
}
public Mono<Boolean> exists(String address) {
return db.sql("SELECT 1 FROM mail_lists WHERE address = :a").bind("a", address.toLowerCase(Locale.ROOT))
.map((row, meta) -> true).one().defaultIfEmpty(false);
}
public Flux<MailList> all() {
return db.sql("SELECT id, address, folder, description, open_to_outside FROM mail_lists ORDER BY address")
.map((row, meta) -> new MailList(row.get("id", Long.class), row.get("address", String.class),
row.get("folder", String.class), row.get("description", String.class),
Boolean.TRUE.equals(row.get("open_to_outside", Boolean.class)), List.of()))
.all()
.concatMap(this::withMembers);
}
/** Lists this mailbox is a member of (shown to the mailbox owner). */
public Flux<String> listsOf(Long userId) {
return db.sql("SELECT l.address FROM mail_lists l JOIN mail_list_members m ON m.list_id = l.id "
+ "WHERE m.user_id = :u ORDER BY l.address")
.bind("u", userId).map((row, meta) -> row.get("address", String.class)).all();
}
/**
* Creates or replaces the list: settings and the exact member set. Members are mailbox ids (checked by the caller).
*/
@Transactional
public Mono<MailList> save(String address, String folder, String description, boolean openToOutside,
Collection<Long> memberIds) {
String a = address.toLowerCase(Locale.ROOT);
var insert = db.sql("INSERT INTO mail_lists (address, folder, description, open_to_outside) "
+ "VALUES (:a, :f, :d, :o) ON CONFLICT (address) DO UPDATE "
+ "SET folder = EXCLUDED.folder, description = EXCLUDED.description, "
+ "open_to_outside = EXCLUDED.open_to_outside RETURNING id")
.bind("a", a).bind("o", openToOutside);
insert = folder == null ? insert.bindNull("f", String.class) : insert.bind("f", folder);
insert = description == null ? insert.bindNull("d", String.class) : insert.bind("d", description);
return insert.map((row, meta) -> row.get("id", Long.class)).one()
.flatMap(id -> db.sql("DELETE FROM mail_list_members WHERE list_id = :l").bind("l", id).then()
.thenMany(Flux.fromIterable(memberIds).distinct()
.concatMap(member -> db.sql("INSERT INTO mail_list_members (list_id, user_id) VALUES (:l, :u)")
.bind("l", id).bind("u", member).then()))
.then(find(a)));
}
public Mono<Boolean> delete(String address) {
return db.sql("DELETE FROM mail_lists WHERE address = :a").bind("a", address.toLowerCase(Locale.ROOT))
.fetch().rowsUpdated().map(n -> n > 0);
}
/** Takes the mailbox out of every list (mailbox switched off / role removed in Jambotron). */
public Mono<Long> removeEverywhere(Long userId) {
return db.sql("DELETE FROM mail_list_members WHERE user_id = :u").bind("u", userId).fetch().rowsUpdated();
}
private Mono<MailList> withMembers(MailList list) {
return db.sql("SELECT u.email FROM mail_list_members m JOIN users u ON u.id = m.user_id "
+ "WHERE m.list_id = :l ORDER BY u.email")
.bind("l", list.id()).map((row, meta) -> row.get("email", String.class)).all().collectList()
.map(members -> new MailList(list.id(), list.address(), list.folder(), list.description(),
list.openToOutside(), members));
}
}
@@ -162,6 +162,34 @@ public class MailService {
.switchIfEmpty(Mono.error(badRequest("System folders cannot be renamed or deleted")));
}
private static final java.util.regex.Pattern DELIVERY_FOLDER = java.util.regex.Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,49}");
/**
* A "+tag" / list folder that may be used as a folder name, or null (→ INBOX). A sender never puts mail into
* a system folder (Spam, Trash, Sent …) this way.
*/
static String deliveryFolderName(String raw) {
if (raw == null || !DELIVERY_FOLDER.matcher(raw).matches() || SYSTEM_FOLDERS.stream().anyMatch(raw::equalsIgnoreCase)) {
return null;
}
return raw;
}
/** The folder with this name (any case), created as an own folder when missing; two at once — one wins. */
private Mono<Folder> deliveryFolder(Long userId, String name) {
Mono<Folder> existing = folderRepository.findByUserId(userId)
.filter(folder -> folder.getName().equalsIgnoreCase(name))
.next();
return existing.switchIfEmpty(Mono.defer(() -> {
Folder folder = new Folder();
folder.setUserId(userId);
folder.setName(name);
folder.setCreatedAt(Instant.now());
return folderRepository.save(folder)
.onErrorResume(org.springframework.dao.DataIntegrityViolationException.class, e -> existing);
}));
}
private Mono<Folder> systemFolder(Long userId, String name) {
return folderRepository.findByUserIdAndName(userId, name)
.switchIfEmpty(Mono.error(new IllegalStateException(name + " folder not found")));
@@ -253,12 +281,25 @@ public class MailService {
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
String subject, String body, boolean isHtml, boolean externalSpamFlag,
String toHeader, String ccHeader) {
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, externalSpamFlag,
toHeader, ccHeader, null);
}
/**
* @param folder own folder for the message ("+tag" of the address, folder of a mailing list), created when
* missing; null or not a valid folder name — INBOX. Spam still goes to Spam.
*/
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
String subject, String body, boolean isHtml, boolean externalSpamFlag,
String toHeader, String ccHeader, String targetFolder) {
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")
String own = isSpam ? null : deliveryFolderName(targetFolder);
return (own == null ? systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX")
: deliveryFolder(recipient.getId(), own))
.flatMap(folder -> {
Message message = new Message();
message.setFolderId(folder.getId());
@@ -88,10 +88,10 @@ public class SubmissionService {
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 LOCAL, OUTBOUND, TRAP -> 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");
case RELAY_DENIED, LIST_CLOSED -> throw new IllegalStateException("logged-in user denied: " + route);
}
}
String messageId = UUID.randomUUID() + "@" + hostname;
@@ -74,6 +74,11 @@ public class UserService {
return userRepository.save(user).then();
}
/** The mailbox, or empty (no error). */
public Mono<User> findOptional(String email) {
return userRepository.findByEmail(email.toLowerCase(java.util.Locale.ROOT));
}
public Flux<User> all() {
return userRepository.findAll();
}
@@ -7,18 +7,25 @@ import com.mailserver.domain.User;
import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailLists;
import com.mailserver.service.UserService;
import org.springframework.stereotype.Service;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Set;
/**
* Where mail goes — one rule for SMTP and for the web client:
* an existing mailbox of our domain → that mailbox; catch-all set → everything else into the catch-all mailbox
* (nothing goes outside, existing mailboxes still get their own mail); otherwise our domain without such a mailbox →
* "no such user", someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).
* A logged-in TRAP mailbox (staging@) sends nowhere: every recipient is accepted and ONE copy is stored in the
* sender's own INBOX — a staging stand uses the real server, but its test mail never reaches people.
* <ul>
* <li>a mailbox of ours → that mailbox; {@code bob+prod@} → bob's folder "prod" (created on first mail);</li>
* <li>a mailing list of ours ({@code alerts-prod@}) → a copy to every member, into the list's folder
* (or the "+tag" folder); a closed list takes mail only from logged-in senders;</li>
* <li>a TRAP sender (staging@) and an address that is not ours → nothing leaves: one copy into the sender's INBOX
* (our own mailboxes and lists still get their mail);</li>
* <li>catch-all set → everything else into the catch-all mailbox; otherwise our domain → "no such user",
* someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).</li>
* </ul>
*/
@Service
public class MailRouter implements SmtpBackend {
@@ -30,9 +37,10 @@ public class MailRouter implements SmtpBackend {
private final OutboundQueue queue;
private final DkimService dkim;
private final SmtpDeliveryClient client;
private final MailLists lists;
public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery,
OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client) {
OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client, MailLists lists) {
this.domains = domains;
this.users = users;
this.userService = userService;
@@ -40,6 +48,7 @@ public class MailRouter implements SmtpBackend {
this.queue = queue;
this.dkim = dkim;
this.client = client;
this.lists = lists;
}
@Override
@@ -50,11 +59,18 @@ public class MailRouter implements SmtpBackend {
@Override
public Route route(String recipient, String authenticatedUser) {
if (isTrap(authenticatedUser)) {
return Route.LOCAL;
if (domains.isLocal(recipient)) {
String base = MailDomains.baseOf(recipient);
if (mailboxExists(recipient) || mailboxExists(base)) {
return Route.LOCAL;
}
MailLists.MailList list = lists.find(base).block();
if (list != null) {
return list.openToOutside() || authenticatedUser != null ? Route.LOCAL : Route.LIST_CLOSED;
}
}
if (domains.isLocal(recipient) && mailboxExists(recipient)) {
return Route.LOCAL;
if (isTrap(authenticatedUser)) {
return Route.TRAP;
}
if (domains.catchAll() != null) {
return Route.LOCAL;
@@ -71,18 +87,13 @@ public class MailRouter implements SmtpBackend {
@Override
public void accept(Envelope envelope, byte[] message) throws Exception {
boolean trusted = envelope.authenticatedUser() != null;
if (isTrap(envelope.authenticatedUser())) {
localDelivery.deliver(message, envelope.authenticatedUser().toLowerCase(Locale.ROOT), envelope.remoteIp(),
envelope.mailFrom(), true);
return;
}
// one copy per mailbox + folder, even when an address and a list both lead there
Set<Target> targets = new LinkedHashSet<>();
byte[] signed = null;
for (Recipient recipient : envelope.recipients()) {
switch (recipient.route()) {
case LOCAL -> {
String mailbox = mailbox(recipient.address());
localDelivery.deliver(message, mailbox, envelope.remoteIp(), envelope.mailFrom(), trusted);
}
case LOCAL -> targets.addAll(targets(recipient.address()));
case TRAP -> targets.add(new Target(envelope.authenticatedUser().toLowerCase(Locale.ROOT), null));
case OUTBOUND -> {
if (signed == null) {
signed = dkim.sign(message);
@@ -92,17 +103,50 @@ public class MailRouter implements SmtpBackend {
default -> throw new IllegalStateException("Recipient was not accepted: " + recipient);
}
}
}
/** Mailbox that stores mail to this address: its own if it exists here, otherwise the catch-all one. */
String mailbox(String address) {
if (domains.catchAll() == null || (domains.isLocal(address) && mailboxExists(address))) {
return address;
for (Target target : targets) {
localDelivery.deliver(message, target.mailbox(), target.folder(), envelope.remoteIp(), envelope.mailFrom(), trusted);
}
return domains.catchAll();
}
/** The logged-in sender is an active trap mailbox. */
/** Mailbox + folder (null — INBOX) that store mail to a local address. */
record Target(String mailbox, String folder) {
}
Set<Target> targets(String address) {
Set<Target> result = new LinkedHashSet<>();
String lower = address.toLowerCase(Locale.ROOT);
if (domains.isLocal(lower)) {
if (mailboxExists(lower)) {
result.add(new Target(lower, null));
return result;
}
String base = MailDomains.baseOf(lower);
String tag = MailDomains.tagOf(address);
if (mailboxExists(base)) {
result.add(new Target(base, tag));
return result;
}
MailLists.MailList list = lists.find(base).block();
if (list != null) {
for (String member : list.members()) {
result.add(new Target(member, tag != null ? tag : list.folder()));
}
return result;
}
}
if (domains.catchAll() != null) {
result.add(new Target(domains.catchAll(), null));
}
return result;
}
/** Mailbox that stores mail to this address: its own (without "+tag") if it exists here, otherwise the catch-all one. */
String mailbox(String address) {
Set<Target> found = targets(address);
return found.isEmpty() ? address : found.iterator().next().mailbox();
}
/** The logged-in sender is a trap mailbox. */
boolean isTrap(String authenticatedUser) {
if (authenticatedUser == null) {
return false;
@@ -10,8 +10,12 @@ public interface SmtpBackend {
/** Where a recipient goes, decided at RCPT TO. */
enum Route {
/** A mailbox of ours (or the catch-all mailbox) — store it. */
/** A mailbox or mailing list of ours (or the catch-all mailbox) — store it. */
LOCAL,
/** The sender is a trap mailbox and the address is not ours: one copy into the sender's own INBOX. */
TRAP,
/** A mailing list of ours that takes mail only from logged-in senders. */
LIST_CLOSED,
/** Someone else's address, sender is logged in — queue for delivery. */
OUTBOUND,
/** Address of our domain that does not exist. */
@@ -438,7 +438,7 @@ public class SmtpServer {
String user = authenticatedUser;
return blocking(() -> backend.route(address, user))
.map(route -> switch (route) {
case LOCAL, OUTBOUND -> {
case LOCAL, OUTBOUND, TRAP -> {
recipients.add(new SmtpBackend.Recipient(address, route));
yield "250 2.1.5 Recipient OK\r\n";
}
@@ -448,6 +448,7 @@ public class SmtpServer {
yield "550 5.7.1 Relaying denied: authentication required\r\n";
}
case OUTBOUND_DISABLED -> "550 5.7.1 Outbound delivery is disabled on this server\r\n";
case LIST_CLOSED -> "550 5.7.1 This list takes mail only from logged-in senders\r\n";
});
}