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:
Ludy87
2026-08-24 14:08:42 +02:00
parent 1df372764f
commit 54e7a98ab7
14 changed files with 1657 additions and 0 deletions
@@ -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
@@ -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);
}
}
@@ -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"
+12
View File
@@ -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();
+34
View File
@@ -0,0 +1,34 @@
export type EmailProvider = "Microsoft 365" | "Gmail";
export interface EmailAccountRecord {
id: string;
email: string;
provider: EmailProvider;
displayName?: string;
connectedAt: string;
lastSyncedAt?: string;
}
export interface EmailAttachmentRecord {
id: string;
accountId: string;
messageId: string;
name: string;
type: string;
size: string;
cachedAt?: string;
expiresAt?: string;
}
export interface EmailMessageRecord {
id: string;
accountId: string;
sender: string;
address: string;
subject: string;
preview: string;
date: string;
unread?: boolean;
hasAttachment?: boolean;
syncedAt: string;
}
+2
View File
@@ -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>