Несколько ящиков у одного логина, общие ящики, «кто ответил», вход ящик*логин #27

Merged
Liosha84 merged 1 commits from feat/multi-mailbox into master 2026-11-07 14:45:26 +01:00
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. Адрес рассылки не может совпадать с ящиком. **Рассылки (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}`. `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 по решению владельца; сменили — обновить здесь). **Администратор:** `admin@jambocheck.com`, пароль `eK-6oj3TMtZgoXVxlvtCWFI6` (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь).
@@ -27,8 +27,9 @@ public class ImapConfig {
@Bean @Bean
public ImapStore imapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService, public ImapStore imapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
MessageRepository messages, AttachmentRepository attachments, MessageSources sources, MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
@Value("${mailserver.smtp.hostname:localhost}") String hostname) { @Value("${mailserver.smtp.hostname:localhost}") String hostname,
return new DbImapStore(db, users, userService, mailService, messages, attachments, sources, hostname); com.mailserver.service.MailboxAccess access) {
return new DbImapStore(db, users, userService, mailService, messages, attachments, sources, hostname, access);
} }
@Bean(initMethod = "start", destroyMethod = "stop") @Bean(initMethod = "start", destroyMethod = "stop")
@@ -99,6 +99,10 @@ public class AuthenticationController {
return delay.isZero() ? answer : answer.delayElement(delay); return delay.isZero() ? answer : answer.delayElement(delay);
} }
guard.success(ip, email); 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()) { if (!user.isActive()) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated"))); return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated")));
} }
@@ -138,7 +142,7 @@ public class AuthenticationController {
@PutMapping("/me") @PutMapping("/me")
public Mono<ProfileResponse> updateMe(Mono<Authentication> auth, @Valid @RequestBody Mono<ProfileUpdateRequest> body) { 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(pair -> userService.updateDisplayName(pair.getT1(), pair.getT2().displayName()))
.flatMap(this::profile); .flatMap(this::profile);
} }
@@ -147,7 +151,7 @@ public class AuthenticationController {
public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> body, public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> body,
ServerHttpRequest http) { ServerHttpRequest http) {
String ip = clientIp(http); String ip = clientIp(http);
return user(auth).zipWith(body) return own(auth).zipWith(body)
.flatMap(pair -> { .flatMap(pair -> {
User user = pair.getT1(); User user = pair.getT1();
// Someone with a stolen session must not guess the current password either // Someone with a stolen session must not guess the current password either
@@ -186,6 +190,15 @@ public class AuthenticationController {
admins.isAdmin(user.getEmail()))); 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) { private Mono<User> user(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.flatMap(a -> userService.findByEmail(a.getName())) .flatMap(a -> userService.findByEmail(a.getName()))
@@ -150,7 +150,8 @@ public class MailController {
@PostMapping("/send") @PostMapping("/send")
public Mono<MessageResponse> sendMessage(Mono<Authentication> auth, @Valid @RequestBody Mono<SendRequest> requestMono) { public Mono<MessageResponse> sendMessage(Mono<Authentication> auth, @Valid @RequestBody Mono<SendRequest> requestMono) {
return auth.zipWith(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 --- // --- Drafts ---
@@ -6,6 +6,7 @@ import com.mailserver.domain.User;
import com.mailserver.dto.LoginResponse; import com.mailserver.dto.LoginResponse;
import com.mailserver.service.MailDirectory; import com.mailserver.service.MailDirectory;
import com.mailserver.service.MailLists; import com.mailserver.service.MailLists;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.MailService; import com.mailserver.service.MailService;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils; import com.mailserver.util.JwtUtils;
@@ -45,12 +46,13 @@ public class MailboxAdminController {
private static final int MIN_PASSWORD = MailDirectory.MIN_PASSWORD; private static final int MIN_PASSWORD = MailDirectory.MIN_PASSWORD;
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap, 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 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, 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) { public record PasswordRequest(@Size(max = 200) String password) {
@@ -73,9 +75,11 @@ public class MailboxAdminController {
private final JwtUtils jwt; private final JwtUtils jwt;
private final MailDirectory directory; private final MailDirectory directory;
private final MailLists lists; private final MailLists lists;
private final MailboxAccess access;
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt, public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt,
MailDirectory directory, MailLists lists) { MailDirectory directory, MailLists lists, MailboxAccess access) {
this.access = access;
this.jwt = jwt; this.jwt = jwt;
this.directory = directory; this.directory = directory;
this.lists = lists; this.lists = lists;
@@ -100,11 +104,13 @@ public class MailboxAdminController {
if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) { if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) {
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one")); 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) 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) .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 -> { return admin(auth).flatMap(me -> mailbox(id).map(user -> {
log.warn("Administrator {} opened mailbox {}", me, user.getEmail()); log.warn("Administrator {} opened mailbox {}", me, user.getEmail());
LoginResponse response = new LoginResponse(); LoginResponse response = new LoginResponse();
response.setToken(jwt.generateToken(user.getEmail())); response.setToken(jwt.generateToken(user.getEmail(), me));
response.setEmail(user.getEmail()); response.setEmail(user.getEmail());
return response; return response;
})); }));
@@ -161,6 +167,19 @@ public class MailboxAdminController {
}))).flatMap(this::view); }))).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) --- // --- Mailing lists (Settings → LISTS) ---
public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside, 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) { private Mono<MailboxView> view(User user) {
return mail.usedBytes(user.getId()).defaultIfEmpty(0L) return mail.usedBytes(user.getId()).defaultIfEmpty(0L).zipWith(access.members(user.getId()).collectList())
.map(used -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(), .map(pair -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
admins.isAdmin(user.getEmail()), user.isTrap(), used, user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), admins.isAdmin(user.getEmail()), user.isTrap(), user.isShared(), pair.getT2(), pair.getT1(), user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
user.getCreatedAt())); 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.domain.User;
import com.mailserver.service.MailDirectory; import com.mailserver.service.MailDirectory;
import com.mailserver.service.MailLists; import com.mailserver.service.MailLists;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -57,9 +58,11 @@ public class ServiceController {
private final UserService users; private final UserService users;
private final MailLists lists; private final MailLists lists;
private final MailDirectory directory; private final MailDirectory directory;
private final MailboxAccess access;
public ServiceController(@Value("${mailserver.service-token:}") String token, UserService users, MailLists lists, 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.token = token.isBlank() ? null : token.trim().getBytes(StandardCharsets.UTF_8);
this.users = users; this.users = users;
this.lists = lists; this.lists = lists;
@@ -131,6 +134,23 @@ public class ServiceController {
? ResponseEntity.noContent().<Void>build() : ResponseEntity.notFound().<Void>build()); ? 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) { private Mono<User> update(User user, AccountRequest request) {
Mono<User> named = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user) Mono<User> named = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user)
: users.updateDisplayName(user, request.displayName()); : users.updateDisplayName(user, request.displayName());
@@ -139,7 +159,8 @@ public class ServiceController {
} }
boolean on = request.active(); boolean on = request.active();
return named.flatMap(u -> users.setActive(u, on)) 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")); .doOnNext(u -> log.info("Mailbox {} {} by Jambotron", u.getEmail(), on ? "switched on" : "switched off"));
} }
@@ -54,6 +54,13 @@ public class Message {
@Column("message_id") @Column("message_id")
private String messageId; 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 Long getId() { return id; }
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
@@ -34,6 +34,10 @@ public class User {
@Column("is_trap") @Column("is_trap")
private boolean isTrap = false; 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") @Column("created_at")
private Instant createdAt; private Instant createdAt;
@@ -61,6 +65,9 @@ public class User {
public boolean isTrap() { return isTrap; } public boolean isTrap() { return isTrap; }
public void setTrap(boolean trap) { isTrap = trap; } 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 Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
} }
@@ -20,6 +20,11 @@ public class MessageResponse {
private Long folderId; private Long folderId;
private boolean hasAttachments; private boolean hasAttachments;
private int attachmentCount; 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 Long getId() { return id; }
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
@@ -26,6 +26,12 @@ public class SendRequest {
private Long draftId; 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 String getTo() { return to; }
public void setTo(String to) { this.to = 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.repository.UserRepository;
import com.mailserver.service.LocalDelivery; import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailService; import com.mailserver.service.MailService;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.MessageSources; import com.mailserver.service.MessageSources;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import jakarta.mail.Session; import jakarta.mail.Session;
@@ -45,10 +46,12 @@ public class DbImapStore implements ImapStore {
private final AttachmentRepository attachments; private final AttachmentRepository attachments;
private final MessageSources sources; private final MessageSources sources;
private final String hostname; private final String hostname;
private final MailboxAccess access;
public DbImapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService, public DbImapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
MessageRepository messages, AttachmentRepository attachments, MessageSources sources, MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
String hostname) { String hostname, MailboxAccess access) {
this.access = access;
this.db = db; this.db = db;
this.users = users; this.users = users;
this.userService = userService; this.userService = userService;
@@ -61,11 +64,9 @@ public class DbImapStore implements ImapStore {
@Override @Override
public Account authenticate(String username, String password) { public Account authenticate(String username, String password) {
User user = users.findByEmail(username.trim().toLowerCase(Locale.ROOT)).block(); // login or mailbox*login (shared / attached mailbox in a mail program)
if (user == null || !user.isActive() || !userService.checkPassword(user, password)) { User user = access.login(username, password);
return null; return user == null ? null : new Account(user.getId(), user.getEmail());
}
return new Account(user.getId(), user.getEmail());
} }
@Override @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()); UserDetails userDetails = new User(email, "", Collections.emptyList());
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, Collections.emptyList()); 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) return chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication)); .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication));
@@ -29,8 +29,10 @@ public class MailDirectory {
private final UserService users; private final UserService users;
private final MailLists lists; private final MailLists lists;
private final MailDomains domains; 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.users = users;
this.lists = lists; this.lists = lists;
this.domains = domains; 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() { public static String randomPassword() {
byte[] bytes = new byte[18]; byte[] bytes = new byte[18];
new SecureRandom().nextBytes(bytes); new SecureRandom().nextBytes(bytes);
@@ -480,6 +480,7 @@ public class MailService {
resp.setMessageSize(message.getMessageSize()); resp.setMessageSize(message.getMessageSize());
resp.setReceivedAt(message.getReceivedAt()); resp.setReceivedAt(message.getReceivedAt());
resp.setFolderId(message.getFolderId()); resp.setFolderId(message.getFolderId());
resp.setAnsweredBy(message.getAnsweredBy());
return resp; 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 MailService mailService;
private final MailRouter router; private final MailRouter router;
private final String hostname; private final String hostname;
private final MailboxAccess access;
public SubmissionService(UserRepository users, MailService mailService, MailRouter router, 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.users = users;
this.mailService = mailService; this.mailService = mailService;
this.router = router; this.router = router;
@@ -60,6 +62,11 @@ public class SubmissionService {
} }
public Mono<MessageResponse> send(String fromEmail, SendRequest request) { 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> to = addresses(request.getTo());
List<String> cc = addresses(request.getCc()); List<String> cc = addresses(request.getCc());
cc.removeAll(to); cc.removeAll(to);
@@ -78,7 +85,9 @@ public class SubmissionService {
.subscribeOn(Schedulers.boundedElastic())) .subscribeOn(Schedulers.boundedElastic()))
.flatMap(messageId -> mailService.saveSent(user, String.join(", ", to), String.join(", ", cc), .flatMap(messageId -> mailService.saveSent(user, String.join(", ", to), String.join(", ", cc),
request.getSubject(), request.getBody(), request.isHtml(), messageId)) 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())))); .then(mailService.getMessage(sent.getId(), user.getId()))));
} }
@@ -98,6 +98,11 @@ public class UserService {
return userRepository.save(user); 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) { public Mono<User> setTrap(User user, boolean trap) {
user.setTrap(trap); user.setTrap(trap);
return userRepository.save(user); return userRepository.save(user);
@@ -8,6 +8,7 @@ import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService; import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery; import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailLists; import com.mailserver.service.MailLists;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -38,9 +39,12 @@ public class MailRouter implements SmtpBackend {
private final DkimService dkim; private final DkimService dkim;
private final SmtpDeliveryClient client; private final SmtpDeliveryClient client;
private final MailLists lists; private final MailLists lists;
private final MailboxAccess access;
public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery, 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.domains = domains;
this.users = users; this.users = users;
this.userService = userService; this.userService = userService;
@@ -52,9 +56,9 @@ public class MailRouter implements SmtpBackend {
} }
@Override @Override
public boolean authenticate(String username, String password) { public String authenticate(String username, String password) {
User user = users.findByEmail(username.toLowerCase(Locale.ROOT)).block(); User mailbox = access.login(username, password);
return user != null && user.isActive() && userService.checkPassword(user, password); return mailbox == null ? null : mailbox.getEmail();
} }
@Override @Override
@@ -32,8 +32,11 @@ public interface SmtpBackend {
record Envelope(String remoteIp, String helo, String mailFrom, String authenticatedUser, List<Recipient> recipients) { 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); Route route(String recipient, String authenticatedUser);
@@ -373,12 +373,13 @@ public class SmtpServer {
closing = true; closing = true;
return reply("421 4.7.0 Too many failed logins, try again in " + LoginGuard.human(wait)); return reply("421 4.7.0 Too many failed logins, try again in " + LoginGuard.human(wait));
} }
return blocking(() -> backend.authenticate(username, password)) return blocking(() -> java.util.Optional.ofNullable(backend.authenticate(username, password)))
.flatMap(ok -> { .flatMap(found -> {
if (ok) { if (found.isPresent()) {
guard.success(remoteIp, username); guard.success(remoteIp, username);
authenticatedUser = username; authenticatedUser = found.get();
log.info("SMTP {}: authenticated as {}", remoteIp, username); 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"); return Mono.just("235 2.7.0 Authentication successful\r\n");
} }
guard.failure(remoteIp, username); guard.failure(remoteIp, username);
@@ -18,11 +18,20 @@ public class JwtUtils {
} }
public String generateToken(String email) { 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 now = new Date();
Date expiry = new Date(now.getTime() + EXPIRATION_MS); Date expiry = new Date(now.getTime() + EXPIRATION_MS);
return Jwts.builder() return Jwts.builder()
.subject(email) .subject(email)
.claim("by", by)
.issuedAt(now) .issuedAt(now)
.expiration(expiry) .expiration(expiry)
.signWith(signingKey) .signWith(signingKey)
@@ -38,6 +47,12 @@ public class JwtUtils {
return claims.getSubject(); 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) { public boolean isValidToken(String token) {
try { try {
Jwts.parser() 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 MailAdmins admins = new MailAdmins("", "", domains);
private final com.mailserver.util.JwtUtils jwt = mock(com.mailserver.util.JwtUtils.class); 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.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 com.mailserver.service.MailboxAccess access = mock(com.mailserver.service.MailboxAccess.class);
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt, directory, lists); 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 admin = user(1L, "admin@jambocheck.com");
private final User alice = user(2L, "alice@jambocheck.com"); private final User alice = user(2L, "alice@jambocheck.com");
@@ -44,6 +45,7 @@ class MailboxAdminControllerTest {
MailboxAdminControllerTest() { MailboxAdminControllerTest() {
when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L)); when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L));
when(lists.exists(anyString())).thenReturn(Mono.just(false)); 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(lists.exists("alerts@jambocheck.com")).thenReturn(Mono.just(true));
when(users.findOptional(anyString())).thenReturn(Mono.empty()); when(users.findOptional(anyString())).thenReturn(Mono.empty());
when(users.findOptional("alice@jambocheck.com")).thenReturn(Mono.just(alice)); when(users.findOptional("alice@jambocheck.com")).thenReturn(Mono.just(alice));
@@ -89,6 +91,26 @@ class MailboxAdminControllerTest {
.isEqualTo(HttpStatus.FORBIDDEN); .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 @Test
void administratorSwitchesTrapOthersCanNot() { void administratorSwitchesTrapOthersCanNot() {
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block().trap()).isTrue(); assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block().trap()).isTrue();
@@ -117,14 +139,14 @@ class MailboxAdminControllerTest {
@Test @Test
void createsReservedNamesWithAGeneratedPasswordShownOnce() { void createsReservedNamesWithAGeneratedPasswordShownOnce() {
var created = controller.create(as("admin@jambocheck.com"), 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.mailbox().email()).isEqualTo("no-reply@jambocheck.com");
assertThat(created.password()).hasSizeGreaterThanOrEqualTo(20); assertThat(created.password()).hasSizeGreaterThanOrEqualTo(20);
verify(users).register(eq("no-reply@jambocheck.com"), eq(created.password()), eq("Jambotron")); verify(users).register(eq("no-reply@jambocheck.com"), eq(created.password()), eq("Jambotron"));
var chosen = controller.create(as("admin@jambocheck.com"), 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(); assertThat(chosen.password()).isNull();
} }
@@ -153,7 +175,7 @@ class MailboxAdminControllerTest {
@Test @Test
void administratorOpensAnyMailboxOthersCanNot() { 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(); var session = controller.open(as("admin@jambocheck.com"), 2L).block();
assertThat(session.getEmail()).isEqualTo("alice@jambocheck.com"); assertThat(session.getEmail()).isEqualTo("alice@jambocheck.com");
@@ -164,7 +186,7 @@ class MailboxAdminControllerTest {
} }
private MailboxAdminController.Created create(String me, String email, String password) { 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) { private static Mono<Authentication> as(String email) {
@@ -31,7 +31,8 @@ class ServiceControllerTest {
private final UserService users = mock(UserService.class); private final UserService users = mock(UserService.class);
private final MailLists lists = mock(MailLists.class); private final MailLists lists = mock(MailLists.class);
private final MailDomains domains = new MailDomains("jambocheck.com", ""); 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"); private final User existing = user(7L, "jambotronpw@jambocheck.com");
ServiceControllerTest() { ServiceControllerTest() {
@@ -40,6 +41,7 @@ class ServiceControllerTest {
when(lists.exists(anyString())).thenReturn(Mono.just(false)); when(lists.exists(anyString())).thenReturn(Mono.just(false));
when(lists.listsOf(anyLong())).thenReturn(Flux.just("alerts-prod@jambocheck.com")); when(lists.listsOf(anyLong())).thenReturn(Flux.just("alerts-prod@jambocheck.com"));
when(lists.removeEverywhere(anyLong())).thenReturn(Mono.just(1L)); 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.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(8L, i.getArgument(0))));
when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> { when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0); User u = i.getArgument(0);
@@ -50,7 +52,7 @@ class ServiceControllerTest {
} }
private ServiceController api(String token) { private ServiceController api(String token) {
return new ServiceController(token, users, lists, directory); return new ServiceController(token, users, lists, directory, access);
} }
@Test @Test
@@ -84,6 +86,7 @@ class ServiceControllerTest {
Mono.just(new ServiceController.AccountRequest(null, false))).block(); Mono.just(new ServiceController.AccountRequest(null, false))).block();
assertThat(off.getBody().account().active()).isFalse(); assertThat(off.getBody().account().active()).isFalse();
verify(lists).removeEverywhere(7L); verify(lists).removeEverywhere(7L);
verify(access).removeEverywhere(7L);
var reset = api("s3cret-token").password(AUTH, "jambotronpw@jambocheck.com").block(); var reset = api("s3cret-token").password(AUTH, "jambotronpw@jambocheck.com").block();
assertThat(reset.password()).isNotBlank(); 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", when(lists.find("talk@mail.test")).thenReturn(Mono.just(new MailLists.MailList(2L, "talk@mail.test",
null, null, true, List.of("bob@mail.test")))); null, null, true, List.of("bob@mail.test"))));
return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery, 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 @Test
@@ -43,8 +43,8 @@ class SmtpServerTest {
volatile boolean failStore; volatile boolean failStore;
@Override @Override
public boolean authenticate(String username, String password) { public String authenticate(String username, String password) {
return password.equals(passwords.get(username)); return password.equals(passwords.get(username)) ? username : null;
} }
@Override @Override
+19 -5
View File
@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; 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). */ /** The mail server REST API (same origin, /api). */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -19,8 +19,22 @@ export class ApiService {
// mailboxes — administrators only // mailboxes — administrators only
mailboxes(): Observable<Mailbox[]> { return this.http.get<Mailbox[]>('/api/admin/mailboxes'); } mailboxes(): Observable<Mailbox[]> { return this.http.get<Mailbox[]>('/api/admin/mailboxes'); }
createMailbox(email: string, displayName: string, password: string): Observable<MailboxCreated> { createMailbox(email: string, displayName: string, password: string, shared = false): Observable<MailboxCreated> {
return this.http.post<MailboxCreated>('/api/admin/mailboxes', { email, displayName, password }); 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> { newMailboxPassword(id: number): Observable<MailboxCreated> {
return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {}); return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {});
@@ -67,8 +81,8 @@ export class ApiService {
// compose // compose
createDraft(draft: Draft): Observable<Message> { return this.http.post<Message>('/api/drafts', draft); } 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); } updateDraft(id: number, draft: Draft): Observable<Message> { return this.http.put<Message>(`/api/drafts/${id}`, draft); }
send(draft: Draft, draftId: number | null): Observable<Message> { send(draft: Draft, draftId: number | null, replyToId: number | null = null): Observable<Message> {
return this.http.post<Message>('/api/send', { ...draft, draftId }); return this.http.post<Message>('/api/send', { ...draft, draftId, replyToId });
} }
// attachments // attachments
+12
View File
@@ -40,6 +40,18 @@ export class AuthService {
this.openedBy.set(read('adminEmail')); 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. */ /** Back to the administrator's own mailbox. */
backToAdmin(): void { backToAdmin(): void {
const token = read('adminToken'); const token = read('adminToken');
+13 -1
View File
@@ -23,6 +23,16 @@ export interface Message {
receivedAt: string; receivedAt: string;
hasAttachments: boolean; hasAttachments: boolean;
attachmentCount: number; 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 { export interface Attachment {
@@ -63,7 +73,9 @@ export interface Mailbox {
displayName: string; displayName: string;
active: boolean; active: boolean;
admin: 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; usedBytes: number;
quotaBytes: number; quotaBytes: number;
createdAt: string; createdAt: string;
@@ -141,6 +141,8 @@ export class ComposePage {
readonly body = signal(''); readonly body = signal('');
readonly showCc = signal(false); readonly showCc = signal(false);
readonly draftId = signal<number | null>(null); 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 attachments = signal<Attachment[]>([]);
readonly dirty = signal(false); readonly dirty = signal(false);
readonly saving = signal(false); readonly saving = signal(false);
@@ -207,6 +209,7 @@ export class ComposePage {
} }
private fromReply(m: Message, all: boolean): void { private fromReply(m: Message, all: boolean): void {
this.replyToId = m.id;
const me = (this.state.profile()?.email ?? '').toLowerCase(); const me = (this.state.profile()?.email ?? '').toLowerCase();
this.to.set(m.senderEmail); this.to.set(m.senderEmail);
if (all) { if (all) {
@@ -306,7 +309,7 @@ export class ComposePage {
const go = () => { const go = () => {
this.sending.set(true); this.sending.set(true);
this.error.set(''); this.error.set('');
this.api.send(this.current(), this.draftId()).subscribe({ this.api.send(this.current(), this.draftId(), this.replyToId).subscribe({
next: () => { next: () => {
this.done = true; this.done = true;
this.toasts.ok('Message sent'); 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> <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> <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> </dl>
</div> </div>
@@ -43,6 +43,10 @@ import { AuthService } from '../../core/auth.service';
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon> <mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div> </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> <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> </form>
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> } @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="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> <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.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" @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> } 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> <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"> <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> <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" <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" [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'"> [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> <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()) { @if (m.email !== me()) {
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)" <button type="button" class="ghost-btn !py-1" (click)="toggle(m)"
[attr.aria-label]="(m.active ? 'Switch off ' : 'Switch on ') + m.email"> [attr.aria-label]="(m.active ? 'Switch off ' : 'Switch on ') + m.email">
<mat-icon>{{ m.active ? 'toggle_on' : 'toggle_off' }}</mat-icon></button> <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> </li>
} }
</ul> </ul>
@@ -104,6 +134,10 @@ export class MailboxesSection {
readonly domain = signal(''); readonly domain = signal('');
name = ''; name = '';
password = ''; password = '';
shared = false;
/** mailbox whose members are being edited */
readonly membersOf = signal<number | null>(null);
readonly picked = signal<Set<string>>(new Set());
constructor() { constructor() {
this.domain.set(this.me().split('@')[1] ?? ''); this.domain.set(this.me().split('@')[1] ?? '');
@@ -128,11 +162,12 @@ export class MailboxesSection {
const email = local + '@' + this.domain(); const email = local + '@' + this.domain();
this.error.set(''); this.error.set('');
this.busy.set(true); 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 => { next: created => {
this.busy.set(false); this.busy.set(false);
this.local.set(''); this.local.set('');
this.name = this.password = ''; this.name = this.password = '';
this.shared = false;
if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); } if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); }
this.toasts.ok('Mailbox ' + created.mailbox.email + ' created'); this.toasts.ok('Mailbox ' + created.mailbox.email + ' created');
this.load(); 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 { toggle(m: Mailbox): void {
this.api.setMailboxActive(m.id, !m.active).subscribe({ this.api.setMailboxActive(m.id, !m.active).subscribe({
next: () => this.load(), next: () => this.load(),
@@ -8,7 +8,7 @@ import { filter } from 'rxjs';
import { ApiService } from '../../core/api.service'; import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service'; import { AuthService } from '../../core/auth.service';
import { MailStateService } from '../../core/mail-state.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 { BackgroundComponent } from '../../ui/background.component';
import { DialogService } from '../../ui/dialog.service'; import { DialogService } from '../../ui/dialog.service';
import { THEMES, ThemeService } from '../../ui/theme.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"> <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()" <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"> 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)"> <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 <mat-icon>edit</mat-icon> COMPOSE
</a> </a>
@@ -165,6 +203,11 @@ export class ShellComponent {
readonly themes = THEMES; readonly themes = THEMES;
readonly menuOpen = signal(false); 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 = ''; query = '';
readonly links = [ readonly links = [
@@ -195,7 +238,8 @@ export class ShellComponent {
constructor() { constructor() {
this.state.refreshFolders(); this.state.refreshFolders();
this.state.refreshProfile(); 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)); inject(DestroyRef).onDestroy(() => clearInterval(timer));
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => { this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
const q = this.router.parseUrl(this.router.url).queryParams['q']; 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 { backToAdmin(): void {
this.auth.backToAdmin(); this.auth.backToAdmin();
location.assign('/settings'); location.assign('/settings');