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
Liosha84andClaude Opus 5.5 1076cd4f3f Селекты в тёмной теме: выпадающий список не белый
continuous-integration/drone/pr Build is passing
Открытый список <select> рисует браузер: без color-scheme он белый, а текст
наш светлый — не читается (домен в MAILBOXES, «Переместить», тема).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:02:28 +01:00
Liosha84 315402edc8 Merge pull request 'Администраторы почты из Jambotron: каждый ADMIN управляет ящиками' (#30) from feat/admins-from-jambotron into master
continuous-integration/drone/push Build is passing
Reviewed-on: #30
2026-11-07 19:46:46 +01:00
33 changed files with 1010 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);
}
/** 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) {
String key = email.toLowerCase(Locale.ROOT);
if (admin) {
@@ -6,8 +6,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
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
public class MailAdminsLoader {
@@ -23,10 +24,13 @@ public class MailAdminsLoader {
@EventListener(ApplicationReadyEvent.class)
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))
.count()
.subscribe(n -> log.info("Mail administrators from Jambotron: {}", n),
e -> log.error("Mail administrators from Jambotron not loaded: {}", e.getMessage()));
.subscribe(n -> log.info("Mail administrators (role ADMIN): {}", n),
e -> log.error("Mail administrators not loaded: {}", e.getMessage()));
}
}
@@ -53,7 +53,7 @@ public class SecurityConfig {
.authorizeExchange(exchange -> exchange
.pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll()
// service API for Jambotron: its own token, checked by ServiceController (not a mailbox login)
.pathMatchers("/api/service/**").permitAll()
.pathMatchers("/api/service/**", "/api/oauth/start", "/api/oauth/callback").permitAll()
.pathMatchers("/api/**", "/actuator/**").authenticated()
// the web client (static files, SPA routes)
.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 LoginGuard guard;
private final MailAdmins admins;
private final com.mailserver.config.JambotronSso sso;
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
@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.admins = admins;
this.userService = userService;
@@ -73,7 +75,8 @@ public class AuthenticationController {
/** Public: is registration open, and in which domains mailboxes can be created. */
@GetMapping("/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")
@@ -106,6 +109,16 @@ public class AuthenticationController {
if (!user.isActive()) {
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)));
});
});
@@ -45,8 +45,16 @@ public class MailboxAdminController {
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
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,
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 */
@@ -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@). */
@PutMapping("/{id}/trap")
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())
.map(pair -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
admins.isAdmin(user.getEmail()), user.isTrap(), user.isShared(), pair.getT2(), pair.getT1(), user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
user.getCreatedAt()));
user.getCreatedAt(), admins.isAdmin(user.getEmail()) ? "ADMIN" : "USER", admins.isBuiltIn(user.getEmail()), user.isSystem()));
}
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")
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. */
@Column("is_shared")
private boolean isShared = false;
@@ -70,6 +74,8 @@ public class User {
public void setTrap(boolean trap) { isTrap = trap; }
public boolean isAdmin() { return isAdmin; }
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 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 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.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.
* Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password or one of its app
* 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) {
String name = username.trim().toLowerCase(Locale.ROOT);
@@ -46,7 +49,8 @@ public class MailboxAccess {
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)) {
if (user == null || !user.isActive() || user.isShared()
|| !(userService.checkPassword(user, password) || appPasswords.matches(user.getId(), password))) {
return null;
}
if (mailbox.equals(login)) {
@@ -108,6 +108,11 @@ public class UserService {
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) {
user.setTrap(trap);
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.
# In the compose files: generated by secrets-init into the secrets volume (file 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:}
# 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 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));
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,
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();
@@ -56,6 +57,26 @@ class AuthenticationControllerTest {
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
void unknownMailboxAndWrongPasswordLookTheSameAndBothCount() {
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);
}
@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
void administratorOpensAnyMailboxOthersCanNot() {
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:
DB_HOST: db
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_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
MAIL_TLS_CERT: /tls/cert.pem
+3 -1
View File
@@ -3,6 +3,7 @@ import { authGuard, guestGuard } from './core/auth';
export const routes: Routes = [
{ 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: '',
@@ -15,7 +16,8 @@ export const routes: Routes = [
{ 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: '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: '' }
+12 -1
View File
@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Attachment, BlacklistEntry, Contact, Draft, Folder, MailList, Mailbox, MailboxCreated, 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). */
@Injectable({ providedIn: 'root' })
@@ -55,6 +55,17 @@ export class ApiService {
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
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> {
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(); }
}
/** Session from «Sign in with Jambotron» (/sso#token=…). */
signIn(response: TokenResponse): void {
this.clear();
this.store(response);
}
logout(): void {
this.clear();
this.router.navigate(['/login']);
+8 -1
View File
@@ -1,6 +1,6 @@
import { Injectable, computed, inject, signal } from '@angular/core';
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. */
@Injectable({ providedIn: 'root' })
@@ -9,6 +9,8 @@ export class MailStateService {
readonly folders = signal<Folder[]>([]);
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 unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0);
@@ -28,8 +30,13 @@ export class MailStateService {
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 {
this.folders.set([]);
this.profile.set(null);
this.boxes.set([]);
}
}
+12
View File
@@ -79,6 +79,9 @@ export interface Mailbox {
usedBytes: number;
quotaBytes: number;
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) */
@@ -100,6 +103,15 @@ export interface MailboxCreated {
export interface ServerConfig {
registrationEnabled: boolean;
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 {
@@ -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 { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service';
@@ -12,8 +12,24 @@ import { AuthFrameComponent } from './auth-frame.component';
imports: [FormsModule, RouterLink, MatIconModule, AuthFrameComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-auth-frame title="LOG IN" subtitle="Your mailbox address and password">
<form (ngSubmit)="submit()" class="space-y-4">
<app-auth-frame title="LOG IN" [subtitle]="sso() ? 'With your Jambotron account' : 'Your mailbox address and password'">
@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>
<label class="synth-label" for="email">Email</label>
<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' }}
</button>
</form>
}
@if (registration()) {
<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>
@@ -57,9 +74,15 @@ export class LoginPage {
readonly busy = signal(false);
readonly error = signal('');
readonly registration = signal(false);
readonly sso = signal(false);
readonly adminLogin = signal(false);
readonly ssoError = signal(inject(ActivatedRoute).snapshot.queryParamMap.get('sso_error') ?? '');
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 {
+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">
<span class="font-mono text-sm" [class.text-cyan-300]="m.active" [class.text-slate-500]="!m.active" [class.line-through]="!m.active">{{ m.email }}</span>
<span class="text-sm text-slate-400">{{ m.displayName }}</span>
@if (m.admin) { <span class="text-[10px] font-mono px-1.5 rounded border border-pink-500/40 text-pink-300">ADMIN</span> }
@if (m.shared) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/50 text-cyan-300"
[title]="'Members: ' + (m.members.join(', ') || 'none')">SHARED · {{ m.members.length }}</span> }
@if (!m.shared && m.members.length) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/30 text-cyan-300/80"
[title]="'Also opened by: ' + m.members.join(', ')">+{{ m.members.length }}</span> }
@if (m.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"
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>
@@ -84,6 +85,12 @@ import { AuthService } from '../../core/auth.service';
[attr.aria-label]="(m.trap ? 'Trap off for ' : 'Trap on for ') + m.email"
[title]="m.trap ? 'Trap ON: sent mail stays in its INBOX. Click to send for real' : 'Trap: keep what it sends in its own INBOX'">
<mat-icon>{{ m.trap ? 'move_to_inbox' : 'outbox' }}</mat-icon></button>
@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) {
<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. */
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 {
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({
@@ -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 { THEMES, ThemeService } from '../../ui/theme.service';
import { ToastService } from '../../ui/toast.service';
import { MailboxesSection } from './mailboxes.section';
import { ListsSection } from './lists.section';
import { MyMailboxesSection } from './my-mailboxes.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({
selector: 'app-settings',
imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection, ListsSection],
imports: [DatePipe, FormsModule, MatIconModule, MyMailboxesSection, AppPasswordsSection],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<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">
@for (t of themes; track 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>
}
</div>
@@ -112,10 +112,9 @@ import { ListsSection } from './lists.section';
</div>
</section>
@if (state.profile()?.admin) {
<app-mailboxes-section />
<app-lists-section />
}
<app-my-mailboxes-section />
<app-app-passwords-section />
</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">
<nav class="glass-panel static p-4 md:pt-6 h-fit lg:sticky lg:top-20 lg:block" [class.hidden]="!menuOpen()"
aria-label="Mail folders">
<div class="flex items-center justify-between mb-2 px-1">
<h2 class="text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest">MAILBOXES</h2>
<button type="button" class="text-slate-400 hover:text-cyan-300" matTooltip="Add a mailbox you have the password of"
aria-label="Add mailbox" [attr.aria-expanded]="attachOpen()" (click)="attachOpen.set(!attachOpen())">
<mat-icon class="!text-xl">add_circle_outline</mat-icon></button>
</div>
<ul class="space-y-1 mb-3" aria-label="Your mailboxes">
@for (b of boxes(); track b.email) {
<li class="flex items-center gap-1">
<button type="button" (click)="switchTo(b)" [attr.aria-current]="b.email === auth.email() ? 'true' : null"
class="flex-grow flex items-center gap-2 px-3 py-1.5 rounded-lg border text-left font-rajdhani font-semibold min-w-0"
[class.border-pink-500/40]="b.email === auth.email()" [class.bg-pink-500/15]="b.email === auth.email()"
[class.text-pink-200]="b.email === auth.email()" [class.border-transparent]="b.email !== auth.email()"
[class.text-slate-300]="b.email !== auth.email()"
[matTooltip]="b.email + (b.shared ? ' — shared' : b.kind === 'ATTACHED' ? ' — attached' : '')">
<mat-icon class="!text-lg shrink-0">{{ b.shared ? 'groups' : b.kind === 'OWN' ? 'person' : 'link' }}</mat-icon>
<span class="flex-grow truncate text-sm">{{ b.email }}</span>
@if (b.unread) {
<span class="text-[11px] font-mono px-2 rounded-full bg-pink-500/25 text-pink-300" [attr.aria-label]="b.unread + ' unread'">{{ b.unread }}</span>
}
</button>
@if (b.kind !== 'OWN') {
<button type="button" class="text-slate-500 hover:text-pink-400" [attr.aria-label]="'Remove ' + b.email + ' from my mailboxes'"
matTooltip="Remove from my mailboxes" (click)="leave(b)"><mat-icon class="!text-base">close</mat-icon></button>
}
</li>
<label for="mailbox-pick" class="block text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest mb-2 px-1">MAILBOX</label>
<select id="mailbox-pick" class="synth-input !py-1.5 !text-sm font-mono mb-4" [ngModel]="auth.email()"
(ngModelChange)="pick($event)" [disabled]="state.boxes().length < 2">
@for (b of state.boxes(); track b.email) {
<option [value]="b.email">{{ b.email }}{{ b.shared ? ' · shared' : b.kind === 'ATTACHED' ? ' · attached' : '' }}{{ b.unread ? ' (' + b.unread + ')' : '' }}</option>
} @empty {
<option [value]="auth.email()">{{ auth.email() }}</option>
}
</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>
}
</select>
<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
@@ -126,7 +98,7 @@ import { ToastService } from '../../ui/toast.service';
<ul class="space-y-1">
@for (folder of systemFolders(); track folder.id) {
<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"
(click)="menuOpen.set(false)">
<mat-icon class="!text-xl">{{ icon(folder) }}</mat-icon>
@@ -148,7 +120,7 @@ import { ToastService } from '../../ui/toast.service';
<ul class="space-y-1">
@for (folder of ownFolders(); track folder.id) {
<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"
(click)="menuOpen.set(false)">
<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">
@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"
(click)="menuOpen.set(false)">
<mat-icon class="!text-xl">{{ link.icon }}</mat-icon>{{ link.label }}
</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>
@if (state.profile(); as p) {
@@ -203,17 +182,12 @@ export class ShellComponent {
readonly themes = THEMES;
readonly menuOpen = signal(false);
/** own, shared and attached mailboxes of the login (shown when there is more than the own one, or to add one) */
readonly boxes = signal<MailboxEntry[]>([]);
readonly attachOpen = signal(false);
attachEmail = '';
attachPassword = '';
query = '';
readonly links = [
{ path: '/contacts', label: 'Contacts', icon: 'contacts' },
{ path: '/blocked', label: 'Blocked senders', icon: 'block' },
{ path: '/settings', label: 'Settings', icon: 'settings' }
{ path: '/settings', label: 'User panel', icon: 'person' }
];
readonly folderActions: RowAction[] = [
@@ -238,8 +212,8 @@ export class ShellComponent {
constructor() {
this.state.refreshFolders();
this.state.refreshProfile();
this.loadBoxes();
const timer = setInterval(() => { this.state.refreshFolders(); this.loadBoxes(); }, 30000);
this.state.refreshBoxes();
const timer = setInterval(() => { this.state.refreshFolders(); this.state.refreshBoxes(); }, 30000);
inject(DestroyRef).onDestroy(() => clearInterval(timer));
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
const q = this.router.parseUrl(this.router.url).queryParams['q'];
@@ -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. */
switchTo(b: MailboxEntry): void {
if (b.email === this.auth.email()) { this.router.navigate(['/']); return; }
@@ -313,37 +283,9 @@ export class ShellComponent {
});
}
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))
});
});
pick(email: string): void {
const b = this.state.boxes().find(x => x.email === email);
if (b) this.switchTo(b);
}
backToAdmin(): void {
+14
View File
@@ -300,6 +300,20 @@ 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::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 */
select { color-scheme: dark; }
select option, select optgroup { background-color: #0f172a; color: #e2e8f0; }
select option:checked { background-color: var(--primary-color); color: #020617; }
.high-contrast select option { background-color: #000000; color: #ffffff; }
.synth-label { display: block; font-family: 'Fira Code', monospace; font-size: 0.72rem; color: var(--primary-color); margin-bottom: 0.3rem; letter-spacing: 0.04em; }
.ghost-btn {