From c765c51cf0fa45334ca0d5f0caa135495d13688f Mon Sep 17 00:00:00 2001 From: Liosha84 Date: Sat, 7 Nov 2026 14:27:11 +0100 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B5=D1=81=D0=BA=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=BA=D0=BE=20=D1=8F=D1=89=D0=B8=D0=BA=D0=BE=D0=B2=20=D1=83=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0,=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B5=20=D1=8F=D1=89?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8,=20=D0=BA=D1=82=D0=BE=20=D0=BE=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=B8=D0=BB,=20=D0=B2=D1=85=D0=BE=D0=B4=20=D1=8F?= =?UTF-8?q?=D1=89=D0=B8=D0=BA*=D0=BB=D0=BE=D0=B3=D0=B8=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V7: users.is_shared, mailbox_access (MEMBER от админа/Jambotron, ATTACHED по паролю ящика), messages.answered_by - /api/mailboxes: мои ящики с непрочитанными, сессия другого ящика (JWT с claim by), подключить по паролю (LoginGuard), убрать из меню - общий ящик: без своего входа (веб/SMTP/IMAP), участники открывают; «прочитано» общее; ответ из веба → answered_by - SMTP/IMAP: ящик*логин + пароль логина (как master user в Dovecot) - открытый чужой ящик (общий, подключённый, OPEN админа): профиль и пароль не меняются (403) - админ: создать shared, участники ящика (PUT /{id}/members); служебное API: PUT /shared/{address}; выключение аккаунта убирает и из общих ящиков - UI: MAILBOXES в левом меню (переключение, ⊕ подключить, × убрать), shared/участники в настройках, «Answered by» у письма - 88 тестов; живой прогон на Postgres 17 (апгрейд V6→V7 на данных), скриншоты UI Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- .../com/mailserver/config/ImapConfig.java | 5 +- .../controller/AuthenticationController.java | 17 ++- .../mailserver/controller/MailController.java | 3 +- .../controller/MailboxAdminController.java | 39 ++++-- .../controller/MailboxesController.java | 130 ++++++++++++++++++ .../controller/ServiceController.java | 25 +++- .../java/com/mailserver/domain/Message.java | 7 + .../main/java/com/mailserver/domain/User.java | 7 + .../com/mailserver/dto/MessageResponse.java | 5 + .../java/com/mailserver/dto/SendRequest.java | 6 + .../java/com/mailserver/imap/DbImapStore.java | 13 +- .../java/com/mailserver/security/Actor.java | 23 ++++ .../security/JwtAuthenticationFilter.java | 2 + .../com/mailserver/service/MailDirectory.java | 27 +++- .../com/mailserver/service/MailService.java | 1 + .../com/mailserver/service/MailboxAccess.java | 129 +++++++++++++++++ .../mailserver/service/SubmissionService.java | 13 +- .../com/mailserver/service/UserService.java | 5 + .../java/com/mailserver/smtp/MailRouter.java | 12 +- .../java/com/mailserver/smtp/SmtpBackend.java | 7 +- .../java/com/mailserver/smtp/SmtpServer.java | 11 +- .../java/com/mailserver/util/JwtUtils.java | 15 ++ .../db/migration/V7__mailbox_access.sql | 19 +++ .../MailboxAdminControllerTest.java | 34 ++++- .../controller/ServiceControllerTest.java | 7 +- .../com/mailserver/security/ActorTest.java | 24 ++++ .../com/mailserver/smtp/MailRouterTest.java | 2 +- .../com/mailserver/smtp/SmtpServerTest.java | 4 +- frontend/src/app/core/api.service.ts | 24 +++- frontend/src/app/core/auth.service.ts | 12 ++ frontend/src/app/core/models.ts | 14 +- .../src/app/pages/compose/compose.page.ts | 5 +- .../src/app/pages/message/message.page.ts | 4 + .../app/pages/settings/mailboxes.section.ts | 58 +++++++- .../src/app/pages/shell/shell.component.ts | 94 ++++++++++++- 36 files changed, 751 insertions(+), 60 deletions(-) create mode 100644 backend/src/main/java/com/mailserver/controller/MailboxesController.java create mode 100644 backend/src/main/java/com/mailserver/security/Actor.java create mode 100644 backend/src/main/java/com/mailserver/service/MailboxAccess.java create mode 100644 backend/src/main/resources/db/migration/V7__mailbox_access.sql create mode 100644 backend/src/test/java/com/mailserver/security/ActorTest.java diff --git a/README.md b/README.md index c04d52b..677cc59 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,13 @@ **Рассылки (LISTS):** Настройки → LISTS. Письмо на адрес рассылки (`alerts-prod@`) → **каждому участнику своя копия** (своё «прочитано») в папку рассылки. Закрытая рассылка (по умолчанию) принимает письма только от вошедших отправителей (Kuma через `monitor@`, Jambotron) — снаружи 550. Адрес рассылки не может совпадать с ящиком. -**Служебное API для Jambotron** (`/api/service/**`, заголовок `Authorization: Bearer `; токен генерирует `secrets-init` в томе секретов — файл `SERVICE_TOKEN`, без токена API выключено): +**Несколько ящиков у одного логина:** слева в меню **MAILBOXES** — свой ящик, общие, в которых ты участник, и подключённые (кнопка ⊕: адрес + пароль того ящика). Клик — переключиться; «×» — убрать из своего меню. + +**Общий ящик** (`support@`, `billing@`): Настройки → MAILBOXES → галочка «shared mailbox» при создании, кнопка `group` — кто может его открывать. Своего пароля и входа у него нет; «прочитано» общее (командный ящик); у письма видно **«Answered by …»** — кто ответил из веб-почты. В профиле открытого чужого ящика пароль/имя не меняются. + +**В почтовой программе** (Thunderbird, телефон) общий или подключённый ящик — логин `ящик*свой_логин` (например `support@jambocheck.com*jambotronpw@jambocheck.com`) и **свой** пароль; так и для IMAP, и для SMTP (письма уходят от имени общего ящика). + +**Служебное API для Jambotron** (`/api/service/**`, заголовок `Authorization: Bearer `; токен генерирует `secrets-init` в томе секретов — файл `SERVICE_TOKEN`, без токена API выключено): Общие ящики: `PUT /shared/{address}` `{displayName, members[]}` — создать при отсутствии, точный состав участников; выключение аккаунта убирает его и из общих ящиков. `PUT /accounts/{email}` `{displayName, active}` — создать (201 + пароль один раз) или изменить; `active:false` — выключить и убрать из всех рассылок · `POST /accounts/{email}/password` — новый пароль один раз · `GET /accounts/{email}` · `GET /lists` · `PUT /lists/{address}` `{folder, description, openToOutside, members[]}` — точный состав · `DELETE /lists/{address}`. **Администратор:** `admin@jambocheck.com`, пароль `eK-6oj3TMtZgoXVxlvtCWFI6` (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь). diff --git a/backend/src/main/java/com/mailserver/config/ImapConfig.java b/backend/src/main/java/com/mailserver/config/ImapConfig.java index 38f4664..e81cdba 100644 --- a/backend/src/main/java/com/mailserver/config/ImapConfig.java +++ b/backend/src/main/java/com/mailserver/config/ImapConfig.java @@ -27,8 +27,9 @@ public class ImapConfig { @Bean public ImapStore imapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService, MessageRepository messages, AttachmentRepository attachments, MessageSources sources, - @Value("${mailserver.smtp.hostname:localhost}") String hostname) { - return new DbImapStore(db, users, userService, mailService, messages, attachments, sources, hostname); + @Value("${mailserver.smtp.hostname:localhost}") String hostname, + com.mailserver.service.MailboxAccess access) { + return new DbImapStore(db, users, userService, mailService, messages, attachments, sources, hostname, access); } @Bean(initMethod = "start", destroyMethod = "stop") diff --git a/backend/src/main/java/com/mailserver/controller/AuthenticationController.java b/backend/src/main/java/com/mailserver/controller/AuthenticationController.java index 9de1c61..0d69910 100644 --- a/backend/src/main/java/com/mailserver/controller/AuthenticationController.java +++ b/backend/src/main/java/com/mailserver/controller/AuthenticationController.java @@ -99,6 +99,10 @@ public class AuthenticationController { return delay.isZero() ? answer : answer.delayElement(delay); } guard.success(ip, email); + if (user.isShared()) { + return Mono.just(ResponseEntity.badRequest().body(Map.of("error", + "This is a shared mailbox: log in with your own mailbox and open it there"))); + } if (!user.isActive()) { return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated"))); } @@ -138,7 +142,7 @@ public class AuthenticationController { @PutMapping("/me") public Mono updateMe(Mono auth, @Valid @RequestBody Mono body) { - return user(auth).zipWith(body) + return own(auth).zipWith(body) .flatMap(pair -> userService.updateDisplayName(pair.getT1(), pair.getT2().displayName())) .flatMap(this::profile); } @@ -147,7 +151,7 @@ public class AuthenticationController { public Mono> changePassword(Mono auth, @Valid @RequestBody Mono body, ServerHttpRequest http) { String ip = clientIp(http); - return user(auth).zipWith(body) + return own(auth).zipWith(body) .flatMap(pair -> { User user = pair.getT1(); // Someone with a stolen session must not guess the current password either @@ -186,6 +190,15 @@ public class AuthenticationController { admins.isAdmin(user.getEmail()))); } + /** The session's own mailbox — not a shared / attached / administrator-opened one: no profile changes there. */ + private Mono own(Mono auth) { + return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) + .flatMap(a -> com.mailserver.security.Actor.opened(a) + ? Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, + "This mailbox is opened from " + com.mailserver.security.Actor.login(a) + " — change it in Settings → MAILBOXES")) + : user(Mono.just(a))); + } + private Mono user(Mono auth) { return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) .flatMap(a -> userService.findByEmail(a.getName())) diff --git a/backend/src/main/java/com/mailserver/controller/MailController.java b/backend/src/main/java/com/mailserver/controller/MailController.java index 4c0931d..3e97710 100644 --- a/backend/src/main/java/com/mailserver/controller/MailController.java +++ b/backend/src/main/java/com/mailserver/controller/MailController.java @@ -150,7 +150,8 @@ public class MailController { @PostMapping("/send") public Mono sendMessage(Mono auth, @Valid @RequestBody Mono requestMono) { return auth.zipWith(requestMono) - .flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2())); + .flatMap(pair -> submissionService.send(pair.getT1().getName(), + com.mailserver.security.Actor.login(pair.getT1()), pair.getT2())); } // --- Drafts --- diff --git a/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java index 85c11d8..ca2d3b1 100644 --- a/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java +++ b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java @@ -6,6 +6,7 @@ import com.mailserver.domain.User; import com.mailserver.dto.LoginResponse; import com.mailserver.service.MailDirectory; import com.mailserver.service.MailLists; +import com.mailserver.service.MailboxAccess; import com.mailserver.service.MailService; import com.mailserver.service.UserService; import com.mailserver.util.JwtUtils; @@ -45,12 +46,13 @@ public class MailboxAdminController { 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) { + boolean shared, List members, long usedBytes, long quotaBytes, Instant createdAt) { } /** @param password empty — the server generates one and returns it once */ + /** @param shared shared mailbox (support@): no login of its own — members open it; password ignored */ public record CreateRequest(@NotBlank @Size(max = 254) String email, @Size(max = 100) String displayName, - @Size(max = 200) String password) { + @Size(max = 200) String password, Boolean shared) { } public record PasswordRequest(@Size(max = 200) String password) { @@ -73,9 +75,11 @@ public class MailboxAdminController { private final JwtUtils jwt; private final MailDirectory directory; private final MailLists lists; + private final MailboxAccess access; public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt, - MailDirectory directory, MailLists lists) { + MailDirectory directory, MailLists lists, MailboxAccess access) { + this.access = access; this.jwt = jwt; this.directory = directory; this.lists = lists; @@ -100,11 +104,13 @@ public class MailboxAdminController { 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; + boolean shared = Boolean.TRUE.equals(request.shared()); + String password = chosen.isEmpty() || shared ? randomPassword() : chosen; return directory.createMailbox(request.email(), request.displayName(), password) - .doOnNext(user -> log.info("Mailbox created by administrator: {}", user.getEmail())) + .flatMap(user -> shared ? users.setShared(user, true) : Mono.just(user)) + .doOnNext(user -> log.info("{} created by administrator: {}", shared ? "Shared mailbox" : "Mailbox", user.getEmail())) .flatMap(this::view) - .map(view -> new Created(view, chosen.isEmpty() ? password : null)); + .map(view -> new Created(view, chosen.isEmpty() && !shared ? password : null)); }); } @@ -134,7 +140,7 @@ public class MailboxAdminController { return admin(auth).flatMap(me -> mailbox(id).map(user -> { log.warn("Administrator {} opened mailbox {}", me, user.getEmail()); LoginResponse response = new LoginResponse(); - response.setToken(jwt.generateToken(user.getEmail())); + response.setToken(jwt.generateToken(user.getEmail(), me)); response.setEmail(user.getEmail()); return response; })); @@ -161,6 +167,19 @@ public class MailboxAdminController { }))).flatMap(this::view); } + public record MembersRequest(List members) { + } + + /** Exactly these members may open the mailbox (shared mailbox; for an ordinary one — delegates). */ + @PutMapping("/{id}/members") + public Mono members(Mono auth, @PathVariable Long id, @RequestBody Mono body) { + return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> + directory.setMembers(user, request.members()) + .doOnSuccess(v -> log.info("Members of {} set by administrator {}: {}", user.getEmail(), me, request.members())) + .then(users.byId(id))))) + .flatMap(this::view); + } + // --- Mailing lists (Settings → LISTS) --- public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside, @@ -202,9 +221,9 @@ public class MailboxAdminController { } private Mono view(User user) { - return mail.usedBytes(user.getId()).defaultIfEmpty(0L) - .map(used -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(), - admins.isAdmin(user.getEmail()), user.isTrap(), used, user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), + return mail.usedBytes(user.getId()).defaultIfEmpty(0L).zipWith(access.members(user.getId()).collectList()) + .map(pair -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(), + admins.isAdmin(user.getEmail()), user.isTrap(), user.isShared(), pair.getT2(), pair.getT1(), user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt())); } diff --git a/backend/src/main/java/com/mailserver/controller/MailboxesController.java b/backend/src/main/java/com/mailserver/controller/MailboxesController.java new file mode 100644 index 0000000..5bd6119 --- /dev/null +++ b/backend/src/main/java/com/mailserver/controller/MailboxesController.java @@ -0,0 +1,130 @@ +package com.mailserver.controller; + +import com.mailserver.domain.User; +import com.mailserver.dto.LoginResponse; +import com.mailserver.security.Actor; +import com.mailserver.security.LoginGuard; +import com.mailserver.service.MailboxAccess; +import com.mailserver.service.UserService; +import com.mailserver.util.JwtUtils; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +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.time.Duration; +import java.util.Locale; + +/** + * The mailboxes of the logged-in user (left menu): own, shared ones it is a member of, attached ones. + * Always works for the LOGIN behind the session, also when a shared mailbox is open. + */ +@RestController +@RequestMapping("/api/mailboxes") +public class MailboxesController { + + private static final Logger log = LoggerFactory.getLogger(MailboxesController.class); + + public record AttachRequest(@NotBlank @Size(max = 254) String email, @NotBlank @Size(max = 200) String password) { + } + + private final MailboxAccess access; + private final UserService users; + private final JwtUtils jwt; + private final LoginGuard guard; + + public MailboxesController(MailboxAccess access, UserService users, JwtUtils jwt, LoginGuard guard) { + this.access = access; + this.users = users; + this.jwt = jwt; + this.guard = guard; + } + + @GetMapping + public Flux mine(Mono auth) { + return login(auth).flatMapMany(access::mailboxes); + } + + /** A session of this mailbox for the login (switch in the left menu). */ + @PostMapping("/{email}/session") + public Mono session(Mono auth, @PathVariable String email) { + return login(auth).flatMap(me -> mailbox(email).flatMap(target -> access.has(target.getId(), me.getId()) + .flatMap(allowed -> { + if (!allowed) { + return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "No access to this mailbox")); + } + LoginResponse response = new LoginResponse(); + boolean own = target.getId().equals(me.getId()); + response.setToken(own ? jwt.generateToken(me.getEmail()) : jwt.generateToken(target.getEmail(), me.getEmail())); + response.setEmail(target.getEmail()); + return Mono.just(response); + }))); + } + + /** Adds another mailbox the user knows the password of. The password is checked like a login (LoginGuard). */ + @PostMapping("/attach") + @ResponseStatus(HttpStatus.CREATED) + public Mono attach(Mono auth, @RequestBody Mono body, ServerHttpRequest http) { + String ip = AuthenticationController.clientIp(http); + return login(auth).zipWith(body).flatMap(pair -> { + User me = pair.getT1(); + String email = pair.getT2().email().trim().toLowerCase(Locale.ROOT); + Duration wait = guard.retryAfter(ip, email); + if (!wait.isZero()) { + return Mono.error(new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, + "Too many failed attempts, try again in " + LoginGuard.human(wait))); + } + return users.findOptional(email) + .filter(target -> target.isActive() && !target.isShared() && users.checkPassword(target, pair.getT2().password())) + .switchIfEmpty(Mono.defer(() -> { + guard.failure(ip, email); + log.warn("WEB {}: {} failed to attach {}", ip, me.getEmail(), email); + return Mono.error(new ResponseStatusException(HttpStatus.BAD_REQUEST, "Wrong address or password")); + })) + .flatMap(target -> { + if (target.getId().equals(me.getId())) { + return Mono.error(new ResponseStatusException(HttpStatus.BAD_REQUEST, "This is your own mailbox")); + } + guard.success(ip, email); + log.info("{} attached mailbox {}", me.getEmail(), email); + return access.grant(target.getId(), me.getId(), MailboxAccess.ATTACHED) + .then(access.unread(target.getId())) + .map(n -> new MailboxAccess.Entry(target.getEmail(), target.getDisplayName(), MailboxAccess.ATTACHED, false, n)); + }); + }); + } + + /** Removes the mailbox from the user's menu (attached or shared). */ + @DeleteMapping("/{email}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public Mono leave(Mono auth, @PathVariable String email) { + return login(auth).flatMap(me -> mailbox(email).flatMap(target -> access.revoke(target.getId(), me.getId()) + .flatMap(done -> done ? Mono.empty() + : Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Not in your mailboxes"))))); + } + + private Mono login(Mono auth) { + return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) + .flatMap(a -> users.findOptional(Actor.login(a))) + .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))); + } + + private Mono mailbox(String email) { + return users.findOptional(email) + .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox"))); + } +} diff --git a/backend/src/main/java/com/mailserver/controller/ServiceController.java b/backend/src/main/java/com/mailserver/controller/ServiceController.java index 50d7a26..be492af 100644 --- a/backend/src/main/java/com/mailserver/controller/ServiceController.java +++ b/backend/src/main/java/com/mailserver/controller/ServiceController.java @@ -3,6 +3,7 @@ package com.mailserver.controller; import com.mailserver.domain.User; import com.mailserver.service.MailDirectory; import com.mailserver.service.MailLists; +import com.mailserver.service.MailboxAccess; import com.mailserver.service.UserService; import jakarta.validation.constraints.Size; import org.slf4j.Logger; @@ -57,9 +58,11 @@ public class ServiceController { private final UserService users; private final MailLists lists; private final MailDirectory directory; + private final MailboxAccess access; public ServiceController(@Value("${mailserver.service-token:}") String token, UserService users, MailLists lists, - MailDirectory directory) { + MailDirectory directory, MailboxAccess access) { + this.access = access; this.token = token.isBlank() ? null : token.trim().getBytes(StandardCharsets.UTF_8); this.users = users; this.lists = lists; @@ -131,6 +134,23 @@ public class ServiceController { ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build()); } + public record SharedRequest(@Size(max = 100) String displayName, List members) { + } + + public record SharedView(String email, String displayName, List members) { + } + + /** Shared mailbox (support@, billing@): created when missing, exactly these members. */ + @PutMapping("/shared/{address}") + public Mono saveShared(@RequestHeader(value = "Authorization", required = false) String auth, + @PathVariable String address, @RequestBody Mono body) { + check(auth); + return body.flatMap(request -> directory.saveShared(address, request.displayName(), request.members())) + .flatMap(user -> access.members(user.getId()).collectList() + .map(members -> new SharedView(user.getEmail(), user.getDisplayName(), members))) + .doOnNext(view -> log.info("Shared mailbox {} saved by Jambotron: {}", view.email(), view.members())); + } + private Mono update(User user, AccountRequest request) { Mono named = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user) : users.updateDisplayName(user, request.displayName()); @@ -139,7 +159,8 @@ public class ServiceController { } boolean on = request.active(); return named.flatMap(u -> users.setActive(u, on)) - .flatMap(u -> on ? Mono.just(u) : lists.removeEverywhere(u.getId()).thenReturn(u)) + .flatMap(u -> on ? Mono.just(u) : lists.removeEverywhere(u.getId()) + .then(access.removeEverywhere(u.getId())).thenReturn(u)) .doOnNext(u -> log.info("Mailbox {} {} by Jambotron", u.getEmail(), on ? "switched on" : "switched off")); } diff --git a/backend/src/main/java/com/mailserver/domain/Message.java b/backend/src/main/java/com/mailserver/domain/Message.java index b02f04f..1a77c19 100644 --- a/backend/src/main/java/com/mailserver/domain/Message.java +++ b/backend/src/main/java/com/mailserver/domain/Message.java @@ -54,6 +54,13 @@ public class Message { @Column("message_id") private String messageId; + /** Who answered it from the web client (V7) — the login, also for a shared mailbox. */ + @Column("answered_by") + private String answeredBy; + + public String getAnsweredBy() { return answeredBy; } + public void setAnsweredBy(String answeredBy) { this.answeredBy = answeredBy; } + public Long getId() { return id; } public void setId(Long id) { this.id = id; } diff --git a/backend/src/main/java/com/mailserver/domain/User.java b/backend/src/main/java/com/mailserver/domain/User.java index f75e371..6c6bf62 100644 --- a/backend/src/main/java/com/mailserver/domain/User.java +++ b/backend/src/main/java/com/mailserver/domain/User.java @@ -34,6 +34,10 @@ public class User { @Column("is_trap") private boolean isTrap = false; + /** Shared mailbox (V7): no login of its own, members open it. */ + @Column("is_shared") + private boolean isShared = false; + @Column("created_at") private Instant createdAt; @@ -61,6 +65,9 @@ public class User { public boolean isTrap() { return isTrap; } public void setTrap(boolean trap) { isTrap = trap; } + public boolean isShared() { return isShared; } + public void setShared(boolean shared) { isShared = shared; } + public Instant getCreatedAt() { return createdAt; } public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } } diff --git a/backend/src/main/java/com/mailserver/dto/MessageResponse.java b/backend/src/main/java/com/mailserver/dto/MessageResponse.java index 18302ef..a37b870 100644 --- a/backend/src/main/java/com/mailserver/dto/MessageResponse.java +++ b/backend/src/main/java/com/mailserver/dto/MessageResponse.java @@ -20,6 +20,11 @@ public class MessageResponse { private Long folderId; private boolean hasAttachments; private int attachmentCount; + /** login that answered it from the web client, or null */ + private String answeredBy; + + public String getAnsweredBy() { return answeredBy; } + public void setAnsweredBy(String answeredBy) { this.answeredBy = answeredBy; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } diff --git a/backend/src/main/java/com/mailserver/dto/SendRequest.java b/backend/src/main/java/com/mailserver/dto/SendRequest.java index 8ec00fc..122cc15 100644 --- a/backend/src/main/java/com/mailserver/dto/SendRequest.java +++ b/backend/src/main/java/com/mailserver/dto/SendRequest.java @@ -26,6 +26,12 @@ public class SendRequest { private Long draftId; + /** The message this one answers (web client «Reply»): marked answered, by whom. */ + private Long replyToId; + + public Long getReplyToId() { return replyToId; } + public void setReplyToId(Long replyToId) { this.replyToId = replyToId; } + public String getTo() { return to; } public void setTo(String to) { this.to = to; } diff --git a/backend/src/main/java/com/mailserver/imap/DbImapStore.java b/backend/src/main/java/com/mailserver/imap/DbImapStore.java index 7973a3d..2efe14b 100644 --- a/backend/src/main/java/com/mailserver/imap/DbImapStore.java +++ b/backend/src/main/java/com/mailserver/imap/DbImapStore.java @@ -8,6 +8,7 @@ import com.mailserver.repository.MessageRepository; import com.mailserver.repository.UserRepository; import com.mailserver.service.LocalDelivery; import com.mailserver.service.MailService; +import com.mailserver.service.MailboxAccess; import com.mailserver.service.MessageSources; import com.mailserver.service.UserService; import jakarta.mail.Session; @@ -45,10 +46,12 @@ public class DbImapStore implements ImapStore { private final AttachmentRepository attachments; private final MessageSources sources; private final String hostname; + private final MailboxAccess access; public DbImapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService, MessageRepository messages, AttachmentRepository attachments, MessageSources sources, - String hostname) { + String hostname, MailboxAccess access) { + this.access = access; this.db = db; this.users = users; this.userService = userService; @@ -61,11 +64,9 @@ public class DbImapStore implements ImapStore { @Override public Account authenticate(String username, String password) { - User user = users.findByEmail(username.trim().toLowerCase(Locale.ROOT)).block(); - if (user == null || !user.isActive() || !userService.checkPassword(user, password)) { - return null; - } - return new Account(user.getId(), user.getEmail()); + // login or mailbox*login (shared / attached mailbox in a mail program) + User user = access.login(username, password); + return user == null ? null : new Account(user.getId(), user.getEmail()); } @Override diff --git a/backend/src/main/java/com/mailserver/security/Actor.java b/backend/src/main/java/com/mailserver/security/Actor.java new file mode 100644 index 0000000..75517aa --- /dev/null +++ b/backend/src/main/java/com/mailserver/security/Actor.java @@ -0,0 +1,23 @@ +package com.mailserver.security; + +import org.springframework.security.core.Authentication; + +/** + * Who is acting: the mailbox the session belongs to ({@link Authentication#getName()}) and the login behind it — + * the same for an own session, another one when a member opened a shared mailbox or an administrator opened one. + */ +public final class Actor { + + private Actor() { + } + + /** The login behind the session. */ + public static String login(Authentication auth) { + return auth.getDetails() instanceof String by && !by.isBlank() ? by : auth.getName(); + } + + /** The session is another login's view of this mailbox: no password / profile changes from it. */ + public static boolean opened(Authentication auth) { + return !login(auth).equalsIgnoreCase(auth.getName()); + } +} diff --git a/backend/src/main/java/com/mailserver/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/mailserver/security/JwtAuthenticationFilter.java index a2141b7..8e97240 100644 --- a/backend/src/main/java/com/mailserver/security/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/mailserver/security/JwtAuthenticationFilter.java @@ -43,6 +43,8 @@ public class JwtAuthenticationFilter implements WebFilter { UserDetails userDetails = new User(email, "", Collections.emptyList()); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, Collections.emptyList()); + // the login that opened this mailbox (shared / attached / administrator's OPEN); see Actor + authentication.setDetails(jwtUtils.getByFromToken(token)); return chain.filter(exchange) .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication)); diff --git a/backend/src/main/java/com/mailserver/service/MailDirectory.java b/backend/src/main/java/com/mailserver/service/MailDirectory.java index 234a765..a100e97 100644 --- a/backend/src/main/java/com/mailserver/service/MailDirectory.java +++ b/backend/src/main/java/com/mailserver/service/MailDirectory.java @@ -29,8 +29,10 @@ public class MailDirectory { private final UserService users; private final MailLists lists; private final MailDomains domains; + private final MailboxAccess access; - public MailDirectory(UserService users, MailLists lists, MailDomains domains) { + public MailDirectory(UserService users, MailLists lists, MailDomains domains, MailboxAccess access) { + this.access = access; this.users = users; this.lists = lists; this.domains = domains; @@ -95,6 +97,29 @@ public class MailDirectory { }); } + /** Exactly these members (mailboxes of this server) for the mailbox; unknown ones → 400 naming them. */ + public Mono setMembers(User mailbox, List members) { + List wanted = members == null ? List.of() + : members.stream().map(m -> m.trim().toLowerCase(Locale.ROOT)).filter(m -> !m.isEmpty()).distinct().toList(); + List 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() ? access.setMembers(mailbox.getId(), ids) + : Mono.error(bad("No such mailbox: " + String.join(", ", unknown)))); + } + + /** Shared mailbox: created when missing (no login of its own), then exactly these members. */ + public Mono saveShared(String rawAddress, String displayName, List members) { + String address = address(rawAddress); + return users.findOptional(address) + .switchIfEmpty(Mono.defer(() -> createMailbox(address, displayName, randomPassword()))) + .flatMap(user -> user.isShared() ? Mono.just(user) : users.setShared(user, true)) + .flatMap(user -> displayName == null || displayName.isBlank() ? Mono.just(user) : users.updateDisplayName(user, displayName)) + .flatMap(user -> setMembers(user, members).thenReturn(user)); + } + public static String randomPassword() { byte[] bytes = new byte[18]; new SecureRandom().nextBytes(bytes); diff --git a/backend/src/main/java/com/mailserver/service/MailService.java b/backend/src/main/java/com/mailserver/service/MailService.java index 851ba89..f34eafe 100644 --- a/backend/src/main/java/com/mailserver/service/MailService.java +++ b/backend/src/main/java/com/mailserver/service/MailService.java @@ -480,6 +480,7 @@ public class MailService { resp.setMessageSize(message.getMessageSize()); resp.setReceivedAt(message.getReceivedAt()); resp.setFolderId(message.getFolderId()); + resp.setAnsweredBy(message.getAnsweredBy()); return resp; } diff --git a/backend/src/main/java/com/mailserver/service/MailboxAccess.java b/backend/src/main/java/com/mailserver/service/MailboxAccess.java new file mode 100644 index 0000000..d5d4b37 --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/MailboxAccess.java @@ -0,0 +1,129 @@ +package com.mailserver.service; + +import com.mailserver.domain.User; +import com.mailserver.repository.UserRepository; +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.Locale; + +/** + * Several mailboxes for one login (V7): own, shared ones it is a MEMBER of, ATTACHED ones (by their password). + * One rule for the web client, SMTP and IMAP; in mail programs another mailbox is + * {@code mailbox*login} with the login's password (the Dovecot "master user" form). + */ +@Service +public class MailboxAccess { + + public static final String MEMBER = "MEMBER"; + public static final String ATTACHED = "ATTACHED"; + + /** @param kind OWN, MEMBER or ATTACHED; unread — INBOX and own folders (not Spam/Trash/Sent/Drafts) */ + public record Entry(String email, String displayName, String kind, boolean shared, long unread) { + } + + private final DatabaseClient db; + private final UserRepository users; + private final UserService userService; + + public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService) { + this.db = db; + this.users = users; + this.userService = userService; + } + + /** + * Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password → the mailbox the + * session works with, or null. A shared mailbox never logs in by itself. + */ + public User login(String username, String password) { + String name = username.trim().toLowerCase(Locale.ROOT); + int star = name.indexOf('*'); + String mailbox = star < 0 ? name : name.substring(0, star); + String login = star < 0 ? name : name.substring(star + 1); + User user = users.findByEmail(login).block(); + if (user == null || !user.isActive() || user.isShared() || !userService.checkPassword(user, password)) { + return null; + } + if (mailbox.equals(login)) { + return user; + } + User target = users.findByEmail(mailbox).block(); + return target != null && target.isActive() && Boolean.TRUE.equals(has(target.getId(), user.getId()).block()) + ? target : null; + } + + /** The login may open this mailbox (own or by access). */ + public Mono has(Long mailboxId, Long userId) { + if (mailboxId.equals(userId)) { + return Mono.just(true); + } + return db.sql("SELECT 1 FROM mailbox_access WHERE mailbox_id = :m AND user_id = :u") + .bind("m", mailboxId).bind("u", userId).map((row, meta) -> true).one().defaultIfEmpty(false); + } + + /** Own mailbox first, then the others by address. */ + public Flux mailboxes(User login) { + Mono own = unread(login.getId()).map(n -> new Entry(login.getEmail(), login.getDisplayName(), "OWN", false, n)); + Flux others = db.sql("SELECT u.id, u.email, u.display_name, u.is_shared, a.kind FROM mailbox_access a " + + "JOIN users u ON u.id = a.mailbox_id WHERE a.user_id = :u AND u.is_active ORDER BY u.email") + .bind("u", login.getId()) + .map((row, meta) -> new Object[]{row.get("id", Long.class), row.get("email", String.class), + row.get("display_name", String.class), Boolean.TRUE.equals(row.get("is_shared", Boolean.class)), + row.get("kind", String.class)}) + .all() + .concatMap(r -> unread((Long) r[0]).map(n -> new Entry((String) r[1], (String) r[2], (String) r[4], (Boolean) r[3], n))); + return own.flux().concatWith(others); + } + + public Mono unread(Long mailboxId) { + return db.sql("SELECT COUNT(*) AS n FROM messages m JOIN folders f ON f.id = m.folder_id " + + "WHERE f.user_id = :u AND NOT m.is_read AND NOT m.is_deleted AND (NOT f.is_system OR f.name = 'INBOX')") + .bind("u", mailboxId).map((row, meta) -> row.get("n", Long.class)).one().defaultIfEmpty(0L); + } + + public Mono grant(Long mailboxId, Long userId, String kind) { + return db.sql("INSERT INTO mailbox_access (mailbox_id, user_id, kind) VALUES (:m, :u, :k) " + + "ON CONFLICT (mailbox_id, user_id) DO NOTHING") + .bind("m", mailboxId).bind("u", userId).bind("k", kind).then(); + } + + public Mono revoke(Long mailboxId, Long userId) { + return db.sql("DELETE FROM mailbox_access WHERE mailbox_id = :m AND user_id = :u") + .bind("m", mailboxId).bind("u", userId).fetch().rowsUpdated().map(n -> n > 0); + } + + /** Exactly these MEMBERs (ATTACHED ones stay — they know the password). */ + @Transactional + public Mono setMembers(Long mailboxId, Collection memberIds) { + return db.sql("DELETE FROM mailbox_access WHERE mailbox_id = :m AND kind = :k").bind("m", mailboxId).bind("k", MEMBER).then() + .thenMany(Flux.fromIterable(memberIds).distinct().filter(id -> !id.equals(mailboxId)) + .concatMap(id -> db.sql("INSERT INTO mailbox_access (mailbox_id, user_id, kind) VALUES (:m, :u, :k) " + + "ON CONFLICT (mailbox_id, user_id) DO UPDATE SET kind = EXCLUDED.kind") + .bind("m", mailboxId).bind("u", id).bind("k", MEMBER).then())) + .then(); + } + + public Flux members(Long mailboxId) { + return db.sql("SELECT u.email FROM mailbox_access a JOIN users u ON u.id = a.user_id " + + "WHERE a.mailbox_id = :m AND a.kind = :k ORDER BY u.email") + .bind("m", mailboxId).bind("k", MEMBER).map((row, meta) -> row.get("email", String.class)).all(); + } + + /** The login loses every MEMBER access (role removed in Jambotron); ATTACHED stays with the mailbox owner's say. */ + public Mono removeEverywhere(Long userId) { + return db.sql("DELETE FROM mailbox_access WHERE user_id = :u AND kind = :k").bind("u", userId).bind("k", MEMBER) + .fetch().rowsUpdated(); + } + + /** Reply from the web client: the original is answered, and by whom. */ + public Mono markAnswered(Long messageId, Long mailboxId, String by) { + return db.sql("UPDATE messages SET is_answered = TRUE, answered_by = :b WHERE id = :id " + + "AND folder_id IN (SELECT id FROM folders WHERE user_id = :u)") + .bind("b", by).bind("id", messageId).bind("u", mailboxId).then(); + } +} diff --git a/backend/src/main/java/com/mailserver/service/SubmissionService.java b/backend/src/main/java/com/mailserver/service/SubmissionService.java index a022e17..512c223 100644 --- a/backend/src/main/java/com/mailserver/service/SubmissionService.java +++ b/backend/src/main/java/com/mailserver/service/SubmissionService.java @@ -50,9 +50,11 @@ public class SubmissionService { private final MailService mailService; private final MailRouter router; private final String hostname; + private final MailboxAccess access; public SubmissionService(UserRepository users, MailService mailService, MailRouter router, - @Value("${mailserver.smtp.hostname:localhost}") String hostname) { + @Value("${mailserver.smtp.hostname:localhost}") String hostname, MailboxAccess access) { + this.access = access; this.users = users; this.mailService = mailService; this.router = router; @@ -60,6 +62,11 @@ public class SubmissionService { } public Mono send(String fromEmail, SendRequest request) { + return send(fromEmail, fromEmail, request); + } + + /** @param login who sends (the member for a shared mailbox) — recorded as «answered by» on the original */ + public Mono send(String fromEmail, String login, SendRequest request) { List to = addresses(request.getTo()); List cc = addresses(request.getCc()); cc.removeAll(to); @@ -78,7 +85,9 @@ public class SubmissionService { .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.empty() : mailService.finishDraft(draftId, sent.getId())) + .flatMap(sent -> (request.getReplyToId() == null ? Mono.empty() + : access.markAnswered(request.getReplyToId(), user.getId(), login)) + .then(draftId == null ? Mono.empty() : mailService.finishDraft(draftId, sent.getId())) .then(mailService.getMessage(sent.getId(), user.getId())))); } diff --git a/backend/src/main/java/com/mailserver/service/UserService.java b/backend/src/main/java/com/mailserver/service/UserService.java index 1cc0ada..f7da252 100644 --- a/backend/src/main/java/com/mailserver/service/UserService.java +++ b/backend/src/main/java/com/mailserver/service/UserService.java @@ -98,6 +98,11 @@ public class UserService { return userRepository.save(user); } + public Mono setShared(User user, boolean shared) { + user.setShared(shared); + return userRepository.save(user); + } + public Mono setTrap(User user, boolean trap) { user.setTrap(trap); return userRepository.save(user); diff --git a/backend/src/main/java/com/mailserver/smtp/MailRouter.java b/backend/src/main/java/com/mailserver/smtp/MailRouter.java index 4af8916..bee33ec 100644 --- a/backend/src/main/java/com/mailserver/smtp/MailRouter.java +++ b/backend/src/main/java/com/mailserver/smtp/MailRouter.java @@ -8,6 +8,7 @@ import com.mailserver.repository.UserRepository; import com.mailserver.service.DkimService; import com.mailserver.service.LocalDelivery; import com.mailserver.service.MailLists; +import com.mailserver.service.MailboxAccess; import com.mailserver.service.UserService; import org.springframework.stereotype.Service; @@ -38,9 +39,12 @@ public class MailRouter implements SmtpBackend { private final DkimService dkim; private final SmtpDeliveryClient client; private final MailLists lists; + private final MailboxAccess access; public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery, - OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client, MailLists lists) { + OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client, MailLists lists, + MailboxAccess access) { + this.access = access; this.domains = domains; this.users = users; this.userService = userService; @@ -52,9 +56,9 @@ public class MailRouter implements SmtpBackend { } @Override - public boolean authenticate(String username, String password) { - User user = users.findByEmail(username.toLowerCase(Locale.ROOT)).block(); - return user != null && user.isActive() && userService.checkPassword(user, password); + public String authenticate(String username, String password) { + User mailbox = access.login(username, password); + return mailbox == null ? null : mailbox.getEmail(); } @Override diff --git a/backend/src/main/java/com/mailserver/smtp/SmtpBackend.java b/backend/src/main/java/com/mailserver/smtp/SmtpBackend.java index 551085f..11ba0a9 100644 --- a/backend/src/main/java/com/mailserver/smtp/SmtpBackend.java +++ b/backend/src/main/java/com/mailserver/smtp/SmtpBackend.java @@ -32,8 +32,11 @@ public interface SmtpBackend { record Envelope(String remoteIp, String helo, String mailFrom, String authenticatedUser, List recipients) { } - /** true — the address and password belong to an active mailbox. */ - boolean authenticate(String username, String password); + /** + * {@code login} or {@code mailbox*login} with the login's password → the mailbox address the session works as, + * or null. + */ + String authenticate(String username, String password); Route route(String recipient, String authenticatedUser); diff --git a/backend/src/main/java/com/mailserver/smtp/SmtpServer.java b/backend/src/main/java/com/mailserver/smtp/SmtpServer.java index 26cbd67..33a6086 100644 --- a/backend/src/main/java/com/mailserver/smtp/SmtpServer.java +++ b/backend/src/main/java/com/mailserver/smtp/SmtpServer.java @@ -373,12 +373,13 @@ public class SmtpServer { closing = true; return reply("421 4.7.0 Too many failed logins, try again in " + LoginGuard.human(wait)); } - return blocking(() -> backend.authenticate(username, password)) - .flatMap(ok -> { - if (ok) { + return blocking(() -> java.util.Optional.ofNullable(backend.authenticate(username, password))) + .flatMap(found -> { + if (found.isPresent()) { guard.success(remoteIp, username); - authenticatedUser = username; - log.info("SMTP {}: authenticated as {}", remoteIp, username); + authenticatedUser = found.get(); + log.info("SMTP {}: authenticated as {}{}", remoteIp, found.get(), + found.get().equalsIgnoreCase(username) ? "" : " (login " + username + ")"); return Mono.just("235 2.7.0 Authentication successful\r\n"); } guard.failure(remoteIp, username); diff --git a/backend/src/main/java/com/mailserver/util/JwtUtils.java b/backend/src/main/java/com/mailserver/util/JwtUtils.java index 89ae09a..02ebdb2 100644 --- a/backend/src/main/java/com/mailserver/util/JwtUtils.java +++ b/backend/src/main/java/com/mailserver/util/JwtUtils.java @@ -18,11 +18,20 @@ public class JwtUtils { } public String generateToken(String email) { + return generateToken(email, null); + } + + /** + * @param by the login that opened this mailbox (shared / attached mailbox, administrator's OPEN), or null — + * the mailbox itself logged in + */ + public String generateToken(String email, String by) { Date now = new Date(); Date expiry = new Date(now.getTime() + EXPIRATION_MS); return Jwts.builder() .subject(email) + .claim("by", by) .issuedAt(now) .expiration(expiry) .signWith(signingKey) @@ -38,6 +47,12 @@ public class JwtUtils { return claims.getSubject(); } + /** "by" claim: who opened this mailbox, or null. */ + public String getByFromToken(String token) { + Object by = Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token).getPayload().get("by"); + return by == null ? null : by.toString(); + } + public boolean isValidToken(String token) { try { Jwts.parser() diff --git a/backend/src/main/resources/db/migration/V7__mailbox_access.sql b/backend/src/main/resources/db/migration/V7__mailbox_access.sql new file mode 100644 index 0000000..34b19d9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__mailbox_access.sql @@ -0,0 +1,19 @@ +-- Several mailboxes for one login (75.9): +-- shared mailbox (support@, billing@): no login of its own, members open it from their mailbox; read/unread is +-- common — a team inbox; answered_by shows who replied; +-- attached mailbox: a login adds another mailbox it knows the password of. +ALTER TABLE users ADD COLUMN is_shared BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE mailbox_access ( + mailbox_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- MEMBER: given by an administrator / Jambotron; ATTACHED: added by the user with the mailbox password + kind VARCHAR(10) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (mailbox_id, user_id), + CHECK (mailbox_id <> user_id) +); +CREATE INDEX idx_mailbox_access_user ON mailbox_access(user_id); + +-- who answered a message (web client reply; mostly for shared mailboxes) +ALTER TABLE messages ADD COLUMN answered_by VARCHAR(255); diff --git a/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java b/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java index 41eb60b..59998de 100644 --- a/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java +++ b/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java @@ -35,8 +35,9 @@ class MailboxAdminControllerTest { private final MailAdmins admins = new MailAdmins("", "", domains); private final com.mailserver.util.JwtUtils jwt = mock(com.mailserver.util.JwtUtils.class); private final com.mailserver.service.MailLists lists = mock(com.mailserver.service.MailLists.class); - private final com.mailserver.service.MailDirectory directory = new com.mailserver.service.MailDirectory(users, lists, domains); - private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt, directory, lists); + private final com.mailserver.service.MailboxAccess access = mock(com.mailserver.service.MailboxAccess.class); + private final com.mailserver.service.MailDirectory directory = new com.mailserver.service.MailDirectory(users, lists, domains, access); + private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt, directory, lists, access); private final User admin = user(1L, "admin@jambocheck.com"); private final User alice = user(2L, "alice@jambocheck.com"); @@ -44,6 +45,7 @@ class MailboxAdminControllerTest { MailboxAdminControllerTest() { when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L)); when(lists.exists(anyString())).thenReturn(Mono.just(false)); + when(access.members(anyLong())).thenReturn(Flux.empty()); when(lists.exists("alerts@jambocheck.com")).thenReturn(Mono.just(true)); when(users.findOptional(anyString())).thenReturn(Mono.empty()); when(users.findOptional("alice@jambocheck.com")).thenReturn(Mono.just(alice)); @@ -89,6 +91,26 @@ class MailboxAdminControllerTest { .isEqualTo(HttpStatus.FORBIDDEN); } + @Test + void sharedMailboxHasNoPasswordShownMembersAreSet() { + when(users.setShared(any(), any(Boolean.class))).thenAnswer(i -> { + User u = i.getArgument(0); + u.setShared(i.getArgument(1)); + return Mono.just(u); + }); + var created = controller.create(as("admin@jambocheck.com"), + Mono.just(new MailboxAdminController.CreateRequest("support@jambocheck.com", "Support", "", true))).block(); + assertThat(created.password()).isNull(); + assertThat(created.mailbox().shared()).isTrue(); + + when(access.setMembers(anyLong(), any())).thenReturn(Mono.empty()); + controller.members(as("admin@jambocheck.com"), 2L, + Mono.just(new MailboxAdminController.MembersRequest(List.of("Alice@jambocheck.com")))).block(); + verify(access).setMembers(eq(2L), eq(List.of(2L))); + assertThat(status(() -> controller.members(as("admin@jambocheck.com"), 2L, + Mono.just(new MailboxAdminController.MembersRequest(List.of("ghost@jambocheck.com")))).block())).isEqualTo(HttpStatus.BAD_REQUEST); + } + @Test void administratorSwitchesTrapOthersCanNot() { assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block().trap()).isTrue(); @@ -117,14 +139,14 @@ class MailboxAdminControllerTest { @Test void createsReservedNamesWithAGeneratedPasswordShownOnce() { var created = controller.create(as("admin@jambocheck.com"), - Mono.just(new MailboxAdminController.CreateRequest(" No-Reply@JamboCheck.com ", "Jambotron", ""))).block(); + Mono.just(new MailboxAdminController.CreateRequest(" No-Reply@JamboCheck.com ", "Jambotron", "", null))).block(); assertThat(created.mailbox().email()).isEqualTo("no-reply@jambocheck.com"); assertThat(created.password()).hasSizeGreaterThanOrEqualTo(20); verify(users).register(eq("no-reply@jambocheck.com"), eq(created.password()), eq("Jambotron")); var chosen = controller.create(as("admin@jambocheck.com"), - Mono.just(new MailboxAdminController.CreateRequest("monitor@jambocheck.com", null, "long-enough-1"))).block(); + Mono.just(new MailboxAdminController.CreateRequest("monitor@jambocheck.com", null, "long-enough-1", null))).block(); assertThat(chosen.password()).isNull(); } @@ -153,7 +175,7 @@ class MailboxAdminControllerTest { @Test void administratorOpensAnyMailboxOthersCanNot() { - when(jwt.generateToken("alice@jambocheck.com")).thenReturn("alice-token"); + when(jwt.generateToken("alice@jambocheck.com", "admin@jambocheck.com")).thenReturn("alice-token"); var session = controller.open(as("admin@jambocheck.com"), 2L).block(); assertThat(session.getEmail()).isEqualTo("alice@jambocheck.com"); @@ -164,7 +186,7 @@ class MailboxAdminControllerTest { } private MailboxAdminController.Created create(String me, String email, String password) { - return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password))).block(); + return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password, null))).block(); } private static Mono as(String email) { diff --git a/backend/src/test/java/com/mailserver/controller/ServiceControllerTest.java b/backend/src/test/java/com/mailserver/controller/ServiceControllerTest.java index eb833cd..2a781e5 100644 --- a/backend/src/test/java/com/mailserver/controller/ServiceControllerTest.java +++ b/backend/src/test/java/com/mailserver/controller/ServiceControllerTest.java @@ -31,7 +31,8 @@ class ServiceControllerTest { private final UserService users = mock(UserService.class); private final MailLists lists = mock(MailLists.class); private final MailDomains domains = new MailDomains("jambocheck.com", ""); - private final MailDirectory directory = new MailDirectory(users, lists, domains); + private final com.mailserver.service.MailboxAccess access = mock(com.mailserver.service.MailboxAccess.class); + private final MailDirectory directory = new MailDirectory(users, lists, domains, access); private final User existing = user(7L, "jambotronpw@jambocheck.com"); ServiceControllerTest() { @@ -40,6 +41,7 @@ class ServiceControllerTest { when(lists.exists(anyString())).thenReturn(Mono.just(false)); when(lists.listsOf(anyLong())).thenReturn(Flux.just("alerts-prod@jambocheck.com")); when(lists.removeEverywhere(anyLong())).thenReturn(Mono.just(1L)); + when(access.removeEverywhere(anyLong())).thenReturn(Mono.just(1L)); when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(8L, i.getArgument(0)))); when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> { User u = i.getArgument(0); @@ -50,7 +52,7 @@ class ServiceControllerTest { } private ServiceController api(String token) { - return new ServiceController(token, users, lists, directory); + return new ServiceController(token, users, lists, directory, access); } @Test @@ -84,6 +86,7 @@ class ServiceControllerTest { Mono.just(new ServiceController.AccountRequest(null, false))).block(); assertThat(off.getBody().account().active()).isFalse(); verify(lists).removeEverywhere(7L); + verify(access).removeEverywhere(7L); var reset = api("s3cret-token").password(AUTH, "jambotronpw@jambocheck.com").block(); assertThat(reset.password()).isNotBlank(); diff --git a/backend/src/test/java/com/mailserver/security/ActorTest.java b/backend/src/test/java/com/mailserver/security/ActorTest.java new file mode 100644 index 0000000..c9de3c4 --- /dev/null +++ b/backend/src/test/java/com/mailserver/security/ActorTest.java @@ -0,0 +1,24 @@ +package com.mailserver.security; + +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** The login behind a session: own session, or a member who opened a shared mailbox. */ +class ActorTest { + + @Test + void ownAndOpened() { + var own = new UsernamePasswordAuthenticationToken("bob@x", null, List.of()); + assertThat(Actor.login(own)).isEqualTo("bob@x"); + assertThat(Actor.opened(own)).isFalse(); + + var opened = new UsernamePasswordAuthenticationToken("support@x", null, List.of()); + opened.setDetails("bob@x"); + assertThat(Actor.login(opened)).isEqualTo("bob@x"); + assertThat(Actor.opened(opened)).isTrue(); + } +} diff --git a/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java b/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java index 0e779c8..0495c8b 100644 --- a/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java +++ b/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java @@ -58,7 +58,7 @@ class MailRouterTest { when(lists.find("talk@mail.test")).thenReturn(Mono.just(new MailLists.MailList(2L, "talk@mail.test", null, null, true, List.of("bob@mail.test")))); return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery, - queue, mock(DkimService.class), client, lists); + queue, mock(DkimService.class), client, lists, mock(com.mailserver.service.MailboxAccess.class)); } @Test diff --git a/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java b/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java index d998df1..2915e0b 100644 --- a/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java +++ b/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java @@ -43,8 +43,8 @@ class SmtpServerTest { volatile boolean failStore; @Override - public boolean authenticate(String username, String password) { - return password.equals(passwords.get(username)); + public String authenticate(String username, String password) { + return password.equals(passwords.get(username)) ? username : null; } @Override diff --git a/frontend/src/app/core/api.service.ts b/frontend/src/app/core/api.service.ts index e2807d7..7d2a200 100644 --- a/frontend/src/app/core/api.service.ts +++ b/frontend/src/app/core/api.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { Attachment, BlacklistEntry, Contact, Draft, Folder, MailList, Mailbox, MailboxCreated, Message, Profile, ServerConfig } from './models'; +import { Attachment, BlacklistEntry, Contact, Draft, Folder, MailList, Mailbox, MailboxCreated, MailboxEntry, Message, Profile, ServerConfig } from './models'; /** The mail server REST API (same origin, /api). */ @Injectable({ providedIn: 'root' }) @@ -19,8 +19,22 @@ export class ApiService { // mailboxes — administrators only mailboxes(): Observable { return this.http.get('/api/admin/mailboxes'); } - createMailbox(email: string, displayName: string, password: string): Observable { - return this.http.post('/api/admin/mailboxes', { email, displayName, password }); + createMailbox(email: string, displayName: string, password: string, shared = false): Observable { + return this.http.post('/api/admin/mailboxes', { email, displayName, password, shared }); + } + setMailboxMembers(id: number, members: string[]): Observable { + return this.http.put(`/api/admin/mailboxes/${id}/members`, { members }); + } + // the logged-in user's mailboxes (left menu) + myMailboxes(): Observable { return this.http.get('/api/mailboxes'); } + mailboxSession(email: string): Observable<{ token: string; email: string }> { + return this.http.post<{ token: string; email: string }>(`/api/mailboxes/${encodeURIComponent(email)}/session`, {}); + } + attachMailbox(email: string, password: string): Observable { + return this.http.post('/api/mailboxes/attach', { email, password }); + } + leaveMailbox(email: string): Observable { + return this.http.delete(`/api/mailboxes/${encodeURIComponent(email)}`); } newMailboxPassword(id: number): Observable { return this.http.post(`/api/admin/mailboxes/${id}/password`, {}); @@ -67,8 +81,8 @@ export class ApiService { // compose createDraft(draft: Draft): Observable { return this.http.post('/api/drafts', draft); } updateDraft(id: number, draft: Draft): Observable { return this.http.put(`/api/drafts/${id}`, draft); } - send(draft: Draft, draftId: number | null): Observable { - return this.http.post('/api/send', { ...draft, draftId }); + send(draft: Draft, draftId: number | null, replyToId: number | null = null): Observable { + return this.http.post('/api/send', { ...draft, draftId, replyToId }); } // attachments diff --git a/frontend/src/app/core/auth.service.ts b/frontend/src/app/core/auth.service.ts index 4ea269c..ab54659 100644 --- a/frontend/src/app/core/auth.service.ts +++ b/frontend/src/app/core/auth.service.ts @@ -40,6 +40,18 @@ export class AuthService { this.openedBy.set(read('adminEmail')); } + /** + * Switch to another mailbox of the same login (left menu). Going back to the mailbox an administrator came + * from ends the administrator's visit. + */ + switchTo(response: TokenResponse): void { + if (this.openedBy() && response.email === this.openedBy()) { + this.backToAdmin(); + return; + } + this.store(response); + } + /** Back to the administrator's own mailbox. */ backToAdmin(): void { const token = read('adminToken'); diff --git a/frontend/src/app/core/models.ts b/frontend/src/app/core/models.ts index 46cb9ca..e92f304 100644 --- a/frontend/src/app/core/models.ts +++ b/frontend/src/app/core/models.ts @@ -23,6 +23,16 @@ export interface Message { receivedAt: string; hasAttachments: boolean; attachmentCount: number; + answeredBy?: string | null; // login that answered it from the web client (shared mailboxes) +} + +/** MailboxAccess.Entry — a mailbox of the logged-in user (left menu) */ +export interface MailboxEntry { + email: string; + displayName: string; + kind: 'OWN' | 'MEMBER' | 'ATTACHED'; + shared: boolean; + unread: number; } export interface Attachment { @@ -63,7 +73,9 @@ export interface Mailbox { displayName: string; active: boolean; admin: boolean; - trap: boolean; // what it sends stays in its own INBOX, nothing leaves the server (staging@) + trap: boolean; // what it sends to outside addresses stays in its own INBOX (staging@) + shared: boolean; // shared mailbox: no login of its own, members open it + members: string[]; // who may open it usedBytes: number; quotaBytes: number; createdAt: string; diff --git a/frontend/src/app/pages/compose/compose.page.ts b/frontend/src/app/pages/compose/compose.page.ts index ecb55a2..b59b032 100644 --- a/frontend/src/app/pages/compose/compose.page.ts +++ b/frontend/src/app/pages/compose/compose.page.ts @@ -141,6 +141,8 @@ export class ComposePage { readonly body = signal(''); readonly showCc = signal(false); readonly draftId = signal(null); + /** the message being answered: marked answered (and by whom) when this one is sent */ + private replyToId: number | null = null; readonly attachments = signal([]); readonly dirty = signal(false); readonly saving = signal(false); @@ -207,6 +209,7 @@ export class ComposePage { } private fromReply(m: Message, all: boolean): void { + this.replyToId = m.id; const me = (this.state.profile()?.email ?? '').toLowerCase(); this.to.set(m.senderEmail); if (all) { @@ -306,7 +309,7 @@ export class ComposePage { const go = () => { this.sending.set(true); this.error.set(''); - this.api.send(this.current(), this.draftId()).subscribe({ + this.api.send(this.current(), this.draftId(), this.replyToId).subscribe({ next: () => { this.done = true; this.toasts.ok('Message sent'); diff --git a/frontend/src/app/pages/message/message.page.ts b/frontend/src/app/pages/message/message.page.ts index eeda981..356dbe4 100644 --- a/frontend/src/app/pages/message/message.page.ts +++ b/frontend/src/app/pages/message/message.page.ts @@ -73,6 +73,10 @@ import { ToastService } from '../../ui/toast.service'; }
Date
{{ m.receivedAt | date: 'EEEE, d MMMM y, HH:mm' }}
+ @if (m.answeredBy) { +
Answered
+
reply by {{ m.answeredBy }}
+ } diff --git a/frontend/src/app/pages/settings/mailboxes.section.ts b/frontend/src/app/pages/settings/mailboxes.section.ts index 4b02ba5..9a76654 100644 --- a/frontend/src/app/pages/settings/mailboxes.section.ts +++ b/frontend/src/app/pages/settings/mailboxes.section.ts @@ -43,6 +43,10 @@ import { AuthService } from '../../core/auth.service'; {{ showPw() ? 'visibility_off' : 'visibility' }} + @if (error()) { } @@ -63,6 +67,10 @@ import { AuthService } from '../../core/auth.service'; {{ m.email }} {{ m.displayName }} @if (m.admin) { ADMIN } + @if (m.shared) { SHARED · {{ m.members.length }} } + @if (!m.shared && m.members.length) { +{{ m.members.length }} } @if (m.trap) { TRAP } {{ size(m.usedBytes) }} @@ -70,16 +78,38 @@ import { AuthService } from '../../core/auth.service'; } + - + @if (!m.shared) { + + } @if (m.email !== me()) { } + @if (membersOf() === m.id) { +
+
Who may open {{ m.email }} from their own mailbox:
+
+ @for (o of mailboxes(); track o.id) { + @if (o.id !== m.id && !o.shared) { + + } + } +
+
+ + +
+
+ } } @@ -104,6 +134,10 @@ export class MailboxesSection { readonly domain = signal(''); name = ''; password = ''; + shared = false; + /** mailbox whose members are being edited */ + readonly membersOf = signal(null); + readonly picked = signal>(new Set()); constructor() { this.domain.set(this.me().split('@')[1] ?? ''); @@ -128,11 +162,12 @@ export class MailboxesSection { const email = local + '@' + this.domain(); this.error.set(''); this.busy.set(true); - this.api.createMailbox(email, this.name.trim(), this.password).subscribe({ + this.api.createMailbox(email, this.name.trim(), this.password, this.shared).subscribe({ next: created => { this.busy.set(false); this.local.set(''); this.name = this.password = ''; + this.shared = false; if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); } this.toasts.ok('Mailbox ' + created.mailbox.email + ' created'); this.load(); @@ -166,6 +201,25 @@ export class MailboxesSection { }); } + editMembers(m: Mailbox): void { + if (this.membersOf() === m.id) { this.membersOf.set(null); return; } + this.picked.set(new Set(m.members)); + this.membersOf.set(m.id); + } + + pick(email: string): void { + const next = new Set(this.picked()); + if (next.has(email)) { next.delete(email); } else { next.add(email); } + this.picked.set(next); + } + + saveMembers(m: Mailbox): void { + this.api.setMailboxMembers(m.id, [...this.picked()]).subscribe({ + next: () => { this.toasts.ok('Members of ' + m.email + ' saved'); this.membersOf.set(null); this.load(); }, + error: e => this.toasts.error(errorText(e)) + }); + } + toggle(m: Mailbox): void { this.api.setMailboxActive(m.id, !m.active).subscribe({ next: () => this.load(), diff --git a/frontend/src/app/pages/shell/shell.component.ts b/frontend/src/app/pages/shell/shell.component.ts index 0fefc77..858e944 100644 --- a/frontend/src/app/pages/shell/shell.component.ts +++ b/frontend/src/app/pages/shell/shell.component.ts @@ -8,7 +8,7 @@ import { filter } from 'rxjs'; import { ApiService } from '../../core/api.service'; import { AuthService } from '../../core/auth.service'; import { MailStateService } from '../../core/mail-state.service'; -import { Folder, errorText, folderIcon, folderLabel, formatBytes } from '../../core/models'; +import { Folder, MailboxEntry, errorText, folderIcon, folderLabel, formatBytes } from '../../core/models'; import { BackgroundComponent } from '../../ui/background.component'; import { DialogService } from '../../ui/dialog.service'; import { THEMES, ThemeService } from '../../ui/theme.service'; @@ -81,6 +81,44 @@ import { ToastService } from '../../ui/toast.service';