Compare commits

...
Author SHA1 Message Date
Liosha84 abe4f66067 Merge pull request 'Вход через Jambotron (OAuth 2.0 code + PKCE), пароли приложений, системные ящики' (#34) from feat/jambotron-sso into master
continuous-integration/drone/push Build is passing
Reviewed-on: #34
2026-11-07 21:06:40 +01:00
Liosha84andClaude Opus 5.5 99e12d33f3 Вход через Jambotron (OAuth 2.0 code + PKCE), пароли приложений, системные ящики
continuous-integration/drone/pr Build is passing
- /api/oauth/start → JAMBOTRON_URL/oauth/authorize (client mail, PKCE S256);
  /api/oauth/callback: код меняется на сервере у Jambotron /api/oauth/token
  (Bearer SERVICE_TOKEN) → рабочий ящик сотрудника → сессия, ADMIN Jambotron — ADMIN.
- Вход по паролю в веб — только встроенный администратор (когда вход через Jambotron включён).
- Пароли приложений (V9 app_passwords): User panel → APP PASSWORDS, только SMTP/IMAP.
- Системные ящики (V9 users.is_system): без входа в веб, открывают встроенные
  администраторы, не в USERS; Admin panel → MAILBOXES → кнопка SYSTEM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:57:30 +01:00
Liosha84 910a028612 Merge pull request 'Роли ADMIN и USER, User panel и Admin panel' (#33) from feat/roles-panels into master
continuous-integration/drone/push Build is passing
Reviewed-on: #33
2026-11-07 20:20:33 +01:00
Liosha84andClaude Opus 5.5 cf87b60b38 Роли ADMIN и USER, User panel и Admin panel
continuous-integration/drone/pr Build is passing
- Роль ADMIN = users.is_admin; встроенный администратор (mailserver.admins, по
  умолчанию admin@) всегда ADMIN и получает роль в базе при старте.
- PUT /api/admin/mailboxes/{id}/role {ADMIN|USER}: только ADMIN; не свою, не
  встроенного, общий ящик не может быть ADMIN.
- Admin panel (/admin, только ADMIN): USERS (логины и роли, MAKE ADMIN / MAKE USER),
  MAILBOXES, LISTS. User panel (/settings): профиль, пароль, хранилище, вид, мои ящики.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:18:18 +01:00
Liosha84 402d2c403b Merge pull request 'Левая панель: комбобокс ящиков; добавление — в Настройки; выделение в цвете темы' (#32) from feat/mailbox-combobox into master
continuous-integration/drone/push Build is passing
Reviewed-on: #32
2026-11-07 20:12:14 +01:00
Liosha84 8eb69b32e5 Merge pull request 'Селекты в тёмной теме: выпадающий список не белый' (#31) from fix/select-theme into master
continuous-integration/drone/push Build is passing
Reviewed-on: #31
2026-11-07 20:12:01 +01:00
Liosha84andClaude Opus 5.5 3d9fc77201 Левая панель: комбобокс ящиков; добавление ящиков — в Настройки; выделение в цвете темы
continuous-integration/drone/pr Build is passing
- MAILBOX: <select> со своими, общими и добавленными ящиками (непрочитанные в скобках),
  выбор — переключение; список и форма «добавить» из левой панели убраны.
- Settings → MY MAILBOXES (у всех): список ящиков, «убрать» добавленный, «добавить» по паролю.
- Выбранная папка, ссылка и палитра — nav-active в --primary-color темы
  (было фиксированное розовое — в Neon Cyan баклажановое).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:10:54 +01:00
33 changed files with 1005 additions and 114 deletions
@@ -0,0 +1,43 @@
package com.mailserver.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* «Sign in with Jambotron»: JAMBOTRON_URL (https://jambotron.com) + SERVICE_TOKEN (the same token Jambotron uses for
* the service API — here it proves to Jambotron that the code is exchanged by this mail server). Both set — people log
* in through Jambotron, web password login only for the built-in administrator.
*/
@Component
public class JambotronSso {
private final String url;
private final String token;
private final String publicUrl;
public JambotronSso(@Value("${mailserver.jambotron.url:}") String url,
@Value("${mailserver.service-token:}") String token,
@Value("${mailserver.public-url:}") String publicUrl,
@Value("${mailserver.smtp.hostname:localhost}") String hostname) {
this.url = url.isBlank() ? null : url.trim().replaceAll("/+$", "");
this.token = token.isBlank() ? null : token.trim();
this.publicUrl = publicUrl.isBlank() ? "https://" + hostname : publicUrl.trim().replaceAll("/+$", "");
}
public boolean enabled() {
return url != null && token != null;
}
public String url() {
return url;
}
public String token() {
return token;
}
/** Where Jambotron sends the browser back: https://mail.jambocheck.com/api/oauth/callback */
public String callback() {
return publicUrl + "/api/oauth/callback";
}
}
@@ -40,7 +40,17 @@ public class MailAdmins {
return admins.contains(key) || granted.contains(key); return admins.contains(key) || granted.contains(key);
} }
/** Given / taken by Jambotron (users.is_admin) — loaded at start and kept here on every change. */ /** Built-in administrators (lower case) — owners of system mailboxes. */
public Set<String> builtIn() {
return admins;
}
/** Built-in administrator from the configuration (admin@ by default): the role can not be taken away. */
public boolean isBuiltIn(String email) {
return email != null && admins.contains(email.toLowerCase(Locale.ROOT));
}
/** Role ADMIN in users.is_admin (given by an administrator in Admin panel or by Jambotron) — loaded at start, kept here on every change. */
public void grant(String email, boolean admin) { public void grant(String email, boolean admin) {
String key = email.toLowerCase(Locale.ROOT); String key = email.toLowerCase(Locale.ROOT);
if (admin) { if (admin) {
@@ -6,8 +6,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
/** At start: mailboxes Jambotron made administrators (users.is_admin) → {@link MailAdmins}. */ /** At start: role ADMIN (users.is_admin) → {@link MailAdmins}; the built-in administrator gets the role in the database too. */
@Component @Component
public class MailAdminsLoader { public class MailAdminsLoader {
@@ -23,10 +24,13 @@ public class MailAdminsLoader {
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
public void load() { public void load() {
users.findAll().filter(user -> user.isAdmin()) users.findAll()
.concatMap(user -> !user.isAdmin() && admins.isBuiltIn(user.getEmail())
? Mono.fromSupplier(() -> { user.setAdmin(true); return user; }).flatMap(users::save) : Mono.just(user))
.filter(user -> user.isAdmin())
.doOnNext(user -> admins.grant(user.getEmail(), true)) .doOnNext(user -> admins.grant(user.getEmail(), true))
.count() .count()
.subscribe(n -> log.info("Mail administrators from Jambotron: {}", n), .subscribe(n -> log.info("Mail administrators (role ADMIN): {}", n),
e -> log.error("Mail administrators from Jambotron not loaded: {}", e.getMessage())); e -> log.error("Mail administrators not loaded: {}", e.getMessage()));
} }
} }
@@ -53,7 +53,7 @@ public class SecurityConfig {
.authorizeExchange(exchange -> exchange .authorizeExchange(exchange -> exchange
.pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll() .pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll()
// service API for Jambotron: its own token, checked by ServiceController (not a mailbox login) // service API for Jambotron: its own token, checked by ServiceController (not a mailbox login)
.pathMatchers("/api/service/**").permitAll() .pathMatchers("/api/service/**", "/api/oauth/start", "/api/oauth/callback").permitAll()
.pathMatchers("/api/**", "/actuator/**").authenticated() .pathMatchers("/api/**", "/actuator/**").authenticated()
// the web client (static files, SPA routes) // the web client (static files, SPA routes)
.anyExchange().permitAll() .anyExchange().permitAll()
@@ -0,0 +1,72 @@
package com.mailserver.controller;
import com.mailserver.service.AppPasswords;
import com.mailserver.service.UserService;
import jakarta.validation.constraints.Size;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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;
/** User panel → APP PASSWORDS: a password per mail program (Thunderbird, phone), SMTP / IMAP only. */
@RestController
@RequestMapping("/api/app-passwords")
public class AppPasswordsController {
private static final Logger log = LoggerFactory.getLogger(AppPasswordsController.class);
public record CreateRequest(@Size(max = 100) String name) {
}
/** @param password shown once */
public record Created(AppPasswords.AppPassword appPassword, String password) {
}
private final AppPasswords passwords;
private final UserService users;
public AppPasswordsController(AppPasswords passwords, UserService users) {
this.passwords = passwords;
this.users = users;
}
@GetMapping
public Flux<AppPasswords.AppPassword> list(Mono<Authentication> auth) {
return me(auth).flatMapMany(id -> passwords.list(id));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
return me(auth).zipWith(body).flatMap(pair -> {
String name = pair.getT2().name() == null || pair.getT2().name().isBlank() ? "Mail program" : pair.getT2().name().trim();
return passwords.create(pair.getT1(), name)
.flatMap(password -> passwords.list(pair.getT1()).last()
.doOnNext(p -> log.info("App password '{}' created for user {}", name, pair.getT1()))
.map(p -> new Created(p, password)));
});
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> delete(Mono<Authentication> auth, @PathVariable Long id) {
return me(auth).flatMap(userId -> passwords.delete(userId, id))
.flatMap(deleted -> deleted ? Mono.<Void>empty()
: Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such app password")));
}
private Mono<Long> me(Mono<Authentication> auth) {
return auth.map(Authentication::getName).flatMap(users::findByEmail).map(u -> u.getId());
}
}
@@ -57,10 +57,12 @@ public class AuthenticationController {
private final boolean registrationEnabled; private final boolean registrationEnabled;
private final LoginGuard guard; private final LoginGuard guard;
private final MailAdmins admins; private final MailAdmins admins;
private final com.mailserver.config.JambotronSso sso;
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains, public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled, @Value("${mailserver.registration.enabled:false}") boolean registrationEnabled,
LoginGuard guard, MailAdmins admins) { LoginGuard guard, MailAdmins admins, com.mailserver.config.JambotronSso sso) {
this.sso = sso;
this.guard = guard; this.guard = guard;
this.admins = admins; this.admins = admins;
this.userService = userService; this.userService = userService;
@@ -73,7 +75,8 @@ public class AuthenticationController {
/** Public: is registration open, and in which domains mailboxes can be created. */ /** Public: is registration open, and in which domains mailboxes can be created. */
@GetMapping("/config") @GetMapping("/config")
public Map<String, Object> config() { public Map<String, Object> config() {
return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList()); return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList(),
"ssoEnabled", sso.enabled());
} }
@PostMapping("/login") @PostMapping("/login")
@@ -106,6 +109,16 @@ public class AuthenticationController {
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")));
} }
// With «Sign in with Jambotron» the web password is only for the built-in administrator
// (Jambotron down); the others' passwords are for mail programs (SMTP / IMAP)
if (sso.enabled() && !admins.isBuiltIn(user.getEmail())) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error",
"Sign in with Jambotron — the password is only for mail programs")));
}
if (user.isSystem()) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error",
"A system mailbox has no web login: its owners open it from their mailbox")));
}
return Mono.just(ResponseEntity.ok(token(user))); return Mono.just(ResponseEntity.ok(token(user)));
}); });
}); });
@@ -45,8 +45,16 @@ public class MailboxAdminController {
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class); private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
private static final int MIN_PASSWORD = MailDirectory.MIN_PASSWORD; private static final int MIN_PASSWORD = MailDirectory.MIN_PASSWORD;
/** @param role ADMIN or USER; @param builtIn the administrator from the configuration — the role stays */
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,
boolean shared, List<String> members, long usedBytes, long quotaBytes, Instant createdAt) { boolean shared, List<String> members, long usedBytes, long quotaBytes, Instant createdAt,
String role, boolean builtIn, boolean system) {
}
public record SystemRequest(boolean system) {
}
public record RoleRequest(String role) {
} }
/** @param password empty — the server generates one and returns it once */ /** @param password empty — the server generates one and returns it once */
@@ -171,6 +179,49 @@ public class MailboxAdminController {
})); }));
} }
/** Role ADMIN (manages mailboxes, lists, roles — Admin panel) or USER. Not your own, not the built-in administrator. */
@PutMapping("/{id}/role")
public Mono<MailboxView> role(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<RoleRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
if (!"ADMIN".equals(request.role()) && !"USER".equals(request.role())) {
return Mono.error(bad("Role: ADMIN or USER"));
}
boolean toAdmin = "ADMIN".equals(request.role());
if (user.getEmail().equalsIgnoreCase(me)) {
return Mono.error(bad("You can not change your own role"));
}
if (!toAdmin && admins.isBuiltIn(user.getEmail())) {
return Mono.error(bad("The built-in administrator stays ADMIN"));
}
if (toAdmin && (user.isShared() || user.isSystem())) {
return Mono.error(bad("A shared mailbox has no login of its own — it can not be ADMIN"));
}
return users.setAdmin(user, toAdmin).doOnNext(u -> {
admins.grant(u.getEmail(), toAdmin);
log.warn("Mailbox {} role {} given by administrator {}", u.getEmail(), request.role(), me);
});
}))).flatMap(this::view);
}
/**
* System mailbox (no-reply@, monitor@, staging@): no web login, not in USERS; the built-in administrators
* open it from their mailbox; programs keep its password. Off — an ordinary mailbox again.
*/
@PutMapping("/{id}/system")
public Mono<MailboxView> system(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<SystemRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
if (request.system() && (user.isShared() || admins.isAdmin(user.getEmail()))) {
return Mono.error(bad("A shared mailbox or an ADMIN can not be a system mailbox"));
}
log.info("Mailbox {} system {} by administrator {}", user.getEmail(), request.system() ? "ON" : "OFF", me);
return users.setSystem(user, request.system())
.flatMap(u -> !request.system() ? Mono.just(u)
: Flux.fromIterable(admins.builtIn()).concatMap(users::findOptional)
.concatMap(owner -> access.grant(u.getId(), owner.getId(), MailboxAccess.MEMBER))
.then(Mono.just(u)));
}))).flatMap(this::view);
}
/** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */ /** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */
@PutMapping("/{id}/trap") @PutMapping("/{id}/trap")
public Mono<MailboxView> trap(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<TrapRequest> body) { public Mono<MailboxView> trap(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<TrapRequest> body) {
@@ -237,7 +288,7 @@ public class MailboxAdminController {
return mail.usedBytes(user.getId()).defaultIfEmpty(0L).zipWith(access.members(user.getId()).collectList()) 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(), .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(), admins.isAdmin(user.getEmail()), user.isTrap(), user.isShared(), pair.getT2(), pair.getT1(), user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
user.getCreatedAt())); user.getCreatedAt(), admins.isAdmin(user.getEmail()) ? "ADMIN" : "USER", admins.isBuiltIn(user.getEmail()), user.isSystem()));
} }
private static ResponseStatusException bad(String message) { private static ResponseStatusException bad(String message) {
@@ -0,0 +1,176 @@
package com.mailserver.controller;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.mailserver.config.JambotronSso;
import com.mailserver.config.MailAdmins;
import com.mailserver.domain.User;
import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* «Sign in with Jambotron» — OAuth 2.0 authorization code + PKCE (S256):
* <ol>
* <li>{@code /api/oauth/start} → browser to {@code JAMBOTRON_URL/oauth/authorize?client_id=mail&…}</li>
* <li>Jambotron logs the person in (Google / GitHub / password), only staff get a one-time code</li>
* <li>{@code /api/oauth/callback?code&state} → code exchanged server to server at {@code /api/oauth/token}
* (Bearer SERVICE_TOKEN + code_verifier) → who it is, roles, their work mailbox</li>
* <li>session of that mailbox → {@code /sso#token=…}; ADMIN of Jambotron gets role ADMIN here</li>
* </ol>
*/
@RestController
@RequestMapping("/api/oauth")
public class SsoController {
private static final Logger log = LoggerFactory.getLogger(SsoController.class);
private static final Duration STATE_TTL = Duration.ofMinutes(10);
private static final SecureRandom RANDOM = new SecureRandom();
/** What Jambotron says about the person (its /api/oauth/token). */
@JsonIgnoreProperties(ignoreUnknown = true)
public record Identity(String sub, String username, String email, List<String> roles, String mailbox) {
}
private record Pending(String verifier, Instant until) {
}
private final Map<String, Pending> pending = new ConcurrentHashMap<>();
private final JambotronSso sso;
private final UserService users;
private final MailAdmins admins;
private final JwtUtils jwt;
private final WebClient http;
public SsoController(JambotronSso sso, UserService users, MailAdmins admins, JwtUtils jwt, WebClient.Builder http) {
this.sso = sso;
this.users = users;
this.admins = admins;
this.jwt = jwt;
this.http = http.build();
}
@GetMapping("/start")
public ResponseEntity<Void> start() {
if (!sso.enabled()) {
return back("/login?sso_error=" + enc("Sign in with Jambotron is not set up on this server"));
}
Instant now = Instant.now();
pending.values().removeIf(p -> p.until().isBefore(now));
String state = random(24);
String verifier = random(48);
pending.put(state, new Pending(verifier, now.plus(STATE_TTL)));
return back(sso.url() + "/oauth/authorize?response_type=code&client_id=mail"
+ "&redirect_uri=" + enc(sso.callback()) + "&state=" + state
+ "&code_challenge=" + challenge(verifier) + "&code_challenge_method=S256");
}
@GetMapping("/callback")
public Mono<ResponseEntity<Void>> callback(@RequestParam(required = false) String code,
@RequestParam(required = false) String state,
@RequestParam(required = false) String error) {
Pending p = state == null ? null : pending.remove(state);
if (error != null) {
return Mono.just(fail(error));
}
if (code == null || p == null || p.until().isBefore(Instant.now())) {
return Mono.just(fail("The sign-in took too long or was not started here — try again"));
}
return http.post().uri(sso.url() + "/api/oauth/token")
.header("Authorization", "Bearer " + sso.token())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("grant_type", "authorization_code", "client_id", "mail", "code", code,
"code_verifier", p.verifier(), "redirect_uri", sso.callback()))
.retrieve()
.bodyToMono(Identity.class)
.timeout(Duration.ofSeconds(15))
.flatMap(this::session)
.onErrorResume(WebClientResponseException.class, e -> {
log.warn("Sign in with Jambotron: token exchange refused {} {}", e.getStatusCode(), e.getResponseBodyAsString());
return Mono.just(fail("Jambotron did not confirm the sign-in (" + e.getStatusCode().value() + ")"));
})
.onErrorResume(e -> !(e instanceof SsoRefused), e -> {
log.warn("Sign in with Jambotron failed: {}", e.toString());
return Mono.just(fail("Jambotron is not reachable — try again later"));
})
.onErrorResume(SsoRefused.class, e -> Mono.just(fail(e.getMessage())));
}
private Mono<ResponseEntity<Void>> session(Identity who) {
if (who.mailbox() == null || who.mailbox().isBlank()) {
return Mono.error(new SsoRefused("You have no work mailbox: mail is for Jambotron staff"));
}
boolean admin = who.roles() != null && who.roles().contains("ROLE_ADMIN");
return users.findOptional(who.mailbox())
.switchIfEmpty(Mono.error(new SsoRefused("Your mailbox " + who.mailbox() + " is not created yet — it appears within 10 minutes")))
.flatMap(user -> {
if (!user.isActive() || user.isShared() || user.isSystem()) {
return Mono.error(new SsoRefused("Mailbox " + who.mailbox() + " is switched off"));
}
return admin && !user.isAdmin() ? users.setAdmin(user, true) : Mono.just(user);
})
.map(user -> {
if (user.isAdmin()) {
admins.grant(user.getEmail(), true);
}
log.info("WEB: {} signed in with Jambotron as {} ({})", user.getEmail(), who.username(), who.email());
return back("/sso#token=" + enc(jwt.generateToken(user.getEmail())) + "&email=" + enc(user.getEmail()));
});
}
private static final class SsoRefused extends RuntimeException {
SsoRefused(String message) {
super(message);
}
}
private static ResponseEntity<Void> fail(String message) {
return back("/login?sso_error=" + enc(message));
}
private static ResponseEntity<Void> back(String location) {
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(location)).build();
}
private static String enc(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private static String random(int bytes) {
byte[] b = new byte[bytes];
RANDOM.nextBytes(b);
return Base64.getUrlEncoder().withoutPadding().encodeToString(b);
}
static String challenge(String verifier) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -38,6 +38,10 @@ public class User {
@Column("is_admin") @Column("is_admin")
private boolean isAdmin = false; private boolean isAdmin = false;
/** System mailbox (no-reply@ …): no web login, owners open it, programs use its passwords (V9). */
@Column("is_system")
private boolean isSystem = false;
/** Shared mailbox (V7): no login of its own, members open it. */ /** Shared mailbox (V7): no login of its own, members open it. */
@Column("is_shared") @Column("is_shared")
private boolean isShared = false; private boolean isShared = false;
@@ -70,6 +74,8 @@ public class User {
public void setTrap(boolean trap) { isTrap = trap; } public void setTrap(boolean trap) { isTrap = trap; }
public boolean isAdmin() { return isAdmin; } public boolean isAdmin() { return isAdmin; }
public void setAdmin(boolean admin) { isAdmin = admin; } public void setAdmin(boolean admin) { isAdmin = admin; }
public boolean isSystem() { return isSystem; }
public void setSystem(boolean system) { isSystem = system; }
public boolean isShared() { return isShared; } public boolean isShared() { return isShared; }
public void setShared(boolean shared) { isShared = shared; } public void setShared(boolean shared) { isShared = shared; }
@@ -0,0 +1,60 @@
package com.mailserver.service;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
/** App passwords (V9): one per mail program, SMTP / IMAP only — never the web client. */
@Service
public class AppPasswords {
public record AppPassword(Long id, String name, LocalDateTime createdAt, LocalDateTime lastUsedAt) {
}
private final DatabaseClient db;
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
public AppPasswords(DatabaseClient db) {
this.db = db;
}
public Flux<AppPassword> list(Long userId) {
return db.sql("SELECT id, name, created_at, last_used_at FROM app_passwords WHERE user_id = :u ORDER BY id")
.bind("u", userId)
.map(row -> new AppPassword(row.get("id", Long.class), row.get("name", String.class),
row.get("created_at", LocalDateTime.class), row.get("last_used_at", LocalDateTime.class)))
.all();
}
/** @return the password itself — shown once */
public Mono<String> create(Long userId, String name) {
String password = MailDirectory.randomPassword();
return db.sql("INSERT INTO app_passwords (user_id, name, hash) VALUES (:u, :n, :h)")
.bind("u", userId).bind("n", name).bind("h", encoder.encode(password))
.then().thenReturn(password);
}
public Mono<Boolean> delete(Long userId, Long id) {
return db.sql("DELETE FROM app_passwords WHERE id = :id AND user_id = :u").bind("id", id).bind("u", userId)
.fetch().rowsUpdated().map(n -> n > 0);
}
/** SMTP / IMAP: does the password match one of the app passwords of this login (and note the use). */
public boolean matches(Long userId, String password) {
Long hit = db.sql("SELECT id, hash FROM app_passwords WHERE user_id = :u").bind("u", userId)
.map(row -> new Object[]{row.get("id", Long.class), row.get("hash", String.class)})
.all()
.filter(r -> encoder.matches(password, (String) r[1]))
.map(r -> (Long) r[0])
.next().block();
if (hit == null) {
return false;
}
db.sql("UPDATE app_passwords SET last_used_at = NOW() WHERE id = :id").bind("id", hit).then().subscribe();
return true;
}
}
@@ -30,15 +30,18 @@ public class MailboxAccess {
private final UserRepository users; private final UserRepository users;
private final UserService userService; private final UserService userService;
public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService) { private final AppPasswords appPasswords;
public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService, AppPasswords appPasswords) {
this.appPasswords = appPasswords;
this.db = db; this.db = db;
this.users = users; this.users = users;
this.userService = userService; this.userService = userService;
} }
/** /**
* Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password → the mailbox the * Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password or one of its app
* session works with, or null. A shared mailbox never logs in by itself. * passwords (V9) → the mailbox the session works with, or null. A shared mailbox never logs in by itself.
*/ */
public User login(String username, String password) { public User login(String username, String password) {
String name = username.trim().toLowerCase(Locale.ROOT); String name = username.trim().toLowerCase(Locale.ROOT);
@@ -46,7 +49,8 @@ public class MailboxAccess {
String mailbox = star < 0 ? name : name.substring(0, star); String mailbox = star < 0 ? name : name.substring(0, star);
String login = star < 0 ? name : name.substring(star + 1); String login = star < 0 ? name : name.substring(star + 1);
User user = users.findByEmail(login).block(); User user = users.findByEmail(login).block();
if (user == null || !user.isActive() || user.isShared() || !userService.checkPassword(user, password)) { if (user == null || !user.isActive() || user.isShared()
|| !(userService.checkPassword(user, password) || appPasswords.matches(user.getId(), password))) {
return null; return null;
} }
if (mailbox.equals(login)) { if (mailbox.equals(login)) {
@@ -108,6 +108,11 @@ public class UserService {
return userRepository.save(user); return userRepository.save(user);
} }
public Mono<User> setSystem(User user, boolean system) {
user.setSystem(system);
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);
@@ -34,6 +34,10 @@ mailserver.passwords-file=${MAIL_PASSWORDS_FILE:}
# Service API for Jambotron (/api/service/**, staff mailboxes and lists); empty — the API is off. # Service API for Jambotron (/api/service/**, staff mailboxes and lists); empty — the API is off.
# In the compose files: generated by secrets-init into the secrets volume (file SERVICE_TOKEN). # In the compose files: generated by secrets-init into the secrets volume (file SERVICE_TOKEN).
mailserver.service-token=${SERVICE_TOKEN:} mailserver.service-token=${SERVICE_TOKEN:}
# Sign in with Jambotron (OAuth 2.0 code + PKCE): https://jambotron.com; empty — password login for everyone
mailserver.jambotron.url=${JAMBOTRON_URL:}
# address of this web client for the OAuth callback; empty — https://MAIL_HOSTNAME
mailserver.public-url=${MAIL_PUBLIC_URL:}
mailserver.cors.origins=${MAIL_CORS_ORIGINS:} mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
# Attachments # Attachments
@@ -0,0 +1,15 @@
-- Sign in with Jambotron (OAuth 2.0 code + PKCE): people log in through Jambotron; passwords stay for mail programs.
-- App passwords: one per program (Thunderbird, phone, Kuma …), SMTP / IMAP only, shown once, revoked one by one.
CREATE TABLE app_passwords (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
hash VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMP
);
CREATE INDEX app_passwords_user ON app_passwords(user_id);
-- System mailbox (no-reply@, monitor@, staging@): no person behind it, no web login, its owners (built-in
-- administrators) open it from their own mailbox; programs use its password / app passwords.
ALTER TABLE users ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT FALSE;
@@ -33,8 +33,9 @@ class AuthenticationControllerTest {
private final JwtUtils jwt = mock(JwtUtils.class); private final JwtUtils jwt = mock(JwtUtils.class);
private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15), private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15),
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO)); Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO));
private final com.mailserver.config.MailAdmins admins = new com.mailserver.config.MailAdmins("admin@x.test", "", new MailDomains("x.test", ""));
private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt, private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt,
mock(MailDomains.class), false, guard, mock(com.mailserver.config.MailAdmins.class)); mock(MailDomains.class), false, guard, admins, new com.mailserver.config.JambotronSso("", "", "", "mail.x.test"));
private final User alice = new User(); private final User alice = new User();
@@ -56,6 +57,26 @@ class AuthenticationControllerTest {
return controller.login(Mono.just(request), http).block(); return controller.login(Mono.just(request), http).block();
} }
// «Sign in with Jambotron» on: the web password only for the built-in administrator
@Test
void withJambotronSignInOnlyTheBuiltInAdministratorLogsInWithAPassword() {
AuthenticationController sso = new AuthenticationController(users, mock(MailService.class), jwt,
mock(MailDomains.class), false, guard, admins,
new com.mailserver.config.JambotronSso("https://jambotron.test", "tok", "", "mail.x.test"));
User admin = new User();
admin.setEmail("admin@x.test");
when(users.findByEmail("admin@x.test")).thenReturn(Mono.just(admin));
when(users.checkPassword(eq(admin), anyString())).thenReturn(true);
LoginRequest request = new LoginRequest();
request.setEmail("alice@x.test");
request.setPassword("secret");
MockServerHttpRequest http = MockServerHttpRequest.post("/api/login").remoteAddress(new InetSocketAddress("9.9.9.9", 1)).build();
assertThat(sso.login(Mono.just(request), http).block().getBody())
.isEqualTo(Map.of("error", "Sign in with Jambotron — the password is only for mail programs"));
request.setEmail("admin@x.test");
assertThat(sso.login(Mono.just(request), http).block().getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test @Test
void unknownMailboxAndWrongPasswordLookTheSameAndBothCount() { void unknownMailboxAndWrongPasswordLookTheSameAndBothCount() {
assertThat(login("1.2.3.4", "ghost@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials")); assertThat(login("1.2.3.4", "ghost@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials"));
@@ -186,6 +186,32 @@ class MailboxAdminControllerTest {
verify(mail, never()).deleteMailbox(admin); verify(mail, never()).deleteMailbox(admin);
} }
@Test
void administratorGivesAndTakesRoleAdminNotOwnNotBuiltInNotShared() {
when(users.setAdmin(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0);
u.setAdmin(i.getArgument(1));
return Mono.just(u);
});
var made = controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block();
assertThat(made.role()).isEqualTo("ADMIN");
assertThat(admins.isAdmin("alice@jambocheck.com")).isTrue();
// alice is ADMIN now — she manages roles too, but not the built-in one and not her own
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
var back = controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block();
assertThat(back.role()).isEqualTo("USER");
assertThat(admins.isAdmin("alice@jambocheck.com")).isFalse();
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block()))
.isEqualTo(HttpStatus.FORBIDDEN);
alice.setShared(true);
assertThat(status(() -> controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test @Test
void administratorOpensAnyMailboxOthersCanNot() { void administratorOpensAnyMailboxOthersCanNot() {
when(jwt.generateToken("alice@jambocheck.com", "admin@jambocheck.com")).thenReturn("alice-token"); when(jwt.generateToken("alice@jambocheck.com", "admin@jambocheck.com")).thenReturn("alice-token");
@@ -0,0 +1,15 @@
package com.mailserver.controller;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SsoControllerTest {
// RFC 7636 appendix B
@Test
void pkceChallengeIsS256OfTheVerifier() {
assertThat(SsoController.challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"))
.isEqualTo("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
}
}
+2
View File
@@ -128,6 +128,8 @@ services:
environment: environment:
DB_HOST: db DB_HOST: db
MAILSERVER_SECRETS_DIR: /run/mail-secrets MAILSERVER_SECRETS_DIR: /run/mail-secrets
# «Sign in with Jambotron» (OAuth code + PKCE, SERVICE_TOKEN as the client secret); empty — password login
JAMBOTRON_URL: ${JAMBOTRON_URL:-https://jambotron.com}
MAIL_DOMAINS: ${MAIL_DOMAINS:-jambocheck.com} MAIL_DOMAINS: ${MAIL_DOMAINS:-jambocheck.com}
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com} MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
MAIL_TLS_CERT: /tls/cert.pem MAIL_TLS_CERT: /tls/cert.pem
+3 -1
View File
@@ -3,6 +3,7 @@ import { authGuard, guestGuard } from './core/auth';
export const routes: Routes = [ export const routes: Routes = [
{ path: 'login', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/login.page').then(m => m.LoginPage) }, { path: 'login', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/login.page').then(m => m.LoginPage) },
{ path: 'sso', loadComponent: () => import('./pages/auth/sso.page').then(m => m.SsoPage) },
{ path: 'register', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/register.page').then(m => m.RegisterPage) }, { path: 'register', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/register.page').then(m => m.RegisterPage) },
{ {
path: '', path: '',
@@ -15,7 +16,8 @@ export const routes: Routes = [
{ path: 'compose', loadComponent: () => import('./pages/compose/compose.page').then(m => m.ComposePage) }, { path: 'compose', loadComponent: () => import('./pages/compose/compose.page').then(m => m.ComposePage) },
{ path: 'contacts', loadComponent: () => import('./pages/contacts/contacts.page').then(m => m.ContactsPage) }, { path: 'contacts', loadComponent: () => import('./pages/contacts/contacts.page').then(m => m.ContactsPage) },
{ path: 'blocked', loadComponent: () => import('./pages/blocked/blocked.page').then(m => m.BlockedPage) }, { path: 'blocked', loadComponent: () => import('./pages/blocked/blocked.page').then(m => m.BlockedPage) },
{ path: 'settings', loadComponent: () => import('./pages/settings/settings.page').then(m => m.SettingsPage) } { path: 'settings', loadComponent: () => import('./pages/settings/settings.page').then(m => m.SettingsPage) },
{ path: 'admin', loadComponent: () => import('./pages/admin/admin.page').then(m => m.AdminPage) }
] ]
}, },
{ path: '**', redirectTo: '' } { path: '**', redirectTo: '' }
+12 -1
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, MailboxEntry, Message, Profile, ServerConfig } from './models'; import { AppPassword, 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' })
@@ -55,6 +55,17 @@ export class ApiService {
setMailboxActive(id: number, active: boolean): Observable<Mailbox> { setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active }); return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
} }
setMailboxSystem(id: number, system: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/system`, { system });
}
appPasswords(): Observable<AppPassword[]> { return this.http.get<AppPassword[]>('/api/app-passwords'); }
createAppPassword(name: string): Observable<{ appPassword: AppPassword; password: string }> {
return this.http.post<{ appPassword: AppPassword; password: string }>('/api/app-passwords', { name });
}
deleteAppPassword(id: number): Observable<void> { return this.http.delete<void>(`/api/app-passwords/${id}`); }
setMailboxRole(id: number, role: 'ADMIN' | 'USER'): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/role`, { role });
}
deleteMailbox(id: number): Observable<void> { deleteMailbox(id: number): Observable<void> {
return this.http.delete<void>(`/api/admin/mailboxes/${id}`); return this.http.delete<void>(`/api/admin/mailboxes/${id}`);
} }
+6
View File
@@ -64,6 +64,12 @@ export class AuthService {
if (token && email) { this.store({ token, email }); } else { this.clear(); } if (token && email) { this.store({ token, email }); } else { this.clear(); }
} }
/** Session from «Sign in with Jambotron» (/sso#token=…). */
signIn(response: TokenResponse): void {
this.clear();
this.store(response);
}
logout(): void { logout(): void {
this.clear(); this.clear();
this.router.navigate(['/login']); this.router.navigate(['/login']);
+8 -1
View File
@@ -1,6 +1,6 @@
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { Folder, Profile } from './models'; import { Folder, MailboxEntry, Profile } from './models';
/** Folders and the profile, shared by the sidebar, the lists and the header; refreshed after changes. */ /** Folders and the profile, shared by the sidebar, the lists and the header; refreshed after changes. */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -9,6 +9,8 @@ export class MailStateService {
readonly folders = signal<Folder[]>([]); readonly folders = signal<Folder[]>([]);
readonly profile = signal<Profile | null>(null); readonly profile = signal<Profile | null>(null);
/** own, shared and attached mailboxes of the login — sidebar combobox, Settings → MY MAILBOXES */
readonly boxes = signal<MailboxEntry[]>([]);
readonly inbox = computed(() => this.folders().find(f => f.system && f.name === 'INBOX') ?? null); readonly inbox = computed(() => this.folders().find(f => f.system && f.name === 'INBOX') ?? null);
readonly unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0); readonly unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0);
@@ -28,8 +30,13 @@ export class MailStateService {
this.api.me().subscribe({ next: profile => this.profile.set(profile), error: () => {} }); this.api.me().subscribe({ next: profile => this.profile.set(profile), error: () => {} });
} }
refreshBoxes(): void {
this.api.myMailboxes().subscribe({ next: list => this.boxes.set(list), error: () => {} });
}
reset(): void { reset(): void {
this.folders.set([]); this.folders.set([]);
this.profile.set(null); this.profile.set(null);
this.boxes.set([]);
} }
} }
+12
View File
@@ -79,6 +79,9 @@ export interface Mailbox {
usedBytes: number; usedBytes: number;
quotaBytes: number; quotaBytes: number;
createdAt: string; createdAt: string;
role: 'ADMIN' | 'USER'; // ADMIN — Admin panel (users, mailboxes, lists)
builtIn: boolean; // administrator from the server configuration — stays ADMIN
system: boolean; // no-reply@, monitor@ …: no web login, the built-in administrators open it
} }
/** MailLists.MailList — mail to the address → a copy to every member, into the folder (null — INBOX) */ /** MailLists.MailList — mail to the address → a copy to every member, into the folder (null — INBOX) */
@@ -100,6 +103,15 @@ export interface MailboxCreated {
export interface ServerConfig { export interface ServerConfig {
registrationEnabled: boolean; registrationEnabled: boolean;
domains: string[]; domains: string[];
ssoEnabled?: boolean; // «Sign in with Jambotron»; then the web password is only for the built-in administrator
}
/** AppPasswords.AppPassword — a password for one mail program (SMTP / IMAP only) */
export interface AppPassword {
id: number;
name: string;
createdAt: string;
lastUsedAt: string | null;
} }
export interface Draft { export interface Draft {
@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MailStateService } from '../../core/mail-state.service';
import { ListsSection } from '../settings/lists.section';
import { MailboxesSection } from '../settings/mailboxes.section';
import { UsersSection } from './users.section';
/** Admin panel (role ADMIN): users and their roles, mailboxes, mailing lists. */
@Component({
selector: 'app-admin',
imports: [MatIconModule, UsersSection, MailboxesSection, ListsSection],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (state.profile(); as p) {
@if (p.admin) {
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
<app-users-section />
<app-mailboxes-section />
<app-lists-section />
</div>
} @else {
<section class="glass-panel static p-6 text-slate-300 font-rajdhani" role="alert">
<mat-icon class="align-middle text-pink-500">lock</mat-icon> Admin panel is only for users with the role ADMIN.
</section>
}
}
`
})
export class AdminPage {
readonly state = inject(MailStateService);
}
@@ -0,0 +1,95 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service';
import { MailStateService } from '../../core/mail-state.service';
import { Mailbox, errorText } from '../../core/models';
import { DialogService } from '../../ui/dialog.service';
import { ToastService } from '../../ui/toast.service';
/** Admin panel → USERS: people with a login of their own (not shared or system mailboxes) and their role — ADMIN or USER. */
@Component({
selector: 'app-users-section',
imports: [MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block xl:col-span-2' },
template: `
<section class="glass-panel static p-5 md:p-6" aria-labelledby="users-title">
<h2 id="users-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-1 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">manage_accounts</mat-icon> USERS
</h2>
<p class="text-xs text-slate-400 font-mono mb-3">ADMIN — this Admin panel: users, mailboxes, lists. USER — own mail only.</p>
@if (error()) { <p class="text-sm text-rose-300 mb-2" role="alert">{{ error() }}</p> }
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-[11px] font-mono text-slate-400 border-b border-cyan-500/20">
<th class="py-2 pr-3 font-normal">User</th>
<th class="py-2 pr-3 font-normal">Name</th>
<th class="py-2 pr-3 font-normal">Role</th>
<th class="py-2 pr-3 font-normal">Status</th>
<th class="py-2 font-normal"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (u of users(); track u.id) {
<tr class="border-b border-cyan-500/10">
<td class="py-2 pr-3 font-mono" [class.text-slate-500]="!u.active">{{ u.email }}</td>
<td class="py-2 pr-3 text-slate-300">{{ u.displayName }}</td>
<td class="py-2 pr-3">
<span class="text-[11px] font-mono px-1.5 rounded border"
[class.role-admin]="u.role === 'ADMIN'" [class.border-slate-500]="u.role !== 'ADMIN'"
[class.text-slate-300]="u.role !== 'ADMIN'">{{ u.role }}</span>
@if (u.builtIn) { <span class="text-[10px] font-mono text-slate-500 ml-1" title="From the server configuration">built-in</span> }
</td>
<td class="py-2 pr-3 text-xs font-mono" [class.text-emerald-300]="u.active" [class.text-slate-500]="!u.active">{{ u.active ? 'active' : 'off' }}</td>
<td class="py-2 text-right whitespace-nowrap">
@if (u.email !== me() && !(u.builtIn && u.role === 'ADMIN')) {
<button type="button" class="ghost-btn !py-1 text-xs" (click)="toggle(u)"
[attr.aria-label]="(u.role === 'ADMIN' ? 'Make USER: ' : 'Make ADMIN: ') + u.email">
<mat-icon>{{ u.role === 'ADMIN' ? 'person' : 'admin_panel_settings' }}</mat-icon>
{{ u.role === 'ADMIN' ? 'MAKE USER' : 'MAKE ADMIN' }}</button>
}
</td>
</tr>
} @empty {
<tr><td colspan="5" class="py-3 text-slate-500 font-mono text-xs">No users</td></tr>
}
</tbody>
</table>
</div>
</section>
`
})
export class UsersSection {
private api = inject(ApiService);
private state = inject(MailStateService);
private dialogs = inject(DialogService);
private toasts = inject(ToastService);
private readonly all = signal<Mailbox[]>([]);
readonly users = computed(() => this.all().filter(m => !m.shared && !m.system));
readonly me = computed(() => this.state.profile()?.email ?? '');
readonly error = signal('');
constructor() {
this.load();
}
load(): void {
this.api.mailboxes().subscribe({ next: list => this.all.set(list), error: e => this.error.set(errorText(e)) });
}
toggle(u: Mailbox): void {
const role = u.role === 'ADMIN' ? 'USER' : 'ADMIN';
const text = role === 'ADMIN'
? `${u.email} will manage users, mailboxes and lists (Admin panel).`
: `${u.email} keeps only own mail, no Admin panel.`;
this.dialogs.confirm(`Role ${role} for ${u.email}?`, text, 'Change role', role === 'USER').subscribe(ok => {
if (ok === null) return;
this.api.setMailboxRole(u.id, role).subscribe({
next: () => { this.toasts.ok(u.email + ' is ' + role + ' now'); this.load(); },
error: e => this.toasts.error(errorText(e))
});
});
}
}
+27 -4
View File
@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service'; import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service'; import { AuthService } from '../../core/auth.service';
@@ -12,8 +12,24 @@ import { AuthFrameComponent } from './auth-frame.component';
imports: [FormsModule, RouterLink, MatIconModule, AuthFrameComponent], imports: [FormsModule, RouterLink, MatIconModule, AuthFrameComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<app-auth-frame title="LOG IN" subtitle="Your mailbox address and password"> <app-auth-frame title="LOG IN" [subtitle]="sso() ? 'With your Jambotron account' : 'Your mailbox address and password'">
<form (ngSubmit)="submit()" class="space-y-4"> @if (sso()) {
<a href="/api/oauth/start" class="cyber-btn w-full py-3 rounded-xl font-bold text-sm mb-3">
<mat-icon>login</mat-icon> SIGN IN WITH JAMBOTRON
</a>
<p class="text-xs text-slate-400 font-rajdhani text-center mb-4">The same login as on jambotron.com — Google, GitHub or password. For Jambotron staff.</p>
@if (ssoError()) {
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2 mb-4" role="alert">
<mat-icon class="!text-lg">error</mat-icon>{{ ssoError() }}
</p>
}
<button type="button" class="text-xs text-slate-500 hover:text-cyan-300 font-mono w-full text-center"
[attr.aria-expanded]="adminLogin()" (click)="adminLogin.set(!adminLogin())">
{{ adminLogin() ? 'Hide administrator login' : 'Administrator login (password)' }}
</button>
}
@if (!sso() || adminLogin()) {
<form (ngSubmit)="submit()" class="space-y-4" [class.mt-4]="sso()">
<div> <div>
<label class="synth-label" for="email">Email</label> <label class="synth-label" for="email">Email</label>
<input id="email" name="email" type="email" class="synth-input" [(ngModel)]="email" required <input id="email" name="email" type="email" class="synth-input" [(ngModel)]="email" required
@@ -39,6 +55,7 @@ import { AuthFrameComponent } from './auth-frame.component';
<mat-icon>login</mat-icon> {{ busy() ? 'LOGGING IN…' : 'LOG IN' }} <mat-icon>login</mat-icon> {{ busy() ? 'LOGGING IN…' : 'LOG IN' }}
</button> </button>
</form> </form>
}
@if (registration()) { @if (registration()) {
<p class="mt-6 text-center text-sm text-slate-400 font-rajdhani"> <p class="mt-6 text-center text-sm text-slate-400 font-rajdhani">
No mailbox yet? <a routerLink="/register" class="text-cyan-300 hover:text-pink-400 font-semibold">Create one</a> No mailbox yet? <a routerLink="/register" class="text-cyan-300 hover:text-pink-400 font-semibold">Create one</a>
@@ -57,9 +74,15 @@ export class LoginPage {
readonly busy = signal(false); readonly busy = signal(false);
readonly error = signal(''); readonly error = signal('');
readonly registration = signal(false); readonly registration = signal(false);
readonly sso = signal(false);
readonly adminLogin = signal(false);
readonly ssoError = signal(inject(ActivatedRoute).snapshot.queryParamMap.get('sso_error') ?? '');
constructor() { constructor() {
inject(ApiService).config().subscribe({ next: c => this.registration.set(c.registrationEnabled), error: () => {} }); inject(ApiService).config().subscribe({
next: c => { this.registration.set(c.registrationEnabled); this.sso.set(!!c.ssoEnabled); },
error: () => {}
});
} }
submit(): void { submit(): void {
+26
View File
@@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../../core/auth.service';
/** «Sign in with Jambotron» lands here: /sso#token=…&email=… → session → mailbox. */
@Component({
selector: 'app-sso',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p class="p-8 text-center text-slate-300 font-mono" role="status">Signing in…</p>`
})
export class SsoPage {
constructor() {
const auth = inject(AuthService);
const router = inject(Router);
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
const token = params.get('token');
const email = params.get('email');
history.replaceState(null, '', '/sso'); // the token does not stay in the address bar / history
if (token && email) {
auth.signIn({ token, email });
router.navigate(['/']);
} else {
router.navigate(['/login'], { queryParams: { sso_error: 'Sign-in did not finish — try again' } });
}
}
}
@@ -0,0 +1,89 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service';
import { AppPassword, errorText } from '../../core/models';
import { DialogService } from '../../ui/dialog.service';
import { ToastService } from '../../ui/toast.service';
/** User panel → APP PASSWORDS: Thunderbird, phone … — SMTP / IMAP only; the web client is «Sign in with Jambotron». */
@Component({
selector: 'app-app-passwords-section',
imports: [DatePipe, FormsModule, MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block' },
template: `
<section class="glass-panel static p-5 md:p-6 h-full" aria-labelledby="app-pw-title">
<h2 id="app-pw-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-1 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">key</mat-icon> APP PASSWORDS
</h2>
<p class="text-xs text-slate-400 font-mono mb-3">For a mail program (Thunderbird, phone): login — your mailbox address,
password — an app password. IMAP and SMTP on this server. Not for the web.</p>
@if (secret(); as s) {
<div class="p-3 mb-3 rounded-lg border border-amber-400/50 bg-amber-400/10" role="status">
<div class="text-xs text-amber-200 mb-1">«{{ s.name }}» — copy it now, it is shown only once:</div>
<div class="flex items-center gap-2">
<code class="font-mono text-base text-amber-100 select-all break-all">{{ s.password }}</code>
<button type="button" class="ghost-btn !py-1" (click)="copy(s.password)" aria-label="Copy password"><mat-icon>content_copy</mat-icon></button>
<button type="button" class="ghost-btn !py-1" (click)="secret.set(null)" aria-label="Hide password"><mat-icon>close</mat-icon></button>
</div>
</div>
}
<ul class="space-y-1 mb-3">
@for (p of list(); track p.id) {
<li class="flex items-center gap-2 px-2 py-1 rounded-lg border border-cyan-500/15 text-sm">
<mat-icon class="!text-lg text-slate-400">devices</mat-icon>
<span class="flex-grow truncate text-slate-200">{{ p.name }}</span>
<span class="text-[11px] font-mono text-slate-500">{{ p.lastUsedAt ? 'used ' + (p.lastUsedAt | date:'d MMM HH:mm') : 'not used yet' }}</span>
<button type="button" class="ghost-btn !py-1 hover:text-rose-400" (click)="remove(p)" [attr.aria-label]="'Revoke ' + p.name"
title="Revoke — the program stops getting mail"><mat-icon>delete</mat-icon></button>
</li>
} @empty {
<li class="text-xs text-slate-500 font-mono px-1">No app passwords yet</li>
}
</ul>
<form class="flex items-end gap-2" (ngSubmit)="create()">
<div class="flex-grow"><label class="synth-label" for="app-pw-name">Program</label>
<input id="app-pw-name" name="name" class="synth-input" [(ngModel)]="name" placeholder="Thunderbird at home" autocomplete="off"></div>
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>add</mat-icon> NEW</button>
</form>
</section>
`
})
export class AppPasswordsSection {
private api = inject(ApiService);
private dialogs = inject(DialogService);
private toasts = inject(ToastService);
readonly list = signal<AppPassword[]>([]);
readonly secret = signal<{ name: string; password: string } | null>(null);
name = '';
constructor() {
this.load();
}
load(): void {
this.api.appPasswords().subscribe({ next: l => this.list.set(l), error: () => {} });
}
create(): void {
this.api.createAppPassword(this.name.trim()).subscribe({
next: r => { this.secret.set({ name: r.appPassword.name, password: r.password }); this.name = ''; this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
remove(p: AppPassword): void {
this.dialogs.confirm(`Revoke «${p.name}»?`, 'That program stops getting and sending mail until it gets a new app password.', 'Revoke', true)
.subscribe(ok => {
if (ok === null) return;
this.api.deleteAppPassword(p.id).subscribe({ next: () => this.load(), error: e => this.toasts.error(errorText(e)) });
});
}
copy(text: string): void {
navigator.clipboard?.writeText(text).then(() => this.toasts.ok('Copied'), () => this.toasts.error('Copy it by hand'));
}
}
@@ -66,11 +66,12 @@ import { AuthService } from '../../core/auth.service';
<li class="flex flex-wrap items-center gap-x-4 gap-y-1 py-2"> <li class="flex flex-wrap items-center gap-x-4 gap-y-1 py-2">
<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.shared) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/50 text-cyan-300" @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> } [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" @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> } [title]="'Also opened by: ' + m.members.join(', ')">+{{ m.members.length }}</span> }
@if (m.system) { <span class="text-[10px] font-mono px-1.5 rounded border border-violet-400/50 text-violet-300"
title="System mailbox: no web login; the built-in administrators open it, programs use its password">SYSTEM</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>
@@ -84,6 +85,12 @@ import { AuthService } from '../../core/auth.service';
[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>
@if (!m.shared && !m.admin) {
<button type="button" class="ghost-btn !py-1" (click)="system(m)" [class.text-violet-300]="m.system"
[attr.aria-label]="(m.system ? 'Not system: ' : 'Make system: ') + m.email"
[title]="m.system ? 'System mailbox — click to make it ordinary' : 'Make it a system mailbox (no-reply@, monitor@ …)'">
<mat-icon>dns</mat-icon></button>
}
@if (!m.shared) { @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> <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>
} }
@@ -196,6 +203,16 @@ export class MailboxesSection {
} }
/** Trap on: what the mailbox sends is kept in its own INBOX (staging@). Off: it sends for real again. */ /** Trap on: what the mailbox sends is kept in its own INBOX (staging@). Off: it sends for real again. */
system(m: Mailbox): void {
const on = !m.system;
if (!confirm(on ? `${m.email} becomes a system mailbox: no web login, the built-in administrators open it. Programs keep its password.`
: `${m.email} becomes an ordinary mailbox again.`)) return;
this.api.setMailboxSystem(m.id, on).subscribe({
next: () => { this.toasts.ok(m.email + (on ? ' is a system mailbox' : ' is ordinary')); this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
trap(m: Mailbox): void { trap(m: Mailbox): void {
if (m.trap && !confirm('Switch the trap off? ' + m.email + ' will send mail to real people again.')) return; if (m.trap && !confirm('Switch the trap off? ' + m.email + ' will send mail to real people again.')) return;
this.api.setMailboxTrap(m.id, !m.trap).subscribe({ this.api.setMailboxTrap(m.id, !m.trap).subscribe({
@@ -0,0 +1,95 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service';
import { MailStateService } from '../../core/mail-state.service';
import { MailboxEntry, errorText } from '../../core/models';
import { DialogService } from '../../ui/dialog.service';
import { ToastService } from '../../ui/toast.service';
/** Settings → MY MAILBOXES (everyone): mailboxes in the sidebar combobox — add one by its password, remove an added one. */
@Component({
selector: 'app-my-mailboxes-section',
imports: [FormsModule, MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block' },
template: `
<section class="glass-panel static p-5 md:p-6 h-full" aria-labelledby="my-boxes-title">
<h2 id="my-boxes-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">alternate_email</mat-icon> MY MAILBOXES
</h2>
<ul class="space-y-1 mb-4" aria-label="Your mailboxes">
@for (b of state.boxes(); track b.email) {
<li class="flex items-center gap-2 px-2 py-1 rounded-lg border border-cyan-500/15">
<mat-icon class="!text-lg text-slate-400 shrink-0">{{ b.shared ? 'groups' : b.kind === 'OWN' ? 'person' : 'link' }}</mat-icon>
<span class="flex-grow truncate font-mono text-sm text-slate-200">{{ b.email }}</span>
<span class="text-[11px] font-mono text-slate-500">{{ b.kind === 'OWN' ? 'own' : b.shared ? 'shared' : 'added' }}</span>
@if (b.kind !== 'OWN') {
<button type="button" class="ghost-btn !py-1 hover:text-rose-400" (click)="leave(b)"
[attr.aria-label]="'Remove ' + b.email + ' from my mailboxes'" title="Remove from my mailboxes">
<mat-icon>close</mat-icon></button>
}
</li>
}
</ul>
<form class="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-3 items-end" (ngSubmit)="attach()" aria-label="Add a mailbox">
<div><label class="synth-label" for="att-email">Mailbox</label>
<input id="att-email" name="attEmail" class="synth-input" [(ngModel)]="email" autocomplete="off" placeholder="name&#64;domain"></div>
<div><label class="synth-label" for="att-pw">Its password</label>
<input id="att-pw" name="attPw" type="password" class="synth-input" [(ngModel)]="password" autocomplete="off"></div>
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>link</mat-icon> ADD</button>
</form>
</section>
`
})
export class MyMailboxesSection {
readonly state = inject(MailStateService);
private api = inject(ApiService);
private auth = inject(AuthService);
private dialogs = inject(DialogService);
private toasts = inject(ToastService);
email = '';
password = '';
constructor() {
this.state.refreshBoxes();
}
attach(): void {
const email = this.email.trim().toLowerCase();
if (!email || !this.password) { this.toasts.error('Mailbox and its password'); return; }
this.api.attachMailbox(email, this.password).subscribe({
next: b => {
this.email = this.password = '';
this.toasts.ok(b.email + ' added — pick it in the MAILBOX list on the left');
this.state.refreshBoxes();
},
error: e => this.toasts.error(errorText(e))
});
}
leave(b: MailboxEntry): void {
this.dialogs.confirm(`Remove ${b.email}?`, b.shared
? 'It disappears from your list. An administrator can give it back.'
: 'It disappears from your list. 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()) {
// removed the one we are in — back to the own mailbox
this.api.mailboxSession(this.state.boxes().find(x => x.kind === 'OWN')?.email ?? '').subscribe({
next: session => { this.state.reset(); this.auth.switchTo(session); location.assign('/'); },
error: () => location.assign('/')
});
return;
}
this.state.refreshBoxes();
},
error: e => this.toasts.error(errorText(e))
});
});
}
}
@@ -7,13 +7,13 @@ import { MailStateService } from '../../core/mail-state.service';
import { errorText, formatBytes } from '../../core/models'; import { errorText, formatBytes } from '../../core/models';
import { THEMES, ThemeService } from '../../ui/theme.service'; import { THEMES, ThemeService } from '../../ui/theme.service';
import { ToastService } from '../../ui/toast.service'; import { ToastService } from '../../ui/toast.service';
import { MailboxesSection } from './mailboxes.section'; import { MyMailboxesSection } from './my-mailboxes.section';
import { ListsSection } from './lists.section'; import { AppPasswordsSection } from './app-passwords.section';
/** Profile name, password, storage, look. */ /** User panel: profile name, password, storage, look, my mailboxes. Administration — Admin panel (/admin). */
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection, ListsSection], imports: [DatePipe, FormsModule, MatIconModule, MyMailboxesSection, AppPasswordsSection],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
@@ -93,7 +93,7 @@ import { ListsSection } from './lists.section';
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
@for (t of themes; track t.value) { @for (t of themes; track t.value) {
<button type="button" class="ghost-btn justify-center !py-2" [attr.aria-pressed]="theme.theme() === t.value" <button type="button" class="ghost-btn justify-center !py-2" [attr.aria-pressed]="theme.theme() === t.value"
[class.!border-pink-500]="theme.theme() === t.value" [class.!text-pink-300]="theme.theme() === t.value" [class.nav-active]="theme.theme() === t.value"
(click)="theme.theme.set(t.value)">{{ t.label }}</button> (click)="theme.theme.set(t.value)">{{ t.label }}</button>
} }
</div> </div>
@@ -112,10 +112,9 @@ import { ListsSection } from './lists.section';
</div> </div>
</section> </section>
@if (state.profile()?.admin) { <app-my-mailboxes-section />
<app-mailboxes-section /> <app-app-passwords-section />
<app-lists-section />
}
</div> </div>
` `
}) })
+24 -82
View File
@@ -81,43 +81,15 @@ 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"> <label for="mailbox-pick" class="block text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest mb-2 px-1">MAILBOX</label>
<h2 class="text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest">MAILBOXES</h2> <select id="mailbox-pick" class="synth-input !py-1.5 !text-sm font-mono mb-4" [ngModel]="auth.email()"
<button type="button" class="text-slate-400 hover:text-cyan-300" matTooltip="Add a mailbox you have the password of" (ngModelChange)="pick($event)" [disabled]="state.boxes().length < 2">
aria-label="Add mailbox" [attr.aria-expanded]="attachOpen()" (click)="attachOpen.set(!attachOpen())"> @for (b of state.boxes(); track b.email) {
<mat-icon class="!text-xl">add_circle_outline</mat-icon></button> <option [value]="b.email">{{ b.email }}{{ b.shared ? ' · shared' : b.kind === 'ATTACHED' ? ' · attached' : '' }}{{ b.unread ? ' (' + b.unread + ')' : '' }}</option>
</div> } @empty {
<ul class="space-y-1 mb-3" aria-label="Your mailboxes"> <option [value]="auth.email()">{{ auth.email() }}</option>
@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> </select>
@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
@@ -126,7 +98,7 @@ import { ToastService } from '../../ui/toast.service';
<ul class="space-y-1"> <ul class="space-y-1">
@for (folder of systemFolders(); track folder.id) { @for (folder of systemFolders(); track folder.id) {
<li> <li>
<a [routerLink]="['/folder', folder.id]" routerLinkActive="!bg-pink-500/20 !text-pink-300 !border-pink-500/40" <a [routerLink]="['/folder', folder.id]" routerLinkActive="nav-active"
class="flex items-center gap-3 px-3 py-2 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold" class="flex items-center gap-3 px-3 py-2 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold"
(click)="menuOpen.set(false)"> (click)="menuOpen.set(false)">
<mat-icon class="!text-xl">{{ icon(folder) }}</mat-icon> <mat-icon class="!text-xl">{{ icon(folder) }}</mat-icon>
@@ -148,7 +120,7 @@ import { ToastService } from '../../ui/toast.service';
<ul class="space-y-1"> <ul class="space-y-1">
@for (folder of ownFolders(); track folder.id) { @for (folder of ownFolders(); track folder.id) {
<li class="flex items-center gap-1"> <li class="flex items-center gap-1">
<a [routerLink]="['/folder', folder.id]" routerLinkActive="!bg-pink-500/20 !text-pink-300 !border-pink-500/40" <a [routerLink]="['/folder', folder.id]" routerLinkActive="nav-active"
class="flex-grow flex items-center gap-3 px-3 py-1.5 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold min-w-0" class="flex-grow flex items-center gap-3 px-3 py-1.5 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold min-w-0"
(click)="menuOpen.set(false)"> (click)="menuOpen.set(false)">
<mat-icon class="!text-xl">folder</mat-icon> <mat-icon class="!text-xl">folder</mat-icon>
@@ -166,12 +138,19 @@ import { ToastService } from '../../ui/toast.service';
<div class="border-t border-cyan-500/20 mt-5 pt-3 space-y-1"> <div class="border-t border-cyan-500/20 mt-5 pt-3 space-y-1">
@for (link of links; track link.path) { @for (link of links; track link.path) {
<a [routerLink]="link.path" routerLinkActive="!text-pink-300" <a [routerLink]="link.path" routerLinkActive="nav-active-text"
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-slate-400 hover:text-cyan-300 font-rajdhani font-semibold" class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-slate-400 hover:text-cyan-300 font-rajdhani font-semibold"
(click)="menuOpen.set(false)"> (click)="menuOpen.set(false)">
<mat-icon class="!text-xl">{{ link.icon }}</mat-icon>{{ link.label }} <mat-icon class="!text-xl">{{ link.icon }}</mat-icon>{{ link.label }}
</a> </a>
} }
@if (state.profile()?.admin) {
<a routerLink="/admin" routerLinkActive="nav-active-text"
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-slate-400 hover:text-cyan-300 font-rajdhani font-semibold"
(click)="menuOpen.set(false)">
<mat-icon class="!text-xl">admin_panel_settings</mat-icon>Admin panel
</a>
}
</div> </div>
@if (state.profile(); as p) { @if (state.profile(); as p) {
@@ -203,17 +182,12 @@ 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 = [
{ path: '/contacts', label: 'Contacts', icon: 'contacts' }, { path: '/contacts', label: 'Contacts', icon: 'contacts' },
{ path: '/blocked', label: 'Blocked senders', icon: 'block' }, { path: '/blocked', label: 'Blocked senders', icon: 'block' },
{ path: '/settings', label: 'Settings', icon: 'settings' } { path: '/settings', label: 'User panel', icon: 'person' }
]; ];
readonly folderActions: RowAction[] = [ readonly folderActions: RowAction[] = [
@@ -238,8 +212,8 @@ export class ShellComponent {
constructor() { constructor() {
this.state.refreshFolders(); this.state.refreshFolders();
this.state.refreshProfile(); this.state.refreshProfile();
this.loadBoxes(); this.state.refreshBoxes();
const timer = setInterval(() => { this.state.refreshFolders(); this.loadBoxes(); }, 30000); const timer = setInterval(() => { this.state.refreshFolders(); this.state.refreshBoxes(); }, 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'];
@@ -300,10 +274,6 @@ 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. */ /** Another mailbox of this login: its session, full reload — fresh folders and profile. */
switchTo(b: MailboxEntry): void { switchTo(b: MailboxEntry): void {
if (b.email === this.auth.email()) { this.router.navigate(['/']); return; } if (b.email === this.auth.email()) { this.router.navigate(['/']); return; }
@@ -313,37 +283,9 @@ export class ShellComponent {
}); });
} }
attach(): void { pick(email: string): void {
const email = this.attachEmail.trim().toLowerCase(); const b = this.state.boxes().find(x => x.email === email);
if (!email || !this.attachPassword) { this.toasts.error('Mailbox and its password'); return; } if (b) this.switchTo(b);
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 {
+9
View File
@@ -300,6 +300,15 @@ body {
} }
.synth-input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(255, 0, 127, 0.45); } .synth-input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(255, 0, 127, 0.45); }
.synth-input::placeholder { color: #64748b; } .synth-input::placeholder { color: #64748b; }
/* Selected item of the sidebar (folder, link) — in the colour of the theme, not a fixed pink */
.nav-active {
background-color: color-mix(in srgb, var(--primary-color) 18%, transparent) !important;
border-color: color-mix(in srgb, var(--primary-color) 45%, transparent) !important;
color: var(--primary-color) !important;
}
.nav-active-text { color: var(--primary-color) !important; }
.role-admin { color: var(--primary-color); border-color: color-mix(in srgb, var(--primary-color) 50%, transparent); }
/* Selects: the opened list is drawn by the browser — without this it is white (light OS theme) with our light text */ /* Selects: the opened list is drawn by the browser — without this it is white (light OS theme) with our light text */
select { color-scheme: dark; } select { color-scheme: dark; }
select option, select optgroup { background-color: #0f172a; color: #e2e8f0; } select option, select optgroup { background-color: #0f172a; color: #e2e8f0; }