mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Add Gmail mailbox OAuth flow
Adds a proprietary Gmail OAuth callback and config entries for mailbox credentials. This also introduces an email inbox UI with /mail routing, sidebar/file-source entry points, and an IndexedDB-backed email cache for connected accounts, messages, and attachments.
This commit is contained in:
@@ -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,14 @@ 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
|
||||
|
||||
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
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
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.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
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;
|
||||
|
||||
@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 TOKEN_SESSION_KEY = "stirling.gmail.oauth.token";
|
||||
static final String PROFILE_SESSION_KEY = "stirling.gmail.oauth.profile";
|
||||
|
||||
private final GmailOAuthService gmailOAuthService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
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);
|
||||
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);
|
||||
return ResponseEntity.ok(
|
||||
profile == null
|
||||
? Map.of("connected", false)
|
||||
: Map.of("connected", true, "email", profile.email(), "provider", "Gmail"));
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
GmailOAuthService.GmailToken token = gmailOAuthService.exchangeCode(code, redirectUri);
|
||||
GmailOAuthService.GmailProfile profile = gmailOAuthService.getProfile(token);
|
||||
session.removeAttribute(STATE_SESSION_KEY);
|
||||
session.removeAttribute(REDIRECT_URI_SESSION_KEY);
|
||||
session.setAttribute(TOKEN_SESSION_KEY, token);
|
||||
session.setAttribute(PROFILE_SESSION_KEY, profile);
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
String target =
|
||||
frontendUrl == null || frontendUrl.isBlank()
|
||||
? "/editor/mail?gmail=connected"
|
||||
: frontendUrl.trim().replaceAll("/$", "") + "/editor/mail?gmail=connected";
|
||||
response.sendRedirect(target);
|
||||
}
|
||||
|
||||
private String randomState() {
|
||||
byte[] bytes = new byte[32];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
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.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
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";
|
||||
static final String READONLY_SCOPE =
|
||||
"openid email https://www.googleapis.com/auth/gmail.readonly";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final 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 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.isBlank() || 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 {}
|
||||
}
|
||||
@@ -3775,7 +3775,48 @@ closeFile = "Close file"
|
||||
[fileEditor]
|
||||
addFiles = "Add Files"
|
||||
|
||||
[email]
|
||||
back = "Back"
|
||||
eyebrow = "File sources"
|
||||
title = "Email inbox"
|
||||
cacheReady = "Local cache active"
|
||||
refresh = "Refresh inbox"
|
||||
settings = "Email settings"
|
||||
accounts = "Accounts"
|
||||
connectAccount = "Connect account"
|
||||
noAccount = "No account connected yet"
|
||||
noAccountHint = "Connect a mailbox to import attachments."
|
||||
folders = "Mailbox"
|
||||
inbox = "Inbox"
|
||||
starred = "Starred"
|
||||
trash = "Trash"
|
||||
syncLabel = "Synchronization"
|
||||
syncTime = "4 minutes ago"
|
||||
cacheHint = "Message metadata is cached locally."
|
||||
messageList = "Message list"
|
||||
messages = "messages"
|
||||
searchPlaceholder = "Search messages"
|
||||
noResults = "No messages found"
|
||||
noResultsHint = "Try a different search term."
|
||||
messageDetails = "Message details"
|
||||
message = "Message"
|
||||
moreActions = "More actions"
|
||||
star = "Star"
|
||||
demoBody = "Attachments can be transferred directly into the Stirling PDF workspace after download."
|
||||
attachments = "Attachments"
|
||||
queued = "Queued"
|
||||
download = "Import"
|
||||
storageNote = "Attachments are stored in the file workflow; email data stays in the local cache."
|
||||
selectMessage = "Select a message"
|
||||
selectMessageHint = "Choose an email from the list."
|
||||
firstSetup = "First step"
|
||||
connectTitle = "Connect mailbox"
|
||||
connectDescription = "Connect your email account to securely transfer attachments into your PDF workflow."
|
||||
oauthNote = "Sign-in uses OAuth. Passwords are not stored by Stirling."
|
||||
|
||||
[fileManager]
|
||||
email = "Email inbox"
|
||||
emailShort = "Email"
|
||||
active = "Active"
|
||||
addToUpload = "Add to Upload"
|
||||
changesNotUploaded = "Changes not uploaded"
|
||||
@@ -3909,6 +3950,7 @@ upload = "Upload"
|
||||
workbench = "Workbench"
|
||||
|
||||
[fileSidebar]
|
||||
email = "Email inbox"
|
||||
addFiles = "Add files"
|
||||
addingFiles = "Adding files…"
|
||||
collapse = "Collapse sidebar"
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
|
||||
const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage"));
|
||||
@@ -53,6 +54,17 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
<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";
|
||||
@@ -36,6 +38,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 +123,20 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<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";
|
||||
@@ -1161,6 +1162,38 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
label={t("fileSidebar.email", "E-Mail-Postfach")}
|
||||
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", "E-Mail-Postfach")}
|
||||
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", "E-Mail-Postfach")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Watched Folders entry */}
|
||||
{WATCHED_FOLDERS_ENABLED && (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
.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 {
|
||||
border-right: 1px solid var(--c-border-subtle);
|
||||
background: var(--c-bg);
|
||||
}
|
||||
.email-column-toolbar {
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
min-height: 5.15rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
}
|
||||
.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, 52%);
|
||||
}
|
||||
.email-message-list {
|
||||
height: calc(100% - 5.15rem);
|
||||
}
|
||||
.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-attachment-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
color: var(--c-accent-fg, var(--c-primary));
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.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 {
|
||||
width: min(26rem, 100%);
|
||||
padding: 2rem;
|
||||
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-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin-bottom: 0.85rem;
|
||||
border-radius: 0.75rem;
|
||||
color: var(--c-text-on-primary);
|
||||
background: var(--c-primary);
|
||||
}
|
||||
.email-connect-panel h2 {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
.email-connect-panel p {
|
||||
margin: 0.7rem 0 1.25rem;
|
||||
color: var(--c-text-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.email-provider-actions {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.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: 4.75rem;
|
||||
}
|
||||
.email-search {
|
||||
width: 48%;
|
||||
}
|
||||
.email-message-list {
|
||||
height: calc(100% - 4.75rem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge, Divider, ScrollArea, TextInput, Tooltip } from "@mantine/core";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import AttachFileIcon from "@mui/icons-material/AttachFile";
|
||||
import CloudDownloadOutlinedIcon from "@mui/icons-material/CloudDownloadOutlined";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
|
||||
import InboxOutlinedIcon from "@mui/icons-material/InboxOutlined";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||
import StarBorderIcon from "@mui/icons-material/StarBorder";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import "@app/pages/EmailInboxPage.css";
|
||||
|
||||
type Provider = "Microsoft 365" | "Gmail";
|
||||
|
||||
interface MailAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
interface MailMessage {
|
||||
id: string;
|
||||
sender: string;
|
||||
address: string;
|
||||
subject: string;
|
||||
preview: string;
|
||||
date: string;
|
||||
unread?: boolean;
|
||||
hasAttachment?: boolean;
|
||||
attachments: MailAttachment[];
|
||||
}
|
||||
|
||||
const DEMO_MESSAGES: MailMessage[] = [
|
||||
{
|
||||
id: "invoice-01",
|
||||
sender: "Nordlicht GmbH",
|
||||
address: "buchhaltung@nordlicht.example",
|
||||
subject: "Rechnung 2026-0814",
|
||||
preview:
|
||||
"Anbei finden Sie die Rechnung für den aktuellen Abrechnungszeitraum.",
|
||||
date: "Heute, 09:42",
|
||||
unread: true,
|
||||
hasAttachment: true,
|
||||
attachments: [
|
||||
{
|
||||
id: "invoice-pdf",
|
||||
name: "Rechnung_2026-0814.pdf",
|
||||
type: "PDF",
|
||||
size: "248 KB",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "contract-02",
|
||||
sender: "Mara Hoffmann",
|
||||
address: "mara.hoffmann@example.com",
|
||||
subject: "Vertragsunterlagen zur Freigabe",
|
||||
preview:
|
||||
"Die aktualisierten Unterlagen liegen im Anhang. Bitte um kurze Rückmeldung.",
|
||||
date: "Gestern",
|
||||
hasAttachment: true,
|
||||
attachments: [
|
||||
{
|
||||
id: "contract-pdf",
|
||||
name: "Vertragsunterlagen.pdf",
|
||||
type: "PDF",
|
||||
size: "1,8 MB",
|
||||
},
|
||||
{ id: "terms-docx", name: "Anlage_A.docx", type: "DOCX", size: "74 KB" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "meeting-03",
|
||||
sender: "Projektteam",
|
||||
address: "projektteam@example.com",
|
||||
subject: "Nächste Schritte",
|
||||
preview:
|
||||
"Danke für das Gespräch. Die nächsten Schritte sind im Überblick zusammengefasst.",
|
||||
date: "12. Aug.",
|
||||
attachments: [],
|
||||
},
|
||||
];
|
||||
|
||||
const DEMO_ACCOUNT = {
|
||||
email: "anna.beispiel@unternehmen.de",
|
||||
provider: "Microsoft 365" as Provider,
|
||||
};
|
||||
|
||||
export default function EmailInboxPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [accountConnected, setAccountConnected] = useState(false);
|
||||
const [accountEmail, setAccountEmail] = useState(DEMO_ACCOUNT.email);
|
||||
const [selectedAccount, setSelectedAccount] = useState("work");
|
||||
const [selectedMessageId, setSelectedMessageId] = useState(
|
||||
DEMO_MESSAGES[0].id,
|
||||
);
|
||||
const [query, setQuery] = useState("");
|
||||
const [downloadedAttachment, setDownloadedAttachment] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
apiClient
|
||||
.get<{ connected: boolean; email?: string }>("/api/v1/email/gmail/status")
|
||||
.then(({ data }) => {
|
||||
if (!active) return;
|
||||
setAccountConnected(data.connected);
|
||||
if (data.email) setAccountEmail(data.email);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setAccountConnected(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const messages = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
if (!normalizedQuery) return DEMO_MESSAGES;
|
||||
return DEMO_MESSAGES.filter((message) =>
|
||||
[message.sender, message.address, message.subject, message.preview]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase()
|
||||
.includes(normalizedQuery),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
const selectedMessage =
|
||||
messages.find((message) => message.id === selectedMessageId) ?? messages[0];
|
||||
|
||||
const connectAccount = async (provider: Provider) => {
|
||||
if (provider === "Gmail") {
|
||||
const { data } = await apiClient.get<{ authorizationUrl: string }>(
|
||||
"/api/v1/email/gmail/connect",
|
||||
);
|
||||
window.location.assign(data.authorizationUrl);
|
||||
return;
|
||||
}
|
||||
setAccountConnected(true);
|
||||
setSelectedAccount("work");
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="email-page">
|
||||
<header className="email-page-header">
|
||||
<div className="email-page-brand">
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
aria-label={t("email.back", "Zurück")}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<div className="email-page-eyebrow">
|
||||
{t("email.eyebrow", "Dateiquellen")}
|
||||
</div>
|
||||
<h1>{t("email.title", "E-Mail-Postfach")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="email-page-header-actions">
|
||||
<span className="email-cache-status">
|
||||
<span className="email-status-dot" />
|
||||
{t("email.cacheReady", "Lokaler Cache aktiv")}
|
||||
</span>
|
||||
<Tooltip label={t("email.refresh", "Postfach aktualisieren")}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
aria-label={t("email.refresh", "Postfach aktualisieren")}
|
||||
>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("email.settings", "E-Mail-Einstellungen")}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
aria-label={t("email.settings", "E-Mail-Einstellungen")}
|
||||
>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="email-page-body">
|
||||
<aside className="email-sidebar">
|
||||
<div className="email-sidebar-heading">
|
||||
<span>{t("email.accounts", "Konten")}</span>
|
||||
<Tooltip label={t("email.connectAccount", "Konto verbinden")}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
aria-label={t("email.connectAccount", "Konto verbinden")}
|
||||
onClick={() => setAccountConnected(true)}
|
||||
>
|
||||
<LinkIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{accountConnected ? (
|
||||
<button
|
||||
className={`email-account-row ${selectedAccount === "work" ? "is-selected" : ""}`}
|
||||
onClick={() => setSelectedAccount("work")}
|
||||
>
|
||||
<span className="email-account-avatar">A</span>
|
||||
<span className="email-account-copy">
|
||||
<strong>{accountEmail}</strong>
|
||||
<span>{DEMO_ACCOUNT.provider}</span>
|
||||
</span>
|
||||
<span className="email-account-dot" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="email-empty-account">
|
||||
<EmailOutlinedIcon />
|
||||
<strong>
|
||||
{t("email.noAccount", "Noch kein Konto verbunden")}
|
||||
</strong>
|
||||
<span>
|
||||
{t(
|
||||
"email.noAccountHint",
|
||||
"Verbinde ein Postfach, um Anhänge zu importieren.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider my="md" />
|
||||
<div className="email-sidebar-heading">
|
||||
<span>{t("email.folders", "Postfach")}</span>
|
||||
</div>
|
||||
<button className="email-folder-row is-active">
|
||||
<InboxOutlinedIcon fontSize="small" />
|
||||
<span>{t("email.inbox", "Posteingang")}</span>
|
||||
<Badge size="sm" variant="light">
|
||||
2
|
||||
</Badge>
|
||||
</button>
|
||||
<button className="email-folder-row">
|
||||
<StarBorderIcon fontSize="small" />
|
||||
<span>{t("email.starred", "Markiert")}</span>
|
||||
</button>
|
||||
<button className="email-folder-row">
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
<span>{t("email.trash", "Papierkorb")}</span>
|
||||
</button>
|
||||
|
||||
<div className="email-sidebar-footer">
|
||||
<span className="email-sync-label">
|
||||
{t("email.syncLabel", "Synchronisierung")}
|
||||
</span>
|
||||
<strong>{t("email.syncTime", "Vor 4 Minuten")}</strong>
|
||||
<span>
|
||||
{t(
|
||||
"email.cacheHint",
|
||||
"Metadaten werden lokal zwischengespeichert.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section
|
||||
className="email-message-column"
|
||||
aria-label={t("email.messageList", "E-Mail-Liste")}
|
||||
>
|
||||
<div className="email-column-toolbar">
|
||||
<div>
|
||||
<h2>{t("email.inbox", "Posteingang")}</h2>
|
||||
<span>
|
||||
{messages.length} {t("email.messages", "Nachrichten")}
|
||||
</span>
|
||||
</div>
|
||||
<TextInput
|
||||
className="email-search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder={t(
|
||||
"email.searchPlaceholder",
|
||||
"Nachrichten durchsuchen",
|
||||
)}
|
||||
leftSection={<SearchIcon fontSize="small" />}
|
||||
aria-label={t(
|
||||
"email.searchPlaceholder",
|
||||
"Nachrichten durchsuchen",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="email-message-list">
|
||||
{messages.length > 0 ? (
|
||||
messages.map((message) => (
|
||||
<button
|
||||
className={`email-message-row ${selectedMessage?.id === message.id ? "is-selected" : ""}`}
|
||||
key={message.id}
|
||||
onClick={() => {
|
||||
setSelectedMessageId(message.id);
|
||||
setDownloadedAttachment(null);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`email-message-avatar ${message.unread ? "is-unread" : ""}`}
|
||||
>
|
||||
{message.sender.charAt(0)}
|
||||
</span>
|
||||
<span className="email-message-copy">
|
||||
<span className="email-message-line">
|
||||
<strong>{message.sender}</strong>
|
||||
<time>{message.date}</time>
|
||||
</span>
|
||||
<span
|
||||
className={`email-message-subject ${message.unread ? "is-unread" : ""}`}
|
||||
>
|
||||
{message.subject}
|
||||
</span>
|
||||
<span className="email-message-preview">
|
||||
{message.preview}
|
||||
</span>
|
||||
{message.hasAttachment && (
|
||||
<span className="email-attachment-indicator">
|
||||
<AttachFileIcon fontSize="inherit" />{" "}
|
||||
{message.attachments.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="email-no-results">
|
||||
<SearchIcon />
|
||||
<strong>
|
||||
{t("email.noResults", "Keine Nachrichten gefunden")}
|
||||
</strong>
|
||||
<span>
|
||||
{t("email.noResultsHint", "Passe deinen Suchbegriff an.")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="email-detail-column"
|
||||
aria-label={t("email.messageDetails", "Nachrichtendetails")}
|
||||
>
|
||||
{selectedMessage ? (
|
||||
<>
|
||||
<div className="email-detail-toolbar">
|
||||
<span className="email-detail-label">
|
||||
{t("email.message", "Nachricht")}
|
||||
</span>
|
||||
<Tooltip label={t("email.moreActions", "Weitere Aktionen")}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
aria-label={t("email.moreActions", "Weitere Aktionen")}
|
||||
>
|
||||
<MoreHorizIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ScrollArea className="email-detail-scroll">
|
||||
<div className="email-detail-content">
|
||||
<div className="email-detail-subject-row">
|
||||
<h2>{selectedMessage.subject}</h2>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
aria-label={t("email.star", "Markieren")}
|
||||
>
|
||||
<StarBorderIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className="email-sender-row">
|
||||
<span className="email-message-avatar is-large">
|
||||
{selectedMessage.sender.charAt(0)}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{selectedMessage.sender}</strong>
|
||||
<span>{selectedMessage.address}</span>
|
||||
</div>
|
||||
<time>{selectedMessage.date}</time>
|
||||
</div>
|
||||
<p className="email-message-body-copy">
|
||||
{selectedMessage.preview}
|
||||
</p>
|
||||
<p className="email-message-body-copy">
|
||||
{t(
|
||||
"email.demoBody",
|
||||
"Die angehängten Dateien können nach dem Download direkt in den Stirling-PDF-Arbeitsbereich übernommen werden.",
|
||||
)}
|
||||
</p>
|
||||
|
||||
{selectedMessage.attachments.length > 0 && (
|
||||
<div className="email-attachments">
|
||||
<div className="email-section-label">
|
||||
<span>{t("email.attachments", "Anhänge")}</span>
|
||||
<span>{selectedMessage.attachments.length}</span>
|
||||
</div>
|
||||
{selectedMessage.attachments.map((attachment) => (
|
||||
<div
|
||||
className="email-attachment-row"
|
||||
key={attachment.id}
|
||||
>
|
||||
<span className="email-file-icon">
|
||||
{attachment.type}
|
||||
</span>
|
||||
<span className="email-attachment-copy">
|
||||
<strong>{attachment.name}</strong>
|
||||
<span>{attachment.size}</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<CloudDownloadOutlinedIcon fontSize="small" />
|
||||
}
|
||||
onClick={() =>
|
||||
setDownloadedAttachment(attachment.id)
|
||||
}
|
||||
>
|
||||
{downloadedAttachment === attachment.id
|
||||
? t("email.queued", "Vorgemerkt")
|
||||
: t("email.download", "Importieren")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<p className="email-attachment-note">
|
||||
<EmailOutlinedIcon fontSize="inherit" />{" "}
|
||||
{t(
|
||||
"email.storageNote",
|
||||
"Anhänge werden im Datei-Workflow gespeichert, E-Mail-Daten bleiben im lokalen Cache.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className="email-detail-empty">
|
||||
<EmailOutlinedIcon />
|
||||
<strong>{t("email.selectMessage", "Nachricht auswählen")}</strong>
|
||||
<span>
|
||||
{t(
|
||||
"email.selectMessageHint",
|
||||
"Wähle eine E-Mail aus der Liste aus.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{!accountConnected && (
|
||||
<div className="email-connect-overlay">
|
||||
<div className="email-connect-panel">
|
||||
<span className="email-connect-icon">
|
||||
<LinkIcon />
|
||||
</span>
|
||||
<span className="email-page-eyebrow">
|
||||
{t("email.firstSetup", "Erster Schritt")}
|
||||
</span>
|
||||
<h2>{t("email.connectTitle", "Postfach verbinden")}</h2>
|
||||
<p>
|
||||
{t(
|
||||
"email.connectDescription",
|
||||
"Verbinde dein E-Mail-Konto, um Anhänge sicher in deinen PDF-Workflow zu übernehmen.",
|
||||
)}
|
||||
</p>
|
||||
<div className="email-provider-actions">
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => connectAccount("Microsoft 365")}
|
||||
leftSection={<EmailOutlinedIcon fontSize="small" />}
|
||||
>
|
||||
Microsoft 365 verbinden
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="secondary"
|
||||
onClick={() => connectAccount("Gmail")}
|
||||
leftSection={<EmailOutlinedIcon fontSize="small" />}
|
||||
>
|
||||
Gmail verbinden
|
||||
</Button>
|
||||
</div>
|
||||
<small>
|
||||
{t(
|
||||
"email.oauthNote",
|
||||
"Die Anmeldung erfolgt über OAuth. Passwörter werden nicht in Stirling gespeichert.",
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ 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"));
|
||||
@@ -104,6 +105,7 @@ export default function App() {
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/invite/:token" element={<InviteAccept />} />
|
||||
<Route path="/share/:token" element={<ShareLinkPage />} />
|
||||
<Route path="/mail" element={<EmailInboxPage />} />
|
||||
{/* The editor and its tool routes - Landing handles auth logic */}
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
|
||||
Reference in New Issue
Block a user