mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
24
Commits
format_java
...
mailbox
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03e5d43fc | ||
|
|
6a6be7057a | ||
|
|
ac60091c2b | ||
|
|
d470ba5a63 | ||
|
|
c3d5d4b4b7 | ||
|
|
ef47c56035 | ||
|
|
55d4902362 | ||
|
|
ad9af94fd8 | ||
|
|
088f33f017 | ||
|
|
af1c212459 | ||
|
|
48246eb577 | ||
|
|
e4a0043bfc | ||
|
|
adcee1be72 | ||
|
|
c57e859aa9 | ||
|
|
2953358dd1 | ||
|
|
fc3a8e3232 | ||
|
|
8ae001edfd | ||
|
|
63b0eb10e6 | ||
|
|
7181063bae | ||
|
|
6e3bc91f7f | ||
|
|
f05a695c9b | ||
|
|
17ab63c579 | ||
|
|
b2a54423ef | ||
|
|
54e7a98ab7 |
@@ -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,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
|
||||
|
||||
+48
@@ -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;
|
||||
}
|
||||
+10
@@ -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);
|
||||
}
|
||||
+234
@@ -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);
|
||||
}
|
||||
}
|
||||
+464
@@ -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) {}
|
||||
}
|
||||
+4
-2
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+211
@@ -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);
|
||||
}
|
||||
}
|
||||
+588
@@ -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
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
+205
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user