Несколько ящиков у одного логина, общие ящики, кто ответил, вход ящик*логин
continuous-integration/drone/pr Build is passing

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 14:27:11 +01:00
co-authored by Claude Opus 5.5
parent cf84797e68
commit c765c51cf0
36 changed files with 751 additions and 60 deletions
+7 -1
View File
@@ -14,7 +14,13 @@
**Рассылки (LISTS):** Настройки → LISTS. Письмо на адрес рассылки (`alerts-prod@`) → **каждому участнику своя копия** (своё «прочитано») в папку рассылки. Закрытая рассылка (по умолчанию) принимает письма только от вошедших отправителей (Kuma через `monitor@`, Jambotron) — снаружи 550. Адрес рассылки не может совпадать с ящиком.
**Служебное API для Jambotron** (`/api/service/**`, заголовок `Authorization: Bearer <SERVICE_TOKEN>`; токен генерирует `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 <SERVICE_TOKEN>`; токен генерирует `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 по решению владельца; сменили — обновить здесь).
@@ -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")
@@ -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<ProfileResponse> updateMe(Mono<Authentication> auth, @Valid @RequestBody Mono<ProfileUpdateRequest> 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<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> 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<User> own(Mono<Authentication> 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> user(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.flatMap(a -> userService.findByEmail(a.getName()))
@@ -150,7 +150,8 @@ public class MailController {
@PostMapping("/send")
public Mono<MessageResponse> sendMessage(Mono<Authentication> auth, @Valid @RequestBody Mono<SendRequest> requestMono) {
return auth.zipWith(requestMono)
.flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2()));
.flatMap(pair -> submissionService.send(pair.getT1().getName(),
com.mailserver.security.Actor.login(pair.getT1()), pair.getT2()));
}
// --- Drafts ---
@@ -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<String> 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<String> members) {
}
/** Exactly these members may open the mailbox (shared mailbox; for an ordinary one — delegates). */
@PutMapping("/{id}/members")
public Mono<MailboxView> members(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<MembersRequest> 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<MailboxView> 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()));
}
@@ -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<MailboxAccess.Entry> mine(Mono<Authentication> 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<LoginResponse> session(Mono<Authentication> 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<MailboxAccess.Entry> attach(Mono<Authentication> auth, @RequestBody Mono<AttachRequest> 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<Void> leave(Mono<Authentication> auth, @PathVariable String email) {
return login(auth).flatMap(me -> mailbox(email).flatMap(target -> access.revoke(target.getId(), me.getId())
.flatMap(done -> done ? Mono.<Void>empty()
: Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Not in your mailboxes")))));
}
private Mono<User> login(Mono<Authentication> 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<User> mailbox(String email) {
return users.findOptional(email)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox")));
}
}
@@ -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().<Void>build() : ResponseEntity.notFound().<Void>build());
}
public record SharedRequest(@Size(max = 100) String displayName, List<String> members) {
}
public record SharedView(String email, String displayName, List<String> members) {
}
/** Shared mailbox (support@, billing@): created when missing, exactly these members. */
@PutMapping("/shared/{address}")
public Mono<SharedView> saveShared(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String address, @RequestBody Mono<SharedRequest> 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<User> update(User user, AccountRequest request) {
Mono<User> 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"));
}
@@ -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; }
@@ -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; }
}
@@ -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; }
@@ -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; }
@@ -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
@@ -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());
}
}
@@ -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));
@@ -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<Void> setMembers(User mailbox, List<String> members) {
List<String> wanted = members == null ? List.of()
: members.stream().map(m -> m.trim().toLowerCase(Locale.ROOT)).filter(m -> !m.isEmpty()).distinct().toList();
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() ? 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<User> saveShared(String rawAddress, String displayName, List<String> 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);
@@ -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;
}
@@ -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<Boolean> 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<Entry> mailboxes(User login) {
Mono<Entry> own = unread(login.getId()).map(n -> new Entry(login.getEmail(), login.getDisplayName(), "OWN", false, n));
Flux<Entry> 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<Long> 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<Void> 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<Boolean> 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<Void> setMembers(Long mailboxId, Collection<Long> 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<String> 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<Long> 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<Void> 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();
}
}
@@ -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<MessageResponse> 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<MessageResponse> send(String fromEmail, String login, SendRequest request) {
List<String> to = addresses(request.getTo());
List<String> 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.<Void>empty() : mailService.finishDraft(draftId, sent.getId()))
.flatMap(sent -> (request.getReplyToId() == null ? Mono.<Void>empty()
: access.markAnswered(request.getReplyToId(), user.getId(), login))
.then(draftId == null ? Mono.<Void>empty() : mailService.finishDraft(draftId, sent.getId()))
.then(mailService.getMessage(sent.getId(), user.getId()))));
}
@@ -98,6 +98,11 @@ public class UserService {
return userRepository.save(user);
}
public Mono<User> setShared(User user, boolean shared) {
user.setShared(shared);
return userRepository.save(user);
}
public Mono<User> setTrap(User user, boolean trap) {
user.setTrap(trap);
return userRepository.save(user);
@@ -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
@@ -32,8 +32,11 @@ public interface SmtpBackend {
record Envelope(String remoteIp, String helo, String mailFrom, String authenticatedUser, List<Recipient> 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);
@@ -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);
@@ -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()
@@ -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);
@@ -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<Authentication> as(String email) {
@@ -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();
@@ -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();
}
}
@@ -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
@@ -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
+19 -5
View File
@@ -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<Mailbox[]> { return this.http.get<Mailbox[]>('/api/admin/mailboxes'); }
createMailbox(email: string, displayName: string, password: string): Observable<MailboxCreated> {
return this.http.post<MailboxCreated>('/api/admin/mailboxes', { email, displayName, password });
createMailbox(email: string, displayName: string, password: string, shared = false): Observable<MailboxCreated> {
return this.http.post<MailboxCreated>('/api/admin/mailboxes', { email, displayName, password, shared });
}
setMailboxMembers(id: number, members: string[]): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/members`, { members });
}
// the logged-in user's mailboxes (left menu)
myMailboxes(): Observable<MailboxEntry[]> { return this.http.get<MailboxEntry[]>('/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<MailboxEntry> {
return this.http.post<MailboxEntry>('/api/mailboxes/attach', { email, password });
}
leaveMailbox(email: string): Observable<void> {
return this.http.delete<void>(`/api/mailboxes/${encodeURIComponent(email)}`);
}
newMailboxPassword(id: number): Observable<MailboxCreated> {
return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {});
@@ -67,8 +81,8 @@ export class ApiService {
// compose
createDraft(draft: Draft): Observable<Message> { return this.http.post<Message>('/api/drafts', draft); }
updateDraft(id: number, draft: Draft): Observable<Message> { return this.http.put<Message>(`/api/drafts/${id}`, draft); }
send(draft: Draft, draftId: number | null): Observable<Message> {
return this.http.post<Message>('/api/send', { ...draft, draftId });
send(draft: Draft, draftId: number | null, replyToId: number | null = null): Observable<Message> {
return this.http.post<Message>('/api/send', { ...draft, draftId, replyToId });
}
// attachments
+12
View File
@@ -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');
+13 -1
View File
@@ -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;
@@ -141,6 +141,8 @@ export class ComposePage {
readonly body = signal('');
readonly showCc = signal(false);
readonly draftId = signal<number | null>(null);
/** the message being answered: marked answered (and by whom) when this one is sent */
private replyToId: number | null = null;
readonly attachments = signal<Attachment[]>([]);
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');
@@ -73,6 +73,10 @@ import { ToastService } from '../../ui/toast.service';
}
<dt class="text-slate-500 font-mono text-xs pt-0.5">Date</dt>
<dd class="text-slate-400 font-mono text-xs pt-0.5">{{ m.receivedAt | date: 'EEEE, d MMMM y, HH:mm' }}</dd>
@if (m.answeredBy) {
<dt class="text-slate-500 font-mono text-xs pt-0.5">Answered</dt>
<dd class="text-emerald-300 font-mono text-xs pt-0.5 flex items-center gap-1"><mat-icon class="!text-sm">reply</mat-icon> by {{ m.answeredBy }}</dd>
}
</dl>
</div>
@@ -43,6 +43,10 @@ import { AuthService } from '../../core/auth.service';
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div>
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" [disabled]="busy()"><mat-icon>add</mat-icon> CREATE</button>
<label class="md:col-span-4 flex items-center gap-1.5 text-sm text-slate-300">
<input type="checkbox" name="shared" [(ngModel)]="shared">
shared mailbox (support&#64;, billing&#64;): no password of its own — members open it from their mailbox, read/unread is common
</label>
</form>
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
@@ -63,6 +67,10 @@ import { AuthService } from '../../core/auth.service';
<span class="font-mono text-sm" [class.text-cyan-300]="m.active" [class.text-slate-500]="!m.active" [class.line-through]="!m.active">{{ m.email }}</span>
<span class="text-sm text-slate-400">{{ m.displayName }}</span>
@if (m.admin) { <span class="text-[10px] font-mono px-1.5 rounded border border-pink-500/40 text-pink-300">ADMIN</span> }
@if (m.shared) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/50 text-cyan-300"
[title]="'Members: ' + (m.members.join(', ') || 'none')">SHARED · {{ m.members.length }}</span> }
@if (!m.shared && m.members.length) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/30 text-cyan-300/80"
[title]="'Also opened by: ' + m.members.join(', ')">+{{ m.members.length }}</span> }
@if (m.trap) { <span class="text-[10px] font-mono px-1.5 rounded border border-amber-400/50 text-amber-300"
title="What it sends is kept in its own INBOX — nothing leaves the server">TRAP</span> }
<span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
@@ -70,16 +78,38 @@ import { AuthService } from '../../core/auth.service';
<button type="button" class="ghost-btn !py-1" (click)="open(m)" [attr.aria-label]="'Open ' + m.email" title="Open mailbox">
<mat-icon>mark_email_read</mat-icon> OPEN</button>
}
<button type="button" class="ghost-btn !py-1" (click)="editMembers(m)" [attr.aria-expanded]="membersOf() === m.id"
[attr.aria-label]="'Who may open ' + m.email" title="Who may open this mailbox"><mat-icon>group</mat-icon></button>
<button type="button" class="ghost-btn !py-1" (click)="trap(m)" [class.text-amber-300]="m.trap"
[attr.aria-label]="(m.trap ? 'Trap off for ' : 'Trap on for ') + m.email"
[title]="m.trap ? 'Trap ON: sent mail stays in its INBOX. Click to send for real' : 'Trap: keep what it sends in its own INBOX'">
<mat-icon>{{ m.trap ? 'move_to_inbox' : 'outbox' }}</mat-icon></button>
<button type="button" class="ghost-btn !py-1" (click)="newPassword(m)" [attr.aria-label]="'New password for ' + m.email"><mat-icon>key</mat-icon></button>
@if (!m.shared) {
<button type="button" class="ghost-btn !py-1" (click)="newPassword(m)" [attr.aria-label]="'New password for ' + m.email"><mat-icon>key</mat-icon></button>
}
@if (m.email !== me()) {
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)"
[attr.aria-label]="(m.active ? 'Switch off ' : 'Switch on ') + m.email">
<mat-icon>{{ m.active ? 'toggle_on' : 'toggle_off' }}</mat-icon></button>
}
@if (membersOf() === m.id) {
<div class="basis-full mt-1 p-2 rounded-lg border border-cyan-500/20">
<div class="text-xs text-slate-400 mb-1">Who may open <span class="font-mono text-cyan-300">{{ m.email }}</span> from their own mailbox:</div>
<div class="flex flex-wrap gap-x-4 gap-y-1">
@for (o of mailboxes(); track o.id) {
@if (o.id !== m.id && !o.shared) {
<label class="flex items-center gap-1.5 text-sm font-mono">
<input type="checkbox" [checked]="picked().has(o.email)" (change)="pick(o.email)"> {{ o.email }}
</label>
}
}
</div>
<div class="flex gap-2 mt-2">
<button type="button" class="cyber-btn px-3 py-1 rounded-lg text-xs font-bold" (click)="saveMembers(m)"><mat-icon>save</mat-icon> SAVE</button>
<button type="button" class="ghost-btn !py-1" (click)="membersOf.set(null)">CANCEL</button>
</div>
</div>
}
</li>
}
</ul>
@@ -104,6 +134,10 @@ export class MailboxesSection {
readonly domain = signal('');
name = '';
password = '';
shared = false;
/** mailbox whose members are being edited */
readonly membersOf = signal<number | null>(null);
readonly picked = signal<Set<string>>(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(),
@@ -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';
<div class="flex-grow max-w-[1500px] w-full mx-auto p-3 md:p-5 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-5">
<nav class="glass-panel static p-4 md:pt-6 h-fit lg:sticky lg:top-20 lg:block" [class.hidden]="!menuOpen()"
aria-label="Mail folders">
<div class="flex items-center justify-between mb-2 px-1">
<h2 class="text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest">MAILBOXES</h2>
<button type="button" class="text-slate-400 hover:text-cyan-300" matTooltip="Add a mailbox you have the password of"
aria-label="Add mailbox" [attr.aria-expanded]="attachOpen()" (click)="attachOpen.set(!attachOpen())">
<mat-icon class="!text-xl">add_circle_outline</mat-icon></button>
</div>
<ul class="space-y-1 mb-3" aria-label="Your mailboxes">
@for (b of boxes(); track b.email) {
<li class="flex items-center gap-1">
<button type="button" (click)="switchTo(b)" [attr.aria-current]="b.email === auth.email() ? 'true' : null"
class="flex-grow flex items-center gap-2 px-3 py-1.5 rounded-lg border text-left font-rajdhani font-semibold min-w-0"
[class.border-pink-500/40]="b.email === auth.email()" [class.bg-pink-500/15]="b.email === auth.email()"
[class.text-pink-200]="b.email === auth.email()" [class.border-transparent]="b.email !== auth.email()"
[class.text-slate-300]="b.email !== auth.email()"
[matTooltip]="b.email + (b.shared ? ' — shared' : b.kind === 'ATTACHED' ? ' — attached' : '')">
<mat-icon class="!text-lg shrink-0">{{ b.shared ? 'groups' : b.kind === 'OWN' ? 'person' : 'link' }}</mat-icon>
<span class="flex-grow truncate text-sm">{{ b.email }}</span>
@if (b.unread) {
<span class="text-[11px] font-mono px-2 rounded-full bg-pink-500/25 text-pink-300" [attr.aria-label]="b.unread + ' unread'">{{ b.unread }}</span>
}
</button>
@if (b.kind !== 'OWN') {
<button type="button" class="text-slate-500 hover:text-pink-400" [attr.aria-label]="'Remove ' + b.email + ' from my mailboxes'"
matTooltip="Remove from my mailboxes" (click)="leave(b)"><mat-icon class="!text-base">close</mat-icon></button>
}
</li>
}
</ul>
@if (attachOpen()) {
<form class="space-y-2 mb-4 p-2 rounded-lg border border-cyan-500/20" (ngSubmit)="attach()" aria-label="Add a mailbox">
<label class="synth-label" for="att-email">Mailbox</label>
<input id="att-email" name="attEmail" class="synth-input !py-1.5" [(ngModel)]="attachEmail" autocomplete="off" placeholder="name&#64;domain">
<label class="synth-label" for="att-pw">Its password</label>
<input id="att-pw" name="attPw" type="password" class="synth-input !py-1.5" [(ngModel)]="attachPassword" autocomplete="off">
<button type="submit" class="cyber-btn w-full py-1.5 rounded-lg text-xs font-bold"><mat-icon>link</mat-icon> ADD</button>
</form>
}
<a routerLink="/compose" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-xs mb-4" (click)="menuOpen.set(false)">
<mat-icon>edit</mat-icon> COMPOSE
</a>
@@ -165,6 +203,11 @@ export class ShellComponent {
readonly themes = THEMES;
readonly menuOpen = signal(false);
/** own, shared and attached mailboxes of the login (shown when there is more than the own one, or to add one) */
readonly boxes = signal<MailboxEntry[]>([]);
readonly attachOpen = signal(false);
attachEmail = '';
attachPassword = '';
query = '';
readonly links = [
@@ -195,7 +238,8 @@ export class ShellComponent {
constructor() {
this.state.refreshFolders();
this.state.refreshProfile();
const timer = setInterval(() => this.state.refreshFolders(), 30000);
this.loadBoxes();
const timer = setInterval(() => { this.state.refreshFolders(); this.loadBoxes(); }, 30000);
inject(DestroyRef).onDestroy(() => clearInterval(timer));
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
const q = this.router.parseUrl(this.router.url).queryParams['q'];
@@ -256,6 +300,52 @@ export class ShellComponent {
}
}
loadBoxes(): void {
this.api.myMailboxes().subscribe({ next: list => this.boxes.set(list), error: () => {} });
}
/** Another mailbox of this login: its session, full reload — fresh folders and profile. */
switchTo(b: MailboxEntry): void {
if (b.email === this.auth.email()) { this.router.navigate(['/']); return; }
this.api.mailboxSession(b.email).subscribe({
next: session => { this.state.reset(); this.auth.switchTo(session); location.assign('/'); },
error: e => this.toasts.error(errorText(e))
});
}
attach(): void {
const email = this.attachEmail.trim().toLowerCase();
if (!email || !this.attachPassword) { this.toasts.error('Mailbox and its password'); return; }
this.api.attachMailbox(email, this.attachPassword).subscribe({
next: b => {
this.attachEmail = this.attachPassword = '';
this.attachOpen.set(false);
this.toasts.ok(b.email + ' added to your mailboxes');
this.loadBoxes();
},
error: e => this.toasts.error(errorText(e))
});
}
leave(b: MailboxEntry): void {
this.dialogs.confirm(`Remove ${b.email}?`, b.shared
? 'It disappears from your menu. An administrator can give it back.'
: 'It disappears from your menu. Add it again with its password.', 'Remove', true).subscribe(ok => {
if (ok === null) return;
this.api.leaveMailbox(b.email).subscribe({
next: () => {
this.toasts.ok(b.email + ' removed');
if (b.email === this.auth.email()) {
const own = this.boxes().find(x => x.kind === 'OWN');
if (own) { this.switchTo(own); return; }
}
this.loadBoxes();
},
error: e => this.toasts.error(errorText(e))
});
});
}
backToAdmin(): void {
this.auth.backToAdmin();
location.assign('/settings');