Compare commits

...
Author SHA1 Message Date
Ludy b03e5d43fc Merge branch 'main' into mailbox 2026-08-25 15:44:11 +02:00
Ludy87 6a6be7057a Update translation.toml 2026-08-25 13:57:19 +02:00
Ludy87 ac60091c2b Add Gmail allowed-emails allowlist
Add support for an allowlist of Google account emails for Gmail mailbox connections. Introduces ApplicationProperties.mailbox.gmail.allowedEmails, documents it in settings.yml.template, and exposes it in the admin mailbox settings UI. Server-side enforcement added to GmailOAuthService (throws 403) and GmailOAuthController now redirects the frontend to ?gmail=not-allowed on rejection. Frontend shows localized error messages and handles connect failures. Unit tests updated to cover allowlist behavior.
2026-08-25 13:41:05 +02:00
Ludy87 d470ba5a63 Add Gmail OAuth test coverage and null safety
This change makes Gmail OAuth configuration checks null-safe so startup/configuration failures are handled predictably, keeps HttpClient replaceable for deterministic tests, and adds controller/service coverage for auth flow, token refresh, attachment queries, and disconnect behavior.
2026-08-25 12:54:56 +02:00
Ludy87 c3d5d4b4b7 Add gmail_oauth_connections to SaasSchemaOwnership
Register the 'gmail_oauth_connections' table in the SaaS schema ownership list so it is included in ownership/management rules. Updated app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java to include the table name.
2026-08-24 23:30:29 +02:00
Ludy87 ef47c56035 Update translation.toml 2026-08-24 21:59:33 +02:00
Ludy87 55d4902362 Add mailbox/email UI translations
Add localization entries for mailbox integration and admin mailbox settings. Adds admin.settings.mailbox (and gmail note) plus mailbox-related UI strings (connect, connectGmail, cancel, copySender/Subject, displayName & hints, file type/label filters, loadingMore, save, etc.) and registers "mailbox" in system settings list to enable UI for email attachment import and configuration.
2026-08-24 21:53:57 +02:00
Ludy87 ad9af94fd8 Update EmailInboxPage.tsx 2026-08-24 20:17:07 +02:00
Ludy87 088f33f017 Update EmailInboxPage.tsx 2026-08-24 20:07:16 +02:00
Ludy87 af1c212459 Bump version to 2.15.0 and tweak email UI
Release bump to 2.15.0 across the project (build.gradle, tauri.conf.json, AUR PKGBUILDs, and test simulation appVersion). Rearranged and documented Mailbox OAuth properties in ApplicationProperties (added @since 2.15.x). Email inbox UI/UX improvements: resized/connect panel styling, added close button, swapped icon, improved spacing and text, adjusted connect dialog open/close logic and demo message fallback, and updated related i18n keys (removed obsolete firstSetup key).
2026-08-24 19:48:42 +02:00
Ludy87 48246eb577 Gate email mailbox behind build flags
Adds a shared email mailbox availability flag and hides the /mail route and mailbox UI when the feature is unavailable. Core/web builds enable the inbox, while desktop disables it to avoid unsupported routes. This also updates the demo inbox text and labels to use clearer English copy.
2026-08-24 19:30:12 +02:00
Ludy87 e4a0043bfc Update EmailInboxPage.tsx 2026-08-24 19:14:58 +02:00
Ludy87 adcee1be72 Support Gmail labels and inbox label UI
Backend: load Gmail label names, map message labelIds to human-readable names (excluding UNREAD), and add labels to GmailMessage record.

Frontend: add CSS for label chips and layout fixes; extend MailMessage with labels; track available/selected labels; add label MultiSelect filter and render label chips in message list and details. Also add custom attachment-type input and minor message-list sizing/auto-load adjustments.

This ties Gmail label metadata into the UI and enables filtering/display of message labels.
2026-08-24 19:00:37 +02:00
Ludy87 c57e859aa9 Add persistent Gmail OAuth connections
Introduce GmailConnectionEntity and GmailConnectionRepository to persist encrypted OAuth tokens. Extend GmailOAuthService to save/load connections, refresh access tokens, revoke grants (disconnect), and return a valid token via getValidToken. Update GmailOAuthController to remove session-token reliance, add a DELETE /api/v1/email/gmail/connection endpoint, and use the service token helpers. Include mail package in DatabaseConfig scans. Frontend: add disconnectAccount action and UI button in EmailInboxPage and adjust a placeholder text.
2026-08-24 18:12:13 +02:00
Ludy87 2953358dd1 Add admin mailbox settings & Gmail type filter
Add mailbox config and admin UI plus Gmail attachment-type filtering. Backend: add mailbox.gmail properties to ApplicationProperties, extend GmailOAuthController/Service to accept a `types` query (builds filename query) and expose mailbox in AdminSettingsController. Frontend: add AdminMailboxSection, include nav entry and VALID_NAV_KEYS, update EmailInboxPage (UI, CSS) to support multi-select attachment type filtering and pass types to API, and add OG metadata for /settings/adminMailbox. Note: mailbox changes require server restart.
2026-08-24 17:41:16 +02:00
Ludy87 fc3a8e3232 Improve email inbox filtering and actions
This change improves the email inbox by parsing sender names and addresses more robustly, adding a refresh action and attachment-type filter, and updating the unread badge to reflect the current inbox state. It also adds quick copy actions for subject and sender, and filters the attachment list to match the selected file type.
2026-08-24 17:20:11 +02:00
Ludy87 8ae001edfd Update EmailInboxPage.tsx 2026-08-24 17:06:03 +02:00
Ludy87 63b0eb10e6 Email inbox: add display-name settings & spinner
Add per-account display name settings and a loading-more spinner to the Email inbox UI; persist display name in localStorage. Use a ScrollArea viewportRef (useRef) for reliable infinite-scroll detection and show a small animated spinner when loading additional messages. Include a settings Modal to edit/save/cancel the display name and show the name in the account header (falls back to email). Small cleanup: remove an unused import (List) in GmailOAuthController.java.
2026-08-24 17:00:07 +02:00
Ludy87 7181063bae Gmail: folders, pagination and inbox UI
Add folder + pagination support to Gmail endpoints and wire it into the inbox UI. Backend: GmailOAuthController now accepts folder and pageToken; GmailOAuthService returns a GmailMessagePage (messages + nextPageToken) and queries labelIds/pageToken. Frontend: EmailInboxPage adds folder selection (inbox/starred/trash), incremental loading with nextPageToken, infinite-scroll handler, and duplicate-check against stored file stubs before import. Also wrap /mail route with RequireAuth guard. Small UI/button state updates to reflect active folder.
2026-08-24 16:24:09 +02:00
Ludy87 6e3bc91f7f Persist Gmail OAuth connections per user
Add in-memory per-user storage for Gmail OAuth connections and wire controller to use it. GmailOAuthService now holds a ConcurrentHashMap of GmailConnection records with saveConnection/getConnection APIs. GmailOAuthController records the current username in session, consults/saves the service-side connection as a fallback to session tokens/profiles, and persists the connection after successful OAuth. Also change EmailInboxPage back button to navigate to the editor route ("/editor") instead of history back. Note: connections are stored in-memory (ephemeral) in this change.
2026-08-24 16:08:58 +02:00
Ludy87 f05a695c9b Import email attachments to app
Adds MIME metadata to demo email attachments and wires the inbox download action to fetch the attachment blob and import it via useFileHandler. This lets users add PDF and document files directly from email messages into the editor workflow.
2026-08-24 15:50:49 +02:00
Ludy87 17ab63c579 Add Gmail inbox sync and attachments
Adds Gmail OAuth session-backed inbox listing and attachment download endpoints, including message metadata and attachment parsing in the proprietary backend. The email inbox page now recognizes Gmail callback state, loads real mailbox messages, and downloads attachments from the server instead of relying only on demo data.
2026-08-24 15:39:57 +02:00
Ludy87 b2a54423ef Change Gmail OAuth redirect to /mail
Update GmailOAuthController to redirect to /mail?gmail=connected instead of /editor/mail?gmail=connected. When a frontendUrl is configured the controller now appends "/mail?gmail=connected" (after trimming any trailing slash). This aligns the backend OAuth callback with the frontend route change.
2026-08-24 14:12:48 +02:00
Ludy87 54e7a98ab7 Add Gmail mailbox OAuth flow
Adds a proprietary Gmail OAuth callback and config entries for mailbox credentials. This also introduces an email inbox UI with /mail routing, sidebar/file-source entry points, and an IndexedDB-backed email cache for connected accounts, messages, and attachments.
2026-08-24 14:08:42 +02:00
35 changed files with 3876 additions and 8 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.14.3
pkgver=2.15.0
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.14.3
pkgver=2.15.0
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
@@ -66,6 +66,7 @@ public class ApplicationProperties {
private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated();
private Mail mail = new Mail();
private Mailbox mailbox = new Mailbox();
private Telegram telegram = new Telegram();
private Premium premium = new Premium();
@@ -1416,6 +1417,26 @@ public class ApplicationProperties {
private Boolean sslCheckServerIdentity;
}
/**
* OAuth configuration for external mailbox providers.
*
* @since 2.15.x
*/
@Data
public static class Mailbox {
private Gmail gmail = new Gmail();
@Data
public static class Gmail {
private String clientId = "";
@ToString.Exclude private String clientSecret = "";
private String redirectUri = "";
/** Google account emails allowed to connect to Gmail; empty means all accounts. */
private List<String> allowedEmails = new ArrayList<>();
}
}
/**
* Telegram bot configuration properties.
*
@@ -196,6 +196,7 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith("/api/v1/email/gmail/callback")
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)
@@ -157,6 +157,15 @@ telegram:
errorMessage: true # set to 'false' to hide/suppress error messages to users (to avoid spam)
processing: true # set to 'false' to hide/suppress processing messages to users (to avoid spam)
# Optional mailbox integration. Gmail OAuth is available in the proprietary build.
# Prefer MAILBOX_GMAIL_CLIENT_ID and MAILBOX_GMAIL_CLIENT_SECRET in production.
mailbox:
gmail:
client-id: "" # Google OAuth client ID
client-secret: "" # Google OAuth client secret; do not commit a real secret
redirect-uri: "" # Optional fixed public callback, e.g. https://pdf.example.com/api/v1/email/gmail/callback
allowed-emails: [] # Empty allows all Google accounts; otherwise only these accounts may connect
legal:
termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
privacyPolicy: https://www.stirling.com/legal/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder
@@ -0,0 +1,48 @@
package stirling.software.proprietary.mail;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.integration.crypto.EncryptedStringConverter;
/** Durable, user-owned Gmail OAuth connection. OAuth secrets are encrypted at rest. */
@Entity
@Table(name = "gmail_oauth_connections")
@NoArgsConstructor
@Getter
@Setter
public class GmailConnectionEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username", nullable = false, unique = true, length = 255)
private String username;
@Convert(converter = EncryptedStringConverter.class)
@Column(name = "access_token", nullable = false, length = 4096)
private String accessToken;
@Convert(converter = EncryptedStringConverter.class)
@Column(name = "refresh_token", nullable = false, length = 4096)
private String refreshToken;
@Column(name = "expires_at", nullable = false)
private long expiresAt;
@Column(name = "email", nullable = false, length = 320)
private String email;
@Column(name = "display_name", length = 255)
private String displayName;
}
@@ -0,0 +1,10 @@
package stirling.software.proprietary.mail;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface GmailConnectionRepository extends JpaRepository<GmailConnectionEntity, Long> {
Optional<GmailConnectionEntity> findByUsername(String username);
}
@@ -0,0 +1,234 @@
package stirling.software.proprietary.mail;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Map;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
@RestController
@RequiredArgsConstructor
@Slf4j
public class GmailOAuthController {
static final String STATE_SESSION_KEY = "stirling.gmail.oauth.state";
static final String REDIRECT_URI_SESSION_KEY = "stirling.gmail.oauth.redirect-uri";
static final String PROFILE_SESSION_KEY = "stirling.gmail.oauth.profile";
static final String USER_SESSION_KEY = "stirling.gmail.oauth.username";
private final GmailOAuthService gmailOAuthService;
private final ApplicationProperties applicationProperties;
private final UserServiceInterface userService;
private final SecureRandom secureRandom = new SecureRandom();
@GetMapping("/api/v1/email/gmail/connect")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Map<String, String>> connect(HttpServletRequest request) {
String state = randomState();
HttpSession session = request.getSession(true);
session.setAttribute(STATE_SESSION_KEY, state);
session.setAttribute(USER_SESSION_KEY, userService.getCurrentUsername());
String redirectUri = gmailOAuthService.resolveRedirectUri(request);
session.setAttribute(REDIRECT_URI_SESSION_KEY, redirectUri);
log.info(
"Starting Gmail OAuth: requestUri={}, redirectUri={}, sessionPresent={}, forwardedHost={}, forwardedProto={}, forwardedPort={}",
request.getRequestURI(),
redirectUri,
session != null,
request.getHeader("X-Forwarded-Host"),
request.getHeader("X-Forwarded-Proto"),
request.getHeader("X-Forwarded-Port"));
return ResponseEntity.ok(
Map.of("authorizationUrl", gmailOAuthService.authorizationUrl(state, request)));
}
@GetMapping("/api/v1/email/gmail/status")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> status(HttpServletRequest request) {
HttpSession session = request.getSession(false);
GmailOAuthService.GmailProfile profile =
session == null
? null
: (GmailOAuthService.GmailProfile)
session.getAttribute(PROFILE_SESSION_KEY);
GmailOAuthService.GmailConnection connection =
gmailOAuthService.getConnection(userService.getCurrentUsername());
if (profile == null && connection != null) profile = connection.profile();
boolean tokenPresent = connection != null;
ResponseEntity<?> response =
ResponseEntity.ok(
profile == null
? Map.of("connected", false)
: Map.of(
"connected",
true,
"email",
profile.email(),
"provider",
"Gmail"));
log.info(
"Gmail status: requestUri={}, sessionPresent={}, profilePresent={}, tokenPresent={}, forwardedHost={}, forwardedProto={}, forwardedPort={}",
request.getRequestURI(),
session != null,
profile != null,
tokenPresent,
request.getHeader("X-Forwarded-Host"),
request.getHeader("X-Forwarded-Proto"),
request.getHeader("X-Forwarded-Port"));
return response;
}
@DeleteMapping("/api/v1/email/gmail/connection")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Map<String, Object>> disconnect(HttpServletRequest request) {
String username = userService.getCurrentUsername();
HttpSession session = request.getSession(false);
if (session != null) {
session.removeAttribute(PROFILE_SESSION_KEY);
session.removeAttribute(USER_SESSION_KEY);
}
boolean revoked = gmailOAuthService.disconnect(username);
return ResponseEntity.ok(Map.of("disconnected", true, "googleRevoked", revoked));
}
@GetMapping("/api/v1/email/gmail/messages")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<GmailOAuthService.GmailMessagePage> messages(
@RequestParam(defaultValue = "inbox") String folder,
@RequestParam(required = false) String types,
@RequestParam(required = false) String query,
@RequestParam(required = false) String pageToken,
HttpServletRequest request)
throws IOException, InterruptedException {
GmailOAuthService.GmailToken token = currentToken();
return ResponseEntity.ok(
gmailOAuthService.listMessages(token, folder, types, query, pageToken));
}
@GetMapping("/api/v1/email/gmail/messages/{messageId}/attachments/{attachmentId}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<byte[]> attachment(
@org.springframework.web.bind.annotation.PathVariable String messageId,
@org.springframework.web.bind.annotation.PathVariable String attachmentId,
HttpServletRequest request)
throws IOException, InterruptedException {
GmailOAuthService.GmailAttachmentData attachment =
gmailOAuthService.downloadAttachment(currentToken(), messageId, attachmentId);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentDisposition(
ContentDisposition.attachment().filename(attachmentId).build());
return new ResponseEntity<>(
attachment.data(), headers, org.springframework.http.HttpStatus.OK);
}
private GmailOAuthService.GmailToken currentToken() throws IOException, InterruptedException {
return gmailOAuthService.getValidToken(userService.getCurrentUsername());
}
@GetMapping("/api/v1/email/gmail/callback")
public void callback(
String code,
String state,
String error,
@RequestParam(name = "error_description", required = false) String errorDescription,
HttpServletRequest request,
HttpServletResponse response)
throws IOException, InterruptedException {
log.info(
"Received Gmail OAuth callback: requestUri={}, queryParameterNames={}, codePresent={}, statePresent={}, error={}, sessionPresent={}, forwardedHost={}, forwardedProto={}, forwardedPort={}",
request.getRequestURI(),
request.getParameterMap().keySet(),
code != null && !code.isBlank(),
state != null && !state.isBlank(),
error,
request.getSession(false) != null,
request.getHeader("X-Forwarded-Host"),
request.getHeader("X-Forwarded-Proto"),
request.getHeader("X-Forwarded-Port"));
if (error != null && !error.isBlank()) {
String detail =
errorDescription == null || errorDescription.isBlank()
? error
: error + ": " + errorDescription;
response.sendError(
HttpServletResponse.SC_BAD_REQUEST, "Gmail OAuth was not completed: " + detail);
return;
}
HttpSession session = request.getSession(false);
String expectedState =
session == null ? null : (String) session.getAttribute(STATE_SESSION_KEY);
String redirectUri =
session == null ? null : (String) session.getAttribute(REDIRECT_URI_SESSION_KEY);
log.info(
"Validating Gmail OAuth callback: expectedStatePresent={}, receivedStatePresent={}, stateMatches={}, redirectUriPresent={}, codePresent={}",
expectedState != null,
state != null && !state.isBlank(),
expectedState != null && expectedState.equals(state),
redirectUri != null && !redirectUri.isBlank(),
code != null && !code.isBlank());
if (expectedState == null
|| !expectedState.equals(state)
|| redirectUri == null
|| code == null
|| code.isBlank()) {
response.sendError(
HttpServletResponse.SC_BAD_REQUEST,
"Invalid Gmail OAuth callback: missing or expired code/state");
return;
}
try {
GmailOAuthService.GmailToken token = gmailOAuthService.exchangeCode(code, redirectUri);
GmailOAuthService.GmailProfile profile = gmailOAuthService.getProfile(token);
gmailOAuthService.ensureEmailAllowed(profile.email());
session.removeAttribute(STATE_SESSION_KEY);
session.removeAttribute(REDIRECT_URI_SESSION_KEY);
session.setAttribute(PROFILE_SESSION_KEY, profile);
String username = (String) session.getAttribute(USER_SESSION_KEY);
if (username != null && !username.isBlank()) {
gmailOAuthService.saveConnection(username, token, profile);
}
response.sendRedirect(frontendTarget("connected"));
} catch (ResponseStatusException exception) {
session.removeAttribute(STATE_SESSION_KEY);
session.removeAttribute(REDIRECT_URI_SESSION_KEY);
session.removeAttribute(PROFILE_SESSION_KEY);
log.warn("Gmail OAuth account rejected: {}", exception.getReason());
response.sendRedirect(frontendTarget("not-allowed"));
}
}
private String frontendTarget(String status) {
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
String path = "/mail?gmail=" + status;
return frontendUrl == null || frontendUrl.isBlank()
? path
: frontendUrl.trim().replaceAll("/$", "") + path;
}
private String randomState() {
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
@@ -0,0 +1,464 @@
package stirling.software.proprietary.mail;
import java.io.IOException;
import java.io.Serializable;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@Service
@RequiredArgsConstructor
@Slf4j
public class GmailOAuthService {
private static final String AUTHORIZATION_URI = "https://accounts.google.com/o/oauth2/v2/auth";
private static final String TOKEN_URI = "https://oauth2.googleapis.com/token";
private static final String USER_INFO_URI = "https://www.googleapis.com/oauth2/v3/userinfo";
private static final String GMAIL_API_URI = "https://gmail.googleapis.com/gmail/v1/users/me";
static final String READONLY_SCOPE =
"openid email https://www.googleapis.com/auth/gmail.readonly";
private final ObjectMapper objectMapper;
private final GmailConnectionRepository connectionRepository;
private final ApplicationProperties applicationProperties;
// Kept replaceable for deterministic tests; production uses the standard JDK client.
private HttpClient httpClient = HttpClient.newHttpClient();
@Value("${mailbox.gmail.client-id:}")
private String clientId;
@Value("${mailbox.gmail.client-secret:}")
private String clientSecret;
@Value("${mailbox.gmail.redirect-uri:}")
private String redirectUri;
public String authorizationUrl(String state, HttpServletRequest request) {
requireConfigured();
String resolvedRedirectUri = resolveRedirectUri(request);
Map<String, String> params = new LinkedHashMap<>();
params.put("client_id", clientId);
params.put("redirect_uri", resolvedRedirectUri);
params.put("response_type", "code");
params.put("scope", READONLY_SCOPE);
params.put("access_type", "offline");
params.put("prompt", "consent");
params.put("state", state);
return AUTHORIZATION_URI + "?" + formEncode(params);
}
public GmailToken exchangeCode(String code, String resolvedRedirectUri)
throws IOException, InterruptedException {
requireConfigured();
Map<String, String> form = new LinkedHashMap<>();
form.put("code", code);
form.put("client_id", clientId);
form.put("client_secret", clientSecret);
form.put("redirect_uri", resolvedRedirectUri);
form.put("grant_type", "authorization_code");
HttpRequest request =
HttpRequest.newBuilder(URI.create(TOKEN_URI))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(formEncode(form)))
.build();
HttpResponse<String> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) {
throw new IOException(
"Gmail OAuth token exchange failed: HTTP " + response.statusCode());
}
JsonNode body = objectMapper.readTree(response.body());
String accessToken = body.path("access_token").asText("");
String refreshToken = body.path("refresh_token").asText("");
long expiresIn = body.path("expires_in").asLong(3600);
if (accessToken.isBlank())
throw new IOException("Gmail OAuth response did not contain an access token");
return new GmailToken(
accessToken, refreshToken, System.currentTimeMillis() + expiresIn * 1000L);
}
public GmailProfile getProfile(GmailToken token) throws IOException, InterruptedException {
HttpRequest request =
HttpRequest.newBuilder(URI.create(USER_INFO_URI))
.header("Authorization", "Bearer " + token.accessToken())
.GET()
.build();
HttpResponse<String> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) {
throw new IOException("Gmail profile request failed: HTTP " + response.statusCode());
}
JsonNode body = objectMapper.readTree(response.body());
return new GmailProfile(body.path("email").asText(""), body.path("name").asText(""));
}
public void saveConnection(String username, GmailToken token, GmailProfile profile) {
ensureEmailAllowed(profile.email());
GmailConnectionEntity entity =
connectionRepository.findByUsername(username).orElseGet(GmailConnectionEntity::new);
String refreshToken = token.refreshToken();
if ((refreshToken == null || refreshToken.isBlank())
&& entity.getRefreshToken() != null
&& !entity.getRefreshToken().isBlank()) {
refreshToken = entity.getRefreshToken();
}
entity.setUsername(username);
entity.setAccessToken(token.accessToken());
entity.setRefreshToken(refreshToken == null ? "" : refreshToken);
entity.setExpiresAt(token.expiresAt());
entity.setEmail(profile.email());
entity.setDisplayName(profile.name());
connectionRepository.save(entity);
}
/**
* Ensures that only configured Google accounts can create or retain a mailbox connection. An
* empty allowlist intentionally permits every Google account.
*/
void ensureEmailAllowed(String email) {
List<String> allowedEmails =
applicationProperties.getMailbox().getGmail().getAllowedEmails();
boolean allowAll =
allowedEmails == null
|| allowedEmails.stream()
.allMatch(value -> value == null || value.isBlank());
boolean allowed =
allowAll
|| (email != null
&& allowedEmails.stream()
.filter(value -> value != null && !value.isBlank())
.map(String::trim)
.anyMatch(value -> value.equalsIgnoreCase(email.trim())));
if (!allowed) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "This Google account is not allowed to connect");
}
}
public GmailConnection getConnection(String username) {
return connectionRepository
.findByUsername(username)
.map(
entity ->
new GmailConnection(
new GmailToken(
entity.getAccessToken(),
entity.getRefreshToken(),
entity.getExpiresAt()),
new GmailProfile(
entity.getEmail(), entity.getDisplayName())))
.orElse(null);
}
/** Removes the local connection and revokes the Google grant when possible. */
public boolean disconnect(String username) {
GmailConnectionEntity entity = connectionRepository.findByUsername(username).orElse(null);
if (entity == null) return false;
boolean revoked = false;
try {
String revokeToken =
entity.getRefreshToken() == null || entity.getRefreshToken().isBlank()
? entity.getAccessToken()
: entity.getRefreshToken();
revoked = revokeToken(revokeToken);
} catch (IOException e) {
log.warn(
"Could not revoke Gmail OAuth grant for user '{}'; local connection removed",
username,
e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn(
"Gmail OAuth revoke interrupted for user '{}'; local connection removed",
username);
} finally {
connectionRepository.delete(entity);
}
return revoked;
}
private boolean revokeToken(String token) throws IOException, InterruptedException {
HttpRequest request =
HttpRequest.newBuilder(URI.create("https://oauth2.googleapis.com/revoke"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString("token=" + encode(token)))
.build();
HttpResponse<String> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return response.statusCode() / 100 == 2 || response.statusCode() == 400;
}
public GmailToken getValidToken(String username) throws IOException, InterruptedException {
GmailConnection connection = getConnection(username);
if (connection == null) {
throw new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.UNAUTHORIZED,
"Gmail mailbox is not connected");
}
GmailToken token = connection.token();
if (token.expiresAt() > System.currentTimeMillis() + 60_000L) {
return token;
}
if (token.refreshToken() == null || token.refreshToken().isBlank()) {
throw new IOException("Gmail connection has no refresh token; reconnect required");
}
GmailToken refreshed = refreshToken(token.refreshToken());
saveConnection(username, refreshed, connection.profile());
return refreshed;
}
private GmailToken refreshToken(String refreshToken) throws IOException, InterruptedException {
requireConfigured();
Map<String, String> form = new LinkedHashMap<>();
form.put("client_id", clientId);
form.put("client_secret", clientSecret);
form.put("refresh_token", refreshToken);
form.put("grant_type", "refresh_token");
HttpRequest request =
HttpRequest.newBuilder(URI.create(TOKEN_URI))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(formEncode(form)))
.build();
HttpResponse<String> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) {
throw new IOException(
"Gmail OAuth token refresh failed: HTTP " + response.statusCode());
}
JsonNode body = objectMapper.readTree(response.body());
String accessToken = body.path("access_token").asText("");
if (accessToken.isBlank()) {
throw new IOException("Gmail OAuth refresh response did not contain an access token");
}
long expiresIn = body.path("expires_in").asLong(3600);
return new GmailToken(
accessToken, refreshToken, System.currentTimeMillis() + expiresIn * 1000L);
}
public GmailMessagePage listMessages(
GmailToken token, String folder, String types, String query, String pageToken)
throws IOException, InterruptedException {
String label =
switch (folder) {
case "starred" -> "STARRED";
case "trash" -> "TRASH";
default -> "INBOX";
};
String pageQuery =
pageToken == null || pageToken.isBlank()
? ""
: "&pageToken=" + URLEncoder.encode(pageToken, StandardCharsets.UTF_8);
String gmailQuery = buildGmailQuery(types, query);
JsonNode list =
sendJson(
token,
GMAIL_API_URI
+ "/messages?labelIds="
+ label
+ "&maxResults=25&q="
+ URLEncoder.encode(gmailQuery, StandardCharsets.UTF_8)
+ pageQuery);
List<GmailMessage> messages = new ArrayList<>();
Map<String, String> labelNames = loadLabelNames(token);
for (JsonNode item : list.path("messages")) {
JsonNode message =
sendJson(
token,
GMAIL_API_URI
+ "/messages/"
+ item.path("id").asText()
+ "?format=full");
messages.add(toMessage(message, labelNames));
}
return new GmailMessagePage(messages, list.path("nextPageToken").asText(null));
}
private String buildGmailQuery(String types, String query) {
StringBuilder gmailQuery = new StringBuilder("has:attachment");
if (types != null && !types.isBlank()) {
String filenameQuery =
Arrays.stream(types.split(","))
.map(String::trim)
.map(String::toLowerCase)
.filter(type -> type.matches("[a-z0-9]{1,10}"))
.distinct()
.map(type -> "filename:" + type)
.collect(Collectors.joining(" "));
if (!filenameQuery.isBlank()) {
gmailQuery.append(" {").append(filenameQuery).append("}");
}
}
if (query != null && !query.isBlank()) {
gmailQuery.append(' ').append(query.trim());
}
return gmailQuery.toString();
}
public GmailAttachmentData downloadAttachment(
GmailToken token, String messageId, String attachmentId)
throws IOException, InterruptedException {
JsonNode attachment =
sendJson(
token,
GMAIL_API_URI + "/messages/" + messageId + "/attachments/" + attachmentId);
byte[] data = Base64.getUrlDecoder().decode(attachment.path("data").asText(""));
return new GmailAttachmentData(data);
}
private Map<String, String> loadLabelNames(GmailToken token)
throws IOException, InterruptedException {
Map<String, String> labelNames = new LinkedHashMap<>();
JsonNode response = sendJson(token, GMAIL_API_URI + "/labels");
for (JsonNode label : response.path("labels")) {
String id = label.path("id").asText("");
String name = label.path("name").asText("");
if (!id.isBlank() && !name.isBlank()) {
labelNames.put(id, name);
}
}
return labelNames;
}
private GmailMessage toMessage(JsonNode message, Map<String, String> labelNames) {
JsonNode payload = message.path("payload");
String from = header(payload, "From");
String subject = header(payload, "Subject");
String date = header(payload, "Date");
List<String> labels = new ArrayList<>();
for (JsonNode labelId : message.path("labelIds")) {
String id = labelId.asText("");
String name = labelNames.get(id);
if (!"UNREAD".equals(id) && name != null && !name.isBlank()) {
labels.add(name);
}
}
List<GmailAttachment> attachments = new ArrayList<>();
collectAttachments(payload, attachments);
return new GmailMessage(
message.path("id").asText(),
from,
subject,
message.path("snippet").asText(""),
date,
message.path("labelIds").toString().contains("UNREAD"),
labels,
attachments);
}
private void collectAttachments(JsonNode part, List<GmailAttachment> attachments) {
String filename = part.path("filename").asText("");
String attachmentId = part.path("body").path("attachmentId").asText("");
if (!filename.isBlank() && !attachmentId.isBlank()) {
attachments.add(
new GmailAttachment(
attachmentId,
filename,
part.path("mimeType").asText("application/octet-stream"),
part.path("body").path("size").asLong(0)));
}
for (JsonNode child : part.path("parts")) {
collectAttachments(child, attachments);
}
}
private String header(JsonNode payload, String name) {
for (JsonNode header : payload.path("headers")) {
if (name.equalsIgnoreCase(header.path("name").asText())) {
return header.path("value").asText("");
}
}
return "";
}
private JsonNode sendJson(GmailToken token, String url)
throws IOException, InterruptedException {
HttpRequest request =
HttpRequest.newBuilder(URI.create(url))
.header("Authorization", "Bearer " + token.accessToken())
.GET()
.build();
HttpResponse<String> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) {
throw new IOException("Gmail API request failed: HTTP " + response.statusCode());
}
return objectMapper.readTree(response.body());
}
public String resolveRedirectUri(HttpServletRequest request) {
if (redirectUri != null && !redirectUri.isBlank()) return redirectUri;
return ServletUriComponentsBuilder.fromCurrentContextPath()
.path("/api/v1/email/gmail/callback")
.build()
.toUriString();
}
private void requireConfigured() {
if (clientId == null
|| clientId.isBlank()
|| clientSecret == null
|| clientSecret.isBlank()) {
throw new IllegalStateException("Gmail OAuth is not configured on the server");
}
}
private static String formEncode(Map<String, String> values) {
return values.entrySet().stream()
.map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue()))
.collect(java.util.stream.Collectors.joining("&"));
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
public record GmailToken(String accessToken, String refreshToken, long expiresAt)
implements Serializable {}
public record GmailProfile(String email, String name) implements Serializable {}
public record GmailMessage(
String id,
String sender,
String subject,
String preview,
String date,
boolean unread,
List<String> labels,
List<GmailAttachment> attachments) {}
public record GmailMessagePage(List<GmailMessage> messages, String nextPageToken) {}
public record GmailAttachment(String id, String name, String mimeType, long size) {}
public record GmailAttachmentData(byte[] data) {}
public record GmailConnection(GmailToken token, GmailProfile profile) {}
}
@@ -40,7 +40,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.repository",
"stirling.software.proprietary.integration.repository",
"stirling.software.proprietary.failure"
"stirling.software.proprietary.failure",
"stirling.software.proprietary.mail"
})
@EntityScan({
"stirling.software.proprietary.security.model",
@@ -55,7 +56,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.model",
"stirling.software.proprietary.integration.model",
"stirling.software.proprietary.failure"
"stirling.software.proprietary.failure",
"stirling.software.proprietary.mail"
})
public class DatabaseConfig {
@@ -665,6 +665,7 @@ public class AdminSettingsController {
case "endpoints" -> applicationProperties.getEndpoints();
case "metrics" -> applicationProperties.getMetrics();
case "mail" -> applicationProperties.getMail();
case "mailbox" -> applicationProperties.getMailbox();
case "storage" -> applicationProperties.getStorage();
case "premium" -> applicationProperties.getPremium();
case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor();
@@ -690,6 +691,7 @@ public class AdminSettingsController {
"endpoints",
"metrics",
"mail",
"mailbox",
"storage",
"premium",
"processExecutor",
@@ -0,0 +1,211 @@
package stirling.software.proprietary.mail;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.core.context.SecurityContextHolder;
import jakarta.servlet.http.HttpSession;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
@ExtendWith(MockitoExtension.class)
class GmailOAuthControllerTest {
@Mock private GmailOAuthService gmailOAuthService;
@Mock private UserServiceInterface userService;
private ApplicationProperties applicationProperties;
private GmailOAuthController controller;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
controller =
new GmailOAuthController(gmailOAuthService, applicationProperties, userService);
lenient().when(userService.getCurrentUsername()).thenReturn("admin");
SecurityContextHolder.clearContext();
}
@Test
void connectStoresOAuthStateAndReturnsAuthorizationUrl() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/api/v1/email/gmail/connect");
when(gmailOAuthService.resolveRedirectUri(request))
.thenReturn("https://frontend.example.com/api/v1/email/gmail/callback");
when(gmailOAuthService.authorizationUrl(
org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.same(request)))
.thenReturn("https://accounts.google.com/oauth");
var result = controller.connect(request);
assertThat(result.getBody())
.containsEntry("authorizationUrl", "https://accounts.google.com/oauth");
HttpSession session = request.getSession(false);
assertThat(session.getAttribute(GmailOAuthController.STATE_SESSION_KEY))
.isInstanceOf(String.class);
assertThat(session.getAttribute(GmailOAuthController.USER_SESSION_KEY)).isEqualTo("admin");
assertThat(session.getAttribute(GmailOAuthController.REDIRECT_URI_SESSION_KEY))
.isEqualTo("https://frontend.example.com/api/v1/email/gmail/callback");
}
@Test
void reportsDisconnectedWhenNoSessionOrPersistentConnectionExists() {
MockHttpServletRequest request = new MockHttpServletRequest();
when(gmailOAuthService.getConnection("admin")).thenReturn(null);
assertThat(controller.status(request).getBody()).isEqualTo(Map.of("connected", false));
}
@Test
void reportsTheSessionProfileBeforeLookingAtPersistentConnection() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.getSession(true)
.setAttribute(
GmailOAuthController.PROFILE_SESSION_KEY,
new GmailOAuthService.GmailProfile("admin@example.com", "Admin"));
var body = controller.status(request).getBody();
assertThat(body)
.isEqualTo(
Map.of(
"connected",
true,
"email",
"admin@example.com",
"provider",
"Gmail"));
}
@Test
void usesPersistentConnectionWhenSessionProfileIsMissing() {
MockHttpServletRequest request = new MockHttpServletRequest();
when(gmailOAuthService.getConnection("admin"))
.thenReturn(
new GmailOAuthService.GmailConnection(
new GmailOAuthService.GmailToken("access", "refresh", 1L),
new GmailOAuthService.GmailProfile("admin@example.com", "Admin")));
assertThat(controller.status(request).getBody())
.isEqualTo(
Map.of(
"connected",
true,
"email",
"admin@example.com",
"provider",
"Gmail"));
}
@Test
void disconnectsConnectionAndClearsSessionAttributes() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpSession session = (MockHttpSession) request.getSession(true);
session.setAttribute(GmailOAuthController.PROFILE_SESSION_KEY, "profile");
session.setAttribute(GmailOAuthController.USER_SESSION_KEY, "admin");
when(gmailOAuthService.disconnect("admin")).thenReturn(true);
assertThat(controller.disconnect(request).getBody())
.containsEntry("disconnected", true)
.containsEntry("googleRevoked", true);
assertThat(session.getAttribute(GmailOAuthController.PROFILE_SESSION_KEY)).isNull();
verify(gmailOAuthService).disconnect("admin");
}
@Test
void forwardsMessageQueryToServiceWithCurrentToken() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
var token = new GmailOAuthService.GmailToken("access", "refresh", Long.MAX_VALUE);
var page = new GmailOAuthService.GmailMessagePage(List.of(), "next");
when(gmailOAuthService.getValidToken("admin")).thenReturn(token);
when(gmailOAuthService.listMessages(token, "starred", "pdf,png", "invoice", "page-2"))
.thenReturn(page);
assertThat(
controller
.messages("starred", "pdf,png", "invoice", "page-2", request)
.getBody())
.isSameAs(page);
verify(gmailOAuthService).listMessages(token, "starred", "pdf,png", "invoice", "page-2");
}
@Test
void returnsAttachmentAsDownload() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
var token = new GmailOAuthService.GmailToken("access", "refresh", Long.MAX_VALUE);
when(gmailOAuthService.getValidToken("admin")).thenReturn(token);
when(gmailOAuthService.downloadAttachment(token, "message-1", "file-1"))
.thenReturn(new GmailOAuthService.GmailAttachmentData(new byte[] {1, 2, 3}));
var response = controller.attachment("message-1", "file-1", request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getHeaders().getContentType().toString())
.isEqualTo("application/octet-stream");
assertThat(response.getHeaders().getContentDisposition().getFilename()).isEqualTo("file-1");
assertThat(response.getBody()).containsExactly(1, 2, 3);
}
@Test
void rejectsCallbackWithMissingState() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
controller.callback("code", "state", null, null, request, response);
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.getErrorMessage()).contains("missing or expired code/state");
}
@Test
void rejectsCallbackWhenGoogleReturnsAnError() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
controller.callback(null, null, "access_denied", "User denied access", request, response);
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.getErrorMessage()).contains("access_denied: User denied access");
}
@Test
void exchangesSuccessfulCallbackPersistsConnectionAndRedirectsToFrontend() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
MockHttpSession session = (MockHttpSession) request.getSession(true);
session.setAttribute(GmailOAuthController.STATE_SESSION_KEY, "expected-state");
session.setAttribute(GmailOAuthController.REDIRECT_URI_SESSION_KEY, "https://callback");
session.setAttribute(GmailOAuthController.USER_SESSION_KEY, "admin");
applicationProperties.getSystem().setFrontendUrl("https://frontend.example.com/");
var token = new GmailOAuthService.GmailToken("access", "refresh", Long.MAX_VALUE);
var profile = new GmailOAuthService.GmailProfile("admin@example.com", "Admin");
when(gmailOAuthService.exchangeCode("code", "https://callback")).thenReturn(token);
when(gmailOAuthService.getProfile(token)).thenReturn(profile);
controller.callback("code", "expected-state", null, null, request, response);
assertThat(response.getRedirectedUrl())
.isEqualTo("https://frontend.example.com/mail?gmail=connected");
assertThat(session.getAttribute(GmailOAuthController.STATE_SESSION_KEY)).isNull();
assertThat(session.getAttribute(GmailOAuthController.PROFILE_SESSION_KEY))
.isEqualTo(profile);
verify(gmailOAuthService).saveConnection("admin", token, profile);
}
}
@@ -0,0 +1,588 @@
package stirling.software.proprietary.mail;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Version;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;
import java.util.Map;
import java.util.Optional;
import javax.net.ssl.SSLSession;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import tools.jackson.databind.ObjectMapper;
@ExtendWith(MockitoExtension.class)
class GmailOAuthServiceTest {
@Mock private GmailConnectionRepository connectionRepository;
@Mock private HttpClient httpClient;
private GmailOAuthService service;
private ApplicationProperties applicationProperties;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
service =
new GmailOAuthService(
new ObjectMapper(), connectionRepository, applicationProperties);
ReflectionTestUtils.setField(service, "httpClient", httpClient);
}
@Test
void alwaysRestrictsMessageSearchToMessagesWithAttachments() {
String gmailQuery = buildQuery(null, null);
assertThat(gmailQuery).isEqualTo("has:attachment");
}
@Test
void buildsNormalizedMultiTypeAndTextQuery() {
String gmailQuery = buildQuery(" PDF, png, PDF, invalid-type ", " from:billing invoice ");
assertThat(gmailQuery)
.isEqualTo("has:attachment {filename:pdf filename:png} from:billing invoice");
}
@Test
void ignoresInvalidFileTypesButKeepsFreeTextSearch() {
String gmailQuery = buildQuery("pdf,application,verylongextension123", "invoice");
assertThat(gmailQuery).isEqualTo("has:attachment {filename:pdf} invoice");
}
@Test
void parsesLabelsAndNestedAttachments() throws Exception {
var message =
new ObjectMapper()
.readTree(
"""
{
"id": "message-1",
"snippet": "Invoice attached",
"labelIds": ["INBOX", "UNREAD", "Label_1"],
"payload": {
"headers": [
{"name": "From", "value": "Billing <billing@example.com>"},
{"name": "Subject", "value": "Invoice"},
{"name": "Date", "value": "Tue, 25 Aug 2026 10:00:00 +0000"}
],
"parts": [
{
"filename": "invoice.pdf",
"mimeType": "application/pdf",
"body": {"attachmentId": "attachment-1", "size": 2048}
}
]
}
}
""");
GmailOAuthService.GmailMessage result =
ReflectionTestUtils.invokeMethod(
service,
"toMessage",
message,
Map.of("INBOX", "Inbox", "Label_1", "Finance"));
assertThat(result).isNotNull();
assertThat(result.id()).isEqualTo("message-1");
assertThat(result.sender()).isEqualTo("Billing <billing@example.com>");
assertThat(result.subject()).isEqualTo("Invoice");
assertThat(result.unread()).isTrue();
assertThat(result.labels()).containsExactly("Inbox", "Finance");
assertThat(result.attachments())
.singleElement()
.satisfies(
attachment -> {
assertThat(attachment.id()).isEqualTo("attachment-1");
assertThat(attachment.name()).isEqualTo("invoice.pdf");
assertThat(attachment.mimeType()).isEqualTo("application/pdf");
assertThat(attachment.size()).isEqualTo(2048);
});
}
@Test
void keepsExistingRefreshTokenWhenGoogleOmitsItOnRefresh() {
GmailConnectionEntity existing = new GmailConnectionEntity();
existing.setUsername("admin");
existing.setRefreshToken("refresh-token");
when(connectionRepository.findByUsername("admin")).thenReturn(Optional.of(existing));
service.saveConnection(
"admin",
new GmailOAuthService.GmailToken("access-token", "", 123L),
new GmailOAuthService.GmailProfile("admin@example.com", "Admin"));
assertThat(existing.getAccessToken()).isEqualTo("access-token");
assertThat(existing.getRefreshToken()).isEqualTo("refresh-token");
assertThat(existing.getExpiresAt()).isEqualTo(123L);
assertThat(existing.getEmail()).isEqualTo("admin@example.com");
assertThat(existing.getDisplayName()).isEqualTo("Admin");
verify(connectionRepository).save(existing);
}
@Test
void allowsEveryGoogleAccountWhenLoginAllowlistIsEmpty() {
service.ensureEmailAllowed("anyone@example.com");
}
@Test
void allowsConfiguredGoogleAccountIgnoringCaseAndWhitespace() {
applicationProperties
.getMailbox()
.getGmail()
.setAllowedEmails(java.util.List.of(" admin@example.com "));
service.ensureEmailAllowed("ADMIN@EXAMPLE.COM");
}
@Test
void rejectsGoogleAccountOutsideLoginAllowlist() {
applicationProperties
.getMailbox()
.getGmail()
.setAllowedEmails(java.util.List.of("allowed@example.com"));
assertThatThrownBy(() -> service.ensureEmailAllowed("blocked@example.com"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
error -> {
ResponseStatusException exception = (ResponseStatusException) error;
assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(exception.getReason())
.isEqualTo("This Google account is not allowed to connect");
});
}
@Test
void doesNotPersistConnectionForDisallowedGoogleAccount() {
applicationProperties
.getMailbox()
.getGmail()
.setAllowedEmails(java.util.List.of("allowed@example.com"));
assertThatThrownBy(
() ->
service.saveConnection(
"admin",
new GmailOAuthService.GmailToken("access", "refresh", 1L),
new GmailOAuthService.GmailProfile(
"blocked@example.com", "Blocked")))
.isInstanceOf(ResponseStatusException.class);
org.mockito.Mockito.verifyNoInteractions(connectionRepository);
}
@Test
void rejectsRequestsWhenNoGmailConnectionExists() {
when(connectionRepository.findByUsername("admin")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getValidToken("admin"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
error -> {
ResponseStatusException exception = (ResponseStatusException) error;
assertThat(exception.getStatusCode())
.isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(exception.getReason())
.isEqualTo("Gmail mailbox is not connected");
});
}
@Test
void disconnectDoesNothingWhenNoConnectionExists() {
when(connectionRepository.findByUsername("admin")).thenReturn(Optional.empty());
assertThat(service.disconnect("admin")).isFalse();
verify(connectionRepository).findByUsername("admin");
}
@Test
void createsAuthorizationUrlWithConfiguredClientAndRedirect() {
ReflectionTestUtils.setField(service, "clientId", "client-id");
ReflectionTestUtils.setField(service, "clientSecret", "client-secret");
ReflectionTestUtils.setField(service, "redirectUri", "https://example.com/callback");
String url =
service.authorizationUrl(
"state-123", new org.springframework.mock.web.MockHttpServletRequest());
assertThat(url)
.contains("client_id=client-id")
.contains("redirect_uri=https%3A%2F%2Fexample.com%2Fcallback")
.contains("state=state-123")
.contains("access_type=offline")
.contains("prompt=consent");
}
@Test
void exchangesCodeAndBuildsToken() throws Exception {
ReflectionTestUtils.setField(service, "clientId", "client-id");
ReflectionTestUtils.setField(service, "clientSecret", "client-secret");
HttpResponse<String> response =
response(
200,
"{\"access_token\":\"access\",\"refresh_token\":\"refresh\",\"expires_in\":3600}");
when(httpClient.<String>send(any(), any())).thenReturn(response);
GmailOAuthService.GmailToken token = service.exchangeCode("code", "https://callback");
assertThat(token.accessToken()).isEqualTo("access");
assertThat(token.refreshToken()).isEqualTo("refresh");
assertThat(token.expiresAt()).isGreaterThan(System.currentTimeMillis());
}
@Test
void rejectsFailedCodeExchange() throws Exception {
ReflectionTestUtils.setField(service, "clientId", "client-id");
ReflectionTestUtils.setField(service, "clientSecret", "client-secret");
when(httpClient.<String>send(any(), any())).thenReturn(response(400, "{}"));
assertThatThrownBy(() -> service.exchangeCode("code", "https://callback"))
.isInstanceOf(java.io.IOException.class)
.hasMessageContaining("HTTP 400");
}
@Test
void rejectsCodeExchangeWithoutAccessToken() throws Exception {
ReflectionTestUtils.setField(service, "clientId", "client-id");
ReflectionTestUtils.setField(service, "clientSecret", "client-secret");
when(httpClient.<String>send(any(), any())).thenReturn(response(200, "{}"));
assertThatThrownBy(() -> service.exchangeCode("code", "https://callback"))
.isInstanceOf(java.io.IOException.class)
.hasMessageContaining("access token");
}
@Test
void readsGmailProfile() throws Exception {
HttpResponse<String> profileResponse =
response(200, "{\"email\":\"admin@example.com\",\"name\":\"Admin\"}");
when(httpClient.<String>send(any(), any())).thenReturn(profileResponse);
assertThat(service.getProfile(new GmailOAuthService.GmailToken("access", "refresh", 1L)))
.isEqualTo(new GmailOAuthService.GmailProfile("admin@example.com", "Admin"));
}
@Test
void rejectsFailedGmailProfileRequest() throws Exception {
when(httpClient.<String>send(any(), any())).thenReturn(response(403, "{}"));
assertThatThrownBy(
() ->
service.getProfile(
new GmailOAuthService.GmailToken("access", "refresh", 1L)))
.isInstanceOf(java.io.IOException.class)
.hasMessageContaining("HTTP 403");
}
@Test
void returnsStoredConnectionAndUnexpiredToken() throws Exception {
GmailConnectionEntity entity =
connectionEntity("admin", "access", "refresh", Long.MAX_VALUE);
when(connectionRepository.findByUsername("admin")).thenReturn(Optional.of(entity));
GmailOAuthService.GmailConnection connection = service.getConnection("admin");
GmailOAuthService.GmailToken token = service.getValidToken("admin");
assertThat(connection.profile().email()).isEqualTo("admin@example.com");
assertThat(token.accessToken()).isEqualTo("access");
}
@Test
void refreshesExpiredTokenAndPersistsNewAccessToken() throws Exception {
GmailConnectionEntity entity = connectionEntity("admin", "old-access", "refresh", 0L);
when(connectionRepository.findByUsername("admin")).thenReturn(Optional.of(entity));
ReflectionTestUtils.setField(service, "clientId", "client-id");
ReflectionTestUtils.setField(service, "clientSecret", "client-secret");
HttpResponse<String> refreshResponse =
response(200, "{\"access_token\":\"new-access\",\"expires_in\":3600}");
when(httpClient.<String>send(any(), any())).thenReturn(refreshResponse);
GmailOAuthService.GmailToken token = service.getValidToken("admin");
assertThat(token.accessToken()).isEqualTo("new-access");
assertThat(token.refreshToken()).isEqualTo("refresh");
verify(connectionRepository).save(entity);
}
@Test
void rejectsExpiredConnectionWithoutRefreshToken() {
GmailConnectionEntity entity = connectionEntity("admin", "old-access", "", 0L);
when(connectionRepository.findByUsername("admin")).thenReturn(Optional.of(entity));
assertThatThrownBy(() -> service.getValidToken("admin"))
.isInstanceOf(java.io.IOException.class)
.hasMessageContaining("no refresh token");
}
@Test
void rejectsFailedTokenRefresh() throws Exception {
GmailConnectionEntity entity = connectionEntity("admin", "old-access", "refresh", 0L);
when(connectionRepository.findByUsername("admin")).thenReturn(Optional.of(entity));
ReflectionTestUtils.setField(service, "clientId", "client-id");
ReflectionTestUtils.setField(service, "clientSecret", "client-secret");
when(httpClient.<String>send(any(), any())).thenReturn(response(401, "{}"));
assertThatThrownBy(() -> service.getValidToken("admin"))
.isInstanceOf(java.io.IOException.class)
.hasMessageContaining("HTTP 401");
}
@Test
void listsMessagesWithLabelsAndNestedAttachments() throws Exception {
HttpResponse<String> listResponse =
response(200, "{\"messages\":[{\"id\":\"message-1\"}],\"nextPageToken\":\"next\"}");
HttpResponse<String> labelsResponse =
response(200, "{\"labels\":[{\"id\":\"INBOX\",\"name\":\"Inbox\"}]}");
HttpResponse<String> messageResponse =
response(
200,
"{\"id\":\"message-1\",\"snippet\":\"Invoice\",\"labelIds\":[\"INBOX\"],\"payload\":{\"headers\":[],\"parts\":[{\"filename\":\"invoice.pdf\",\"body\":{\"attachmentId\":\"a1\",\"size\":12}}]}}");
when(httpClient.<String>send(any(), any()))
.thenReturn(listResponse)
.thenReturn(labelsResponse)
.thenReturn(messageResponse);
GmailOAuthService.GmailMessagePage page =
service.listMessages(
new GmailOAuthService.GmailToken("access", "refresh", Long.MAX_VALUE),
"inbox",
"pdf",
"invoice",
"page-1");
assertThat(page.nextPageToken()).isEqualTo("next");
assertThat(page.messages())
.singleElement()
.satisfies(
message -> {
assertThat(message.labels()).containsExactly("Inbox");
assertThat(message.attachments())
.singleElement()
.extracting("name")
.isEqualTo("invoice.pdf");
});
}
@Test
void supportsStarredFolderAndBlankPageToken() throws Exception {
when(httpClient.<String>send(any(), any()))
.thenReturn(response(200, "{\"messages\":[]}"))
.thenReturn(response(200, "{\"labels\":[]}"));
GmailOAuthService.GmailMessagePage page =
service.listMessages(
new GmailOAuthService.GmailToken("access", "refresh", Long.MAX_VALUE),
"starred",
null,
null,
" ");
assertThat(page.messages()).isEmpty();
assertThat(page.nextPageToken()).isNull();
}
@Test
void supportsTrashFolder() throws Exception {
when(httpClient.<String>send(any(), any()))
.thenReturn(response(200, "{\"messages\":[]}"))
.thenReturn(response(200, "{\"labels\":[]}"));
assertThat(
service.listMessages(
new GmailOAuthService.GmailToken(
"access", "refresh", Long.MAX_VALUE),
"trash",
"",
"",
null)
.messages())
.isEmpty();
}
@Test
void downloadsBase64UrlEncodedAttachment() throws Exception {
String encoded =
Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[] {1, 2, 3});
HttpResponse<String> attachmentResponse = response(200, "{\"data\":\"" + encoded + "\"}");
when(httpClient.<String>send(any(), any())).thenReturn(attachmentResponse);
assertThat(
service.downloadAttachment(
new GmailOAuthService.GmailToken(
"access", "refresh", Long.MAX_VALUE),
"message-1",
"attachment-1")
.data())
.containsExactly(1, 2, 3);
}
@Test
void revokesTokenAndDeletesConnection() throws Exception {
GmailConnectionEntity entity =
connectionEntity("admin", "access", "refresh", Long.MAX_VALUE);
when(connectionRepository.findByUsername("admin")).thenReturn(Optional.of(entity));
HttpResponse<String> revokeResponse = new StubHttpResponse(200, "");
when(httpClient.<String>send(any(), any())).thenReturn(revokeResponse);
assertThat(service.disconnect("admin")).isTrue();
verify(connectionRepository).delete(entity);
}
@Test
void deletesConnectionWhenGoogleRevokeFails() throws Exception {
GmailConnectionEntity entity =
connectionEntity("admin", "access", "refresh", Long.MAX_VALUE);
when(connectionRepository.findByUsername("admin")).thenReturn(Optional.of(entity));
when(httpClient.<String>send(any(), any())).thenReturn(response(500, ""));
assertThat(service.disconnect("admin")).isFalse();
verify(connectionRepository).delete(entity);
}
@Test
void deletesConnectionWhenRevokeRequestFails() throws Exception {
GmailConnectionEntity entity =
connectionEntity("admin", "access", "refresh", Long.MAX_VALUE);
when(connectionRepository.findByUsername("admin")).thenReturn(Optional.of(entity));
when(httpClient.<String>send(any(), any())).thenThrow(new java.io.IOException("network"));
assertThat(service.disconnect("admin")).isFalse();
verify(connectionRepository).delete(entity);
}
@Test
void restoresInterruptFlagWhenRevokeIsInterrupted() throws Exception {
GmailConnectionEntity entity =
connectionEntity("admin", "access", "refresh", Long.MAX_VALUE);
when(connectionRepository.findByUsername("admin")).thenReturn(Optional.of(entity));
when(httpClient.<String>send(any(), any()))
.thenThrow(new InterruptedException("interrupted"));
try {
assertThat(service.disconnect("admin")).isFalse();
assertThat(Thread.currentThread().isInterrupted()).isTrue();
} finally {
Thread.interrupted();
}
verify(connectionRepository).delete(entity);
}
@Test
void usesAccessTokenWhenRefreshTokenIsBlank() throws Exception {
GmailConnectionEntity entity = connectionEntity("admin", "access", "", Long.MAX_VALUE);
when(connectionRepository.findByUsername("admin")).thenReturn(Optional.of(entity));
when(httpClient.<String>send(any(), any())).thenReturn(response(400, ""));
assertThat(service.disconnect("admin")).isTrue();
verify(connectionRepository).delete(entity);
}
@Test
void resolvesConfiguredAndContextRedirectUris() {
ReflectionTestUtils.setField(service, "redirectUri", " https://example.com/callback ");
var request = new org.springframework.mock.web.MockHttpServletRequest();
request.setScheme("https");
request.setServerName("mail.example.com");
request.setServerPort(443);
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
assertThat(service.resolveRedirectUri(request)).isEqualTo(" https://example.com/callback ");
try {
ReflectionTestUtils.setField(service, "redirectUri", "");
assertThat(service.resolveRedirectUri(request))
.isEqualTo("https://mail.example.com/api/v1/email/gmail/callback");
} finally {
RequestContextHolder.resetRequestAttributes();
}
}
@Test
void rejectsAuthorizationWhenOAuthIsNotConfigured() {
assertThatThrownBy(
() ->
service.authorizationUrl(
"state",
new org.springframework.mock.web.MockHttpServletRequest()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("not configured");
}
private static GmailConnectionEntity connectionEntity(
String username, String accessToken, String refreshToken, long expiresAt) {
GmailConnectionEntity entity = new GmailConnectionEntity();
entity.setUsername(username);
entity.setAccessToken(accessToken);
entity.setRefreshToken(refreshToken);
entity.setExpiresAt(expiresAt);
entity.setEmail("admin@example.com");
entity.setDisplayName("Admin");
return entity;
}
private static HttpResponse<String> response(int status, String body) {
return new StubHttpResponse(status, body);
}
private record StubHttpResponse(int statusCode, String body) implements HttpResponse<String> {
@Override
public HttpRequest request() {
return null;
}
@Override
public Optional<HttpResponse<String>> previousResponse() {
return Optional.empty();
}
@Override
public HttpHeaders headers() {
return HttpHeaders.of(Map.of(), (name, value) -> true);
}
@Override
public Optional<SSLSession> sslSession() {
return Optional.empty();
}
@Override
public URI uri() {
return URI.create("https://example.com");
}
@Override
public Version version() {
return Version.HTTP_1_1;
}
}
private String buildQuery(String types, String query) {
return ReflectionTestUtils.invokeMethod(service, "buildGmailQuery", types, query);
}
}
@@ -89,6 +89,7 @@ public final class SaasSchemaOwnership {
"file_share_accesses",
"file_shares",
"folders",
"gmail_oauth_connections",
"integration_configs",
"invite_tokens",
"jwt_signing_keys",
+1 -1
View File
@@ -108,7 +108,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.14.3'
version = '2.15.0'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
@@ -1332,6 +1332,13 @@ label = "SMTP Port"
description = "Username for SMTP authentication"
label = "SMTP Username"
[admin.settings.mailbox]
description = "Configure the OAuth connection used to read mailbox attachments."
title = "Mailbox"
[admin.settings.mailbox.gmail]
note = "These values are read from settings.yml under mailbox.gmail. Changes require a server restart."
[admin.settings.mcp]
apikeyNote = "Clients send a Stirling API key in the X-API-KEY header (or Authorization: Bearer <key>). The key maps to its owning Stirling user - only provisioned accounts get in, and actions are audited as that user. Manage keys under Account > API Keys."
description = "Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint."
@@ -3693,6 +3700,59 @@ subtitle = "Import bookmarks, build hierarchies, and apply the outline without c
description = "Select the Edit Table of Contents tool to load its workspace."
title = "Open the tool to start editing"
[email]
accounts = "Accounts"
attachments = "Attachments"
back = "Back"
cacheHint = "Message metadata is cached locally."
cacheReady = "Local cache active"
cancel = "Cancel"
closeConnectDialog = "Close"
connectAccount = "Connect account"
connectDescription = "Connect your email account to securely transfer attachments into your PDF workflow."
connectFailed = "The Gmail connection could not be started. Please try again."
connectGmail = "Connect Gmail"
connectTitle = "Connect mailbox"
copySender = "Copy sender"
copySubject = "Copy subject"
customFileType = "Enter a custom file type and press Enter"
demoBody = "Attachments can be transferred directly into the Stirling PDF workspace after download."
displayName = "Display name"
displayNameHint = "This name is displayed instead of the email address in the mailbox."
displayNamePlaceholder = "e.g. Peter Example"
download = "Import"
eyebrow = "File sources"
fileTypeFilter = "Filter by file type"
folders = "Mailbox"
gmailNotAllowed = "This Google account is not allowed to connect to this mailbox. Contact an administrator if you need access."
inbox = "Inbox"
labelFilter = "Filter by labels"
loadingMore = "Loading more messages ..."
message = "Message"
messageDetails = "Message details"
messageList = "Message list"
messages = "messages"
moreActions = "More actions"
noAccount = "No account connected yet"
noAccountHint = "Connect a mailbox to import attachments."
noResults = "No messages found"
noResultsHint = "Try a different search term."
oauthNote = "Sign-in uses OAuth. Passwords are not stored by Stirling."
queued = "Queued"
refresh = "Refresh inbox"
save = "Save"
searchPlaceholder = "Search messages"
selectMessage = "Select a message"
selectMessageHint = "Choose an email from the list."
settings = "Email settings"
star = "Star"
starred = "Starred"
storageNote = "Attachments are stored in the file workflow; email data stays in the local cache."
syncLabel = "Synchronization"
syncTime = "4 minutes ago"
title = "Email inbox"
trash = "Trash"
[emptyFilesState]
upload = "Upload"
@@ -3794,6 +3854,8 @@ details = "File Details"
download = "Download"
downloadSelected = "Download Files"
dropFilesHere = "Drop files here"
email = "Email inbox"
emailShort = "Email"
fileFormat = "Format"
fileHistory = "File History"
fileName = "Name"
@@ -3919,6 +3981,7 @@ downloadFailed = "Download failed"
dropHint = "Open files to get started"
dropToAdd = "Drop files to add"
duplicateFailed = "Could not duplicate file"
email = "Email inbox"
expand = "Expand sidebar"
googleDrive = "Google Drive"
googleDriveDisabled = "Google Drive is not configured"
@@ -9830,6 +9893,7 @@ database = "Database"
endpoints = "Endpoints"
features = "Features"
folderAccess = "Folder Access"
mailbox = "Mailbox"
mcp = "MCP Server"
storageSharing = "File Storage & Sharing"
systemSettings = "System Settings"
+6
View File
@@ -455,6 +455,11 @@
"title": "Admin Features Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminMailbox": {
"image": "/og_images/home.png",
"title": "Admin Mailbox Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminPlan": {
"image": "/og_images/home.png",
"title": "Admin Plan Settings - Stirling PDF",
@@ -685,6 +690,7 @@
"/settings/adminLegal": "/settings/adminLegal",
"/settings/adminPremium": "/settings/adminPremium",
"/settings/adminFeatures": "/settings/adminFeatures",
"/settings/adminMailbox": "/settings/adminMailbox",
"/settings/adminPlan": "/settings/adminPlan",
"/settings/adminAudit": "/settings/adminAudit",
"/settings/adminUsage": "/settings/adminUsage",
@@ -457,6 +457,11 @@
"title": "Admin Features Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminMailbox": {
"image": "/og_images/home.png",
"title": "Admin Mailbox Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminPlan": {
"image": "/og_images/home.png",
"title": "Admin Plan Settings - Stirling PDF",
@@ -698,6 +703,7 @@
"/settings/adminLegal": "/settings/adminLegal",
"/settings/adminPremium": "/settings/adminPremium",
"/settings/adminFeatures": "/settings/adminFeatures",
"/settings/adminMailbox": "/settings/adminMailbox",
"/settings/adminPlan": "/settings/adminPlan",
"/settings/adminAudit": "/settings/adminAudit",
"/settings/adminUsage": "/settings/adminUsage",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling PDF",
"mainBinaryName": "Stirling-PDF",
"version": "2.14.3",
"version": "2.15.0",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
+15
View File
@@ -6,7 +6,9 @@ import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { ThemeProvider } from "@app/components/shared/ThemeProvider";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import HomePage from "@app/pages/HomePage";
import EmailInboxPage from "@app/pages/EmailInboxPage";
import Onboarding from "@app/components/onboarding/Onboarding";
import { EMAIL_MAILBOX_ENABLED } from "@app/constants/emailMailboxAvailability";
const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage"));
const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage"));
@@ -53,6 +55,19 @@ export default function App() {
}
/>
{EMAIL_MAILBOX_ENABLED && (
<Route
path="/mail"
element={
<AppProviders>
<AppLayout>
<EmailInboxPage />
</AppLayout>
</AppProviders>
}
/>
)}
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
@@ -3,7 +3,9 @@ import { Stack, Text, Group } from "@mantine/core";
import { Button } from "@app/ui/Button";
import HistoryIcon from "@mui/icons-material/History";
import PhonelinkIcon from "@mui/icons-material/Phonelink";
import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import { useGoogleDrivePicker } from "@app/hooks/useGoogleDrivePicker";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
@@ -12,6 +14,7 @@ import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import { EMAIL_MAILBOX_ENABLED } from "@app/constants/emailMailboxAvailability";
interface FileSourceButtonsProps {
horizontal?: boolean;
@@ -36,6 +39,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
const { config } = useAppConfig();
const isMobile = useIsMobile();
const navigate = useNavigate();
const isMobileUploadEnabled = config?.enableMobileScanner && !isMobile;
const handleGoogleDriveClick = async () => {
@@ -120,6 +124,22 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
</Button>
)}
{EMAIL_MAILBOX_ENABLED && (
<Button
variant="tertiary"
accent="neutral"
leftSection={<EmailOutlinedIcon />}
justify={buttonJustify}
onClick={() => navigate("/mail")}
fullWidth={!horizontal}
size={buttonSize}
>
{horizontal
? t("fileManager.emailShort", "Email")
: t("fileManager.email", "Email inbox")}
</Button>
)}
{!shouldHideMobileQR && (
<Button
variant="tertiary"
@@ -37,6 +37,7 @@ import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
import type { StirlingFileStub } from "@app/types/fileContext";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import AddIcon from "@mui/icons-material/Add";
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
@@ -75,6 +76,7 @@ import {
clearWatchedFolderDraggedFileIds,
} from "@app/components/watchedFolders/watchedFolderDragState";
import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
import { EMAIL_MAILBOX_ENABLED } from "@app/constants/emailMailboxAvailability";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import "@app/components/shared/FileSidebar.css";
@@ -1161,6 +1163,40 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
</Tooltip>
)}
{EMAIL_MAILBOX_ENABLED && (
<Tooltip
label={t("fileSidebar.email", "Email inbox")}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className="file-sidebar-action-row"
data-testid="email-button"
onClick={() => {
if (collapsed && onToggleCollapse) onToggleCollapse();
navigate("/mail");
}}
role="button"
tabIndex={0}
aria-label={t("fileSidebar.email", "Email inbox")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate("/mail");
}
}}
>
<EmailOutlinedIcon className="file-sidebar-action-icon" />
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.email", "Email inbox")}
</span>
)}
</div>
</Tooltip>
)}
{/* Watched Folders entry */}
{WATCHED_FOLDERS_ENABLED && (
<div
@@ -26,6 +26,7 @@ export const VALID_NAV_KEYS = [
"adminLegal",
"adminPremium",
"adminFeatures",
"adminMailbox",
"adminPlan",
"adminAudit",
"adminUsage",
@@ -0,0 +1,2 @@
// Email mailbox support is enabled for web-based builds.
export const EMAIL_MAILBOX_ENABLED = true;
@@ -0,0 +1,647 @@
.email-page {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
color: var(--c-text);
background: var(--c-bg);
}
.email-page-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 4.4rem;
padding: 0.65rem 1.25rem;
border-bottom: 1px solid var(--c-border-subtle);
background: var(--c-bg-raised);
}
.email-page-brand,
.email-page-header-actions,
.email-column-toolbar,
.email-detail-toolbar,
.email-detail-subject-row,
.email-sender-row,
.email-section-label,
.email-attachment-row,
.email-message-line {
display: flex;
align-items: center;
}
.email-page-brand {
gap: 0.65rem;
}
.email-page-brand h1,
.email-column-toolbar h2,
.email-detail-content h2,
.email-connect-panel h2 {
margin: 0;
color: var(--c-text);
}
.email-page-brand h1 {
font-size: 1.05rem;
font-weight: 650;
}
.email-page-eyebrow,
.email-detail-label,
.email-section-label,
.email-sync-label {
color: var(--c-text-subtle);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.email-page-header-actions {
gap: 0.4rem;
}
.email-cache-status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-right: 0.55rem;
color: var(--c-text-muted);
font-size: 0.76rem;
}
.email-status-dot,
.email-account-dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--c-success);
}
.email-page-body {
display: grid;
grid-template-columns: 16.5rem minmax(18rem, 26rem) minmax(24rem, 1fr);
flex: 1;
min-height: 0;
}
.email-sidebar,
.email-message-column,
.email-detail-column {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.email-sidebar {
display: flex;
flex-direction: column;
padding: 1rem 0.75rem;
border-right: 1px solid var(--c-border-subtle);
background: var(--c-bg-raised);
}
.email-sidebar-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.5rem 0.45rem;
color: var(--c-text-subtle);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.email-account-row,
.email-folder-row,
.email-message-row {
border: 0;
color: inherit;
cursor: pointer;
text-align: left;
}
.email-account-row {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
padding: 0.65rem 0.5rem;
border: 1px solid transparent;
border-radius: 0.4rem;
background: transparent;
}
.email-account-row:hover,
.email-account-row.is-selected {
border-color: var(--c-border);
background: var(--c-hover);
}
.email-account-avatar,
.email-message-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 2rem;
height: 2rem;
border-radius: 50%;
color: var(--c-text-on-primary);
background: var(--c-primary);
font-size: 0.75rem;
font-weight: 700;
}
.email-account-copy {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.email-account-copy strong {
overflow: hidden;
color: var(--c-text);
font-size: 0.76rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-account-copy span {
color: var(--c-text-subtle);
font-size: 0.7rem;
}
.email-folder-row {
display: flex;
align-items: center;
gap: 0.65rem;
width: 100%;
padding: 0.55rem 0.65rem;
border-radius: 0.35rem;
color: var(--c-text-muted);
background: transparent;
font-size: 0.8rem;
}
.email-folder-row span {
flex: 1;
}
.email-folder-row:hover,
.email-folder-row.is-active {
color: var(--c-text);
background: var(--c-hover);
}
.email-folder-row.is-active {
font-weight: 650;
}
.email-sidebar-footer {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: auto;
padding: 0.75rem 0.5rem 0;
border-top: 1px solid var(--c-border-subtle);
color: var(--c-text-subtle);
font-size: 0.7rem;
}
.email-sidebar-footer strong {
color: var(--c-text-muted);
font-size: 0.76rem;
}
.email-empty-account {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 1.25rem 0.65rem;
border: 1px dashed var(--c-border);
border-radius: 0.45rem;
color: var(--c-text-muted);
text-align: center;
}
.email-empty-account svg {
color: var(--c-accent-fg, var(--c-primary));
}
.email-empty-account strong {
color: var(--c-text);
font-size: 0.78rem;
}
.email-empty-account span {
font-size: 0.7rem;
}
.email-message-column {
display: flex;
flex-direction: column;
border-right: 1px solid var(--c-border-subtle);
background: var(--c-bg);
}
.email-column-toolbar {
flex-direction: column;
align-items: stretch;
justify-content: space-between;
gap: 0.75rem;
min-height: 7.25rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--c-border-subtle);
}
.email-toolbar-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-width: 0;
}
.email-column-toolbar h2 {
font-size: 0.98rem;
}
.email-column-toolbar > div:first-child {
min-width: 0;
}
.email-column-toolbar > div:first-child span {
color: var(--c-text-subtle);
font-size: 0.72rem;
}
.email-search {
width: min(15rem, 60%);
}
.email-type-filter {
width: 100%;
}
.email-message-list {
flex: 1;
min-height: 0;
height: auto;
}
.email-message-row {
display: flex;
gap: 0.65rem;
width: 100%;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--c-border-subtle);
background: transparent;
}
.email-message-row:hover,
.email-message-row.is-selected {
background: var(--c-hover);
}
.email-message-row.is-selected {
box-shadow: inset 0.18rem 0 0 var(--c-primary);
}
.email-message-avatar {
width: 2.15rem;
height: 2.15rem;
color: var(--c-accent-fg, var(--c-primary));
background: var(--c-primary-subtle);
}
.email-message-avatar.is-unread {
color: var(--c-text-on-primary);
background: var(--c-primary);
}
.email-message-copy {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
gap: 0.18rem;
}
.email-message-line {
justify-content: space-between;
gap: 0.5rem;
}
.email-message-line strong {
overflow: hidden;
color: var(--c-text);
font-size: 0.78rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-message-line time {
flex: 0 0 auto;
color: var(--c-text-subtle);
font-size: 0.68rem;
}
.email-message-subject {
overflow: hidden;
color: var(--c-text-muted);
font-size: 0.76rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-message-subject.is-unread {
color: var(--c-text);
font-weight: 700;
}
.email-message-preview {
overflow: hidden;
color: var(--c-text-subtle);
font-size: 0.72rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-message-labels,
.email-detail-labels {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.email-message-labels {
margin-top: 0.2rem;
}
.email-detail-labels {
margin-top: 0.7rem;
}
.email-label-tag {
display: inline-flex;
max-width: 100%;
align-items: center;
padding: 0.12rem 0.4rem;
border: 1px solid var(--c-border-subtle);
border-radius: 999px;
color: var(--c-text-subtle);
font-size: 0.65rem;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-attachment-indicator {
display: inline-flex;
align-items: center;
gap: 0.2rem;
color: var(--c-accent-fg, var(--c-primary));
font-size: 0.68rem;
}
.email-loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.8rem 1rem 1rem;
color: var(--c-text-subtle);
font-size: 0.72rem;
}
.email-loading-spinner {
width: 0.8rem;
height: 0.8rem;
border: 2px solid var(--c-border-subtle);
border-top-color: var(--c-primary);
border-radius: 50%;
animation: email-loading-spin 0.75s linear infinite;
}
.email-settings-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1.2rem;
}
@keyframes email-loading-spin {
to {
transform: rotate(360deg);
}
}
.email-no-results,
.email-detail-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.45rem;
height: 100%;
padding: 2rem;
color: var(--c-text-subtle);
text-align: center;
}
.email-no-results svg,
.email-detail-empty svg {
width: 2rem;
height: 2rem;
color: var(--c-accent-fg, var(--c-primary));
}
.email-no-results strong,
.email-detail-empty strong {
color: var(--c-text);
font-size: 0.85rem;
}
.email-no-results span,
.email-detail-empty span {
font-size: 0.75rem;
}
.email-detail-column {
background: var(--c-surface);
}
.email-detail-toolbar {
justify-content: space-between;
min-height: 3.2rem;
padding: 0.5rem 1.25rem;
border-bottom: 1px solid var(--c-border-subtle);
}
.email-detail-scroll {
height: calc(100% - 3.2rem);
}
.email-detail-content {
max-width: 52rem;
padding: 2rem clamp(1.25rem, 4vw, 3.25rem);
}
.email-detail-subject-row {
justify-content: space-between;
gap: 1rem;
}
.email-detail-content h2 {
font-size: clamp(1.1rem, 1.5vw, 1.45rem);
line-height: 1.3;
}
.email-sender-row {
gap: 0.65rem;
margin-top: 1.25rem;
}
.email-message-avatar.is-large {
width: 2.5rem;
height: 2.5rem;
}
.email-sender-row > div {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.email-sender-row strong {
color: var(--c-text);
font-size: 0.8rem;
}
.email-sender-row span {
color: var(--c-text-subtle);
font-size: 0.72rem;
}
.email-sender-row time {
color: var(--c-text-subtle);
font-size: 0.7rem;
}
.email-message-body-copy {
max-width: 44rem;
margin: 1.5rem 0 0;
color: var(--c-text-muted);
font-size: 0.86rem;
line-height: 1.65;
}
.email-attachments {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--c-border-subtle);
}
.email-section-label {
justify-content: space-between;
margin-bottom: 0.65rem;
}
.email-attachment-row {
gap: 0.65rem;
padding: 0.65rem 0;
}
.email-file-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.45rem;
height: 2.45rem;
border-radius: 0.3rem;
color: var(--c-danger);
background: var(--c-danger-subtle);
font-size: 0.55rem;
font-weight: 800;
}
.email-attachment-copy {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.email-attachment-copy strong {
overflow: hidden;
color: var(--c-text);
font-size: 0.78rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-attachment-copy span {
color: var(--c-text-subtle);
font-size: 0.7rem;
}
.email-attachment-note {
display: flex;
align-items: center;
gap: 0.3rem;
margin: 0.75rem 0 0;
color: var(--c-text-subtle);
font-size: 0.7rem;
}
.email-connect-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--c-bg) 88%, transparent);
}
.email-connect-panel {
position: relative;
width: min(30rem, 100%);
padding: 2.25rem 2.25rem 1.75rem;
border: 1px solid var(--c-border);
border-radius: 0.5rem;
background: var(--c-surface);
box-shadow: 0 1.25rem 3rem rgb(0 0 0 / 16%);
text-align: center;
}
.email-connect-close {
position: absolute;
top: 0.65rem;
right: 0.65rem;
}
.email-connect-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: auto;
height: auto;
margin-bottom: 1.15rem;
color: var(--c-text-muted);
font-size: 2.25rem;
}
.email-connect-panel h2 {
font-size: 1.45rem;
}
.email-connect-panel p {
max-width: 24rem;
margin: 0.75rem auto 1.5rem;
color: var(--c-text-muted);
font-size: 0.82rem;
line-height: 1.55;
}
.email-connect-error {
margin: 0 0 1rem;
padding: 0.7rem 0.8rem;
border: 1px solid color-mix(in srgb, var(--c-danger) 55%, var(--c-border));
border-radius: 0.35rem;
background: color-mix(in srgb, var(--c-danger) 12%, var(--c-surface));
color: var(--c-danger);
font-size: 0.78rem;
line-height: 1.45;
text-align: left;
}
.email-provider-actions {
display: grid;
gap: 0.55rem;
margin-top: 0.25rem;
}
.email-connect-panel small {
display: block;
margin-top: 1rem;
color: var(--c-text-subtle);
font-size: 0.68rem;
line-height: 1.45;
}
@media (max-width: 980px) {
.email-page-body {
grid-template-columns: 13.5rem minmax(17rem, 1fr);
}
.email-detail-column {
display: none;
}
}
@media (max-width: 680px) {
.email-page-header {
padding: 0.6rem 0.75rem;
}
.email-cache-status {
display: none;
}
.email-page-body {
display: block;
overflow: auto;
}
.email-sidebar {
display: block;
height: auto;
padding: 0.65rem;
border-right: 0;
border-bottom: 1px solid var(--c-border-subtle);
}
.email-sidebar .email-sidebar-heading:nth-of-type(2),
.email-sidebar .email-folder-row,
.email-sidebar .email-sidebar-footer,
.email-sidebar > .mantine-Divider-root {
display: none;
}
.email-account-row {
max-width: 100%;
}
.email-message-column {
min-height: 30rem;
border-right: 0;
}
.email-column-toolbar {
min-height: 7rem;
}
.email-toolbar-top {
gap: 0.5rem;
}
.email-search {
width: 48%;
}
.email-message-list {
height: calc(100% - 7rem);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
import {
indexedDBManager,
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
import type {
EmailAccountRecord,
EmailAttachmentRecord,
EmailMessageRecord,
} from "@app/types/email";
class EmailStorageService {
private readonly config = DATABASE_CONFIGS.EMAIL;
private async getDatabase(): Promise<IDBDatabase> {
return indexedDBManager.openDatabase(this.config);
}
async getAccounts(): Promise<EmailAccountRecord[]> {
const db = await this.getDatabase();
return this.getAll<EmailAccountRecord>(db, "accounts");
}
async getMessages(accountId: string): Promise<EmailMessageRecord[]> {
const db = await this.getDatabase();
return this.getByIndex<EmailMessageRecord>(
db,
"messages",
"accountId",
accountId,
);
}
async getAttachments(messageId: string): Promise<EmailAttachmentRecord[]> {
const db = await this.getDatabase();
return this.getByIndex<EmailAttachmentRecord>(
db,
"attachments",
"messageId",
messageId,
);
}
async upsertAccount(account: EmailAccountRecord): Promise<void> {
const db = await this.getDatabase();
await this.put(db, "accounts", account);
}
async upsertMessages(
messages: EmailMessageRecord[],
attachments: EmailAttachmentRecord[] = [],
): Promise<void> {
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction(
["messages", "attachments"],
"readwrite",
);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
for (const message of messages)
transaction.objectStore("messages").put(message);
for (const attachment of attachments) {
transaction.objectStore("attachments").put(attachment);
}
});
}
async clearAccount(accountId: string): Promise<void> {
const db = await this.getDatabase();
const stores = ["accounts", "messages", "attachments"];
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction(stores, "readwrite");
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.objectStore("accounts").delete(accountId);
for (const storeName of ["messages", "attachments"] as const) {
const store = transaction.objectStore(storeName);
const request = store
.index("accountId")
.openCursor(IDBKeyRange.only(accountId));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) return;
cursor.delete();
cursor.continue();
};
}
});
}
private getAll<T>(db: IDBDatabase, storeName: string): Promise<T[]> {
return new Promise((resolve, reject) => {
const request = db
.transaction(storeName, "readonly")
.objectStore(storeName)
.getAll();
request.onsuccess = () => resolve((request.result as T[]) ?? []);
request.onerror = () => reject(request.error);
});
}
private getByIndex<T>(
db: IDBDatabase,
storeName: string,
indexName: string,
value: IDBValidKey,
): Promise<T[]> {
return new Promise((resolve, reject) => {
const request = db
.transaction(storeName, "readonly")
.objectStore(storeName)
.index(indexName)
.getAll(IDBKeyRange.only(value));
request.onsuccess = () => resolve((request.result as T[]) ?? []);
request.onerror = () => reject(request.error);
});
}
private put<T extends object>(
db: IDBDatabase,
storeName: string,
value: T,
): Promise<void> {
return new Promise((resolve, reject) => {
const request = db
.transaction(storeName, "readwrite")
.objectStore(storeName)
.put(value);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
export const emailStorage = new EmailStorageService();
@@ -516,6 +516,38 @@ export const DATABASE_CONFIGS = {
},
],
} as DatabaseConfig,
EMAIL: {
name: "stirling-email-cache",
version: 1,
stores: [
{
name: "accounts",
keyPath: "id",
indexes: [
{ name: "email", keyPath: "email", unique: false },
{ name: "provider", keyPath: "provider", unique: false },
],
},
{
name: "messages",
keyPath: "id",
indexes: [
{ name: "accountId", keyPath: "accountId", unique: false },
{ name: "date", keyPath: "date", unique: false },
],
},
{
name: "attachments",
keyPath: "id",
indexes: [
{ name: "accountId", keyPath: "accountId", unique: false },
{ name: "messageId", keyPath: "messageId", unique: false },
{ name: "expiresAt", keyPath: "expiresAt", unique: false },
],
},
],
} as DatabaseConfig,
} as const;
export const indexedDBManager = IndexedDBManager.getInstance();
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: "2.14.3",
appVersion: "2.15.0",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
+34
View File
@@ -0,0 +1,34 @@
export type EmailProvider = "Microsoft 365" | "Gmail";
export interface EmailAccountRecord {
id: string;
email: string;
provider: EmailProvider;
displayName?: string;
connectedAt: string;
lastSyncedAt?: string;
}
export interface EmailAttachmentRecord {
id: string;
accountId: string;
messageId: string;
name: string;
type: string;
size: string;
cachedAt?: string;
expiresAt?: string;
}
export interface EmailMessageRecord {
id: string;
accountId: string;
sender: string;
address: string;
subject: string;
preview: string;
date: string;
unread?: boolean;
hasAttachment?: boolean;
syncedAt: string;
}
@@ -0,0 +1,2 @@
// Email mailbox support is intentionally disabled for the Tauri desktop build.
export const EMAIL_MAILBOX_ENABLED = false;
+18
View File
@@ -13,12 +13,15 @@ import ShareLinkPage from "@app/routes/ShareLinkPage";
import ParticipantView from "@app/components/workflow/ParticipantView";
import Onboarding from "@app/components/onboarding/Onboarding";
import WatchedFoldersRegistration from "@app/components/watchedFolders/WatchedFoldersRegistration";
import EmailInboxPage from "@app/pages/EmailInboxPage";
const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage"));
const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage"));
import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions";
import { RootGate } from "@app/routes/RootGate";
import { RequireAuth } from "@app/auth/guards/RequireAuth";
import { EMAIL_MAILBOX_ENABLED } from "@app/constants/emailMailboxAvailability";
// Import global styles
import "@app/styles/tailwind.css";
@@ -104,6 +107,21 @@ export default function App() {
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Route path="/share/:token" element={<ShareLinkPage />} />
{EMAIL_MAILBOX_ENABLED && (
<Route
path="/mail"
element={
<RequireAuth
loading={<LoadingFallback />}
fallback={
<Navigate to="/login?from=%2Fmail" replace />
}
>
<EmailInboxPage />
</RequireAuth>
}
/>
)}
{/* The editor and its tool routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
@@ -17,6 +17,7 @@ import AdminPlanSection from "@app/components/shared/config/configSections/Admin
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
import AdminMcpSection from "@app/components/shared/config/configSections/AdminMcpSection";
import AdminMailboxSection from "@app/components/shared/config/configSections/AdminMailboxSection";
import AdminAiGeneralSection from "@app/components/shared/config/configSections/AdminAiGeneralSection";
import AdminAiModelsSection from "@app/components/shared/config/configSections/AdminAiModelsSection";
import AdminAiDocumentsSection from "@app/components/shared/config/configSections/AdminAiDocumentsSection";
@@ -123,6 +124,14 @@ export const useConfigNavSections = (
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminMailbox",
label: t("settings.configuration.mailbox", "Mailbox"),
icon: "mail-rounded",
component: <AdminMailboxSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminStorageSharing",
label: t(
@@ -0,0 +1,205 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Stack, Paper, Text, TextInput, Loader, Group } from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import EditableSecretField from "@app/components/shared/EditableSecretField";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
interface GmailMailboxSettings {
clientId?: string;
clientSecret?: string;
redirectUri?: string;
allowedEmails?: string[];
}
interface MailboxSettingsData {
gmail?: GmailMailboxSettings;
}
export default function AdminMailboxSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<MailboxSettingsData>({
sectionName: "mailbox",
saveTransformer: (current) => ({
sectionData: {},
deltaSettings: {
"mailbox.gmail.clientId": current.gmail?.clientId ?? "",
"mailbox.gmail.clientSecret": current.gmail?.clientSecret ?? "",
"mailbox.gmail.redirectUri": current.gmail?.redirectUri ?? "",
"mailbox.gmail.allowedEmails": current.gmail?.allowedEmails ?? [],
},
}),
});
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
await saveSettings();
markSaved();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const gmail = settings.gmail ?? {};
const updateGmail = (patch: Partial<GmailMailboxSettings>) =>
setSettings({ ...settings, gmail: { ...gmail, ...patch } });
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">
{t("admin.settings.mailbox.title", "Mailbox")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.mailbox.description",
"Configure the OAuth connection used to read mailbox attachments.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={500} size="sm">
Gmail OAuth
</Text>
<Text size="xs" c="dimmed">
{t(
"admin.settings.mailbox.gmail.note",
"These values are read from settings.yml under mailbox.gmail. Changes require a server restart.",
)}
</Text>
<TextInput
label={
<Group gap="xs">
<span>Client ID</span>
<PendingBadge show={isFieldPending("gmail.clientId")} />
</Group>
}
value={gmail.clientId || ""}
onChange={(event) =>
updateGmail({ clientId: event.currentTarget.value })
}
disabled={!loginEnabled}
/>
<div>
<Group gap="xs" align="center" mb={4}>
<span style={{ fontWeight: 500, fontSize: "0.875rem" }}>
Client Secret
</span>
<PendingBadge show={isFieldPending("gmail.clientSecret")} />
</Group>
<EditableSecretField
value={gmail.clientSecret || ""}
onChange={(value) => updateGmail({ clientSecret: value })}
placeholder="Google OAuth client secret"
disabled={!loginEnabled}
/>
</div>
<TextInput
label={
<Group gap="xs">
<span>Redirect URI</span>
<PendingBadge show={isFieldPending("gmail.redirectUri")} />
</Group>
}
description="Optional fixed public callback URI"
placeholder="https://example.com/api/v1/email/gmail/callback"
value={gmail.redirectUri || ""}
onChange={(event) =>
updateGmail({ redirectUri: event.currentTarget.value })
}
disabled={!loginEnabled}
/>
<TextInput
label={
<Group gap="xs">
<span>Allowed Google account emails</span>
<PendingBadge show={isFieldPending("gmail.allowedEmails")} />
</Group>
}
description="Leave empty to allow all Google accounts. Separate addresses with commas."
placeholder="user@example.com, admin@example.com"
value={(gmail.allowedEmails ?? []).join(", ")}
onChange={(event) =>
updateGmail({
allowedEmails: event.currentTarget.value
.split(",")
.map((value) => value.trim())
.filter(Boolean),
})
}
disabled={!loginEnabled}
/>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: "2.14.3",
appVersion: "2.15.0",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
enableDesktopInstallSlide: true,