Merge pull request #9829 from streetwriters/web/failed-inbox-table

web: handle & show failed inbox items
This commit is contained in:
Abdullah Atta
2026-05-21 13:13:27 +05:00
committed by GitHub
22 changed files with 1105 additions and 43 deletions

View File

@@ -49,6 +49,7 @@ import ThemeSelector from "./theme-selector";
import { TitleFormat } from "./title-format";
import { NotesnookCircle } from "./notesnook-circle";
import { ManageInboxKeys, InboxKeysList } from "./manage-inbox-keys";
import { FailedInboxItems } from "./failed-inbox-items";
export const components: { [name: string]: ReactElement } = {
homeselector: <HomePicker />,
@@ -82,5 +83,6 @@ export const components: { [name: string]: ReactElement } = {
"change-email": <ChangeEmail />,
"notesnook-circle": <NotesnookCircle />,
"manage-inbox-keys": <ManageInboxKeys />,
"inbox-keys": <InboxKeysList />
"inbox-keys": <InboxKeysList />,
"failed-inbox-items": <FailedInboxItems />
};

View File

@@ -0,0 +1,396 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getFormattedDate, usePromise } from "@notesnook/common";
import { InboxItemsHistoryErrorContext } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
import React, { useEffect, useState } from "react";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { db } from "../../common/database";
import { presentDialog } from "../../components/dialog/functions";
import { Button } from "../../components/ui/button";
import { IconButton } from "../../components/ui/icon-button";
import Paragraph from "../../components/ui/typography/paragraph";
import { ToastManager } from "../../services/event-manager";
import { AppFontSize, defaultBorderRadius } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Header } from "../../components/header";
type FailedInboxItem = {
id: string;
dateSynced: number;
errorContext?: string;
};
function parseErrorContext(
raw: string | undefined
): InboxItemsHistoryErrorContext | null {
if (!raw) return null;
try {
return JSON.parse(raw) as InboxItemsHistoryErrorContext;
} catch {
return null;
}
}
function ErrorBadge({
message
}: {
message: InboxItemsHistoryErrorContext["message"] | undefined;
}) {
const { colors } = useThemeColors();
if (!message) {
return (
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
N/A
</Paragraph>
);
}
const palette =
message === "Invalid JSON"
? { background: "rgba(255, 152, 0, 0.15)", paragraph: "#e65100" }
: message === "Validation failed"
? { background: "rgba(255, 193, 7, 0.15)", paragraph: "#8a6000" }
: colors.error;
return (
<View
style={{
alignSelf: "flex-start",
backgroundColor: palette.background,
borderRadius: defaultBorderRadius,
paddingVertical: 4,
paddingHorizontal: 8
}}
>
<Paragraph
size={AppFontSize.xxs}
color={palette.paragraph}
style={{ fontWeight: "700" }}
>
{message}
</Paragraph>
</View>
);
}
function DetailsBlock({ value }: { value: string }) {
const { colors } = useThemeColors();
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const timeout = setTimeout(() => setCopied(false), 1000);
return () => clearTimeout(timeout);
}, [copied]);
return (
<View
style={{
borderWidth: 1,
borderColor: colors.secondary.border,
borderRadius: defaultBorderRadius,
backgroundColor: colors.secondary.background,
overflow: "hidden"
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "flex-end",
borderBottomWidth: 1,
borderBottomColor: colors.secondary.border
}}
>
<IconButton
name={copied ? "check" : "content-copy"}
color={colors.primary.icon}
size={AppFontSize.lg}
onPress={() => {
try {
Clipboard.setString(value);
setCopied(true);
ToastManager.show({
message: strings.copied(),
type: "success"
});
} catch {
ToastManager.show({
message: strings.failedToCopyToClipboard(),
type: "error"
});
}
}}
/>
</View>
<ScrollView
horizontal
style={{ maxHeight: 120 }}
contentContainerStyle={{
padding: DefaultAppStyles.GAP_SMALL
}}
>
<Paragraph
size={AppFontSize.xxs}
style={{
fontFamily: "monospace"
}}
>
{value}
</Paragraph>
</ScrollView>
</View>
);
}
export const FailedInboxItems = () => {
const { colors } = useThemeColors();
const result = usePromise(() => db.inboxItemsHistory.failed.items());
async function deleteItem(id: string) {
await db.inboxItemsHistory.delete(id);
ToastManager.show({
message: strings.itemDeleted(),
type: "success"
});
if (result.status !== "pending") {
result.refresh();
}
}
const items = (
result.status === "fulfilled" ? result.value : []
) as FailedInboxItem[];
return (
<View
style={{
flex: 1
}}
>
<Header
renderedInRoute="Settings"
title={strings.failedInboxItems()}
canGoBack={true}
id="Settings"
rightButton={
items?.length > 0
? {
name: "delete",
color: colors.primary.icon,
onPress: async () => {
presentDialog({
title: strings.deleteAll(),
paragraph: strings.deleteAllFailedItemsDesc(),
positiveText: strings.delete(),
positiveType: "errorShade",
positivePress: async () => {
if (result.status !== "pending") {
await db.inboxItemsHistory.deleteFailed();
result.refresh();
}
return true;
}
});
}
}
: undefined
}
/>
{result.status === "pending" ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<ActivityIndicator size="small" color={colors.primary.accent} />
<Paragraph color={colors.secondary.paragraph}>
{strings.loading()}
</Paragraph>
</View>
) : null}
{result.status === "rejected" ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: DefaultAppStyles.GAP_VERTICAL,
paddingHorizontal: DefaultAppStyles.GAP
}}
>
<Paragraph color={colors.error.paragraph}>
{strings.failed()}
</Paragraph>
<Button
title={strings.retry()}
type="accent"
onPress={result.refresh}
/>
</View>
) : null}
{items.length === 0 ? (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingTop: DefaultAppStyles.GAP_VERTICAL,
flex: 1,
justifyContent: "center"
}}
>
<View
style={{
padding: DefaultAppStyles.GAP,
borderRadius: defaultBorderRadius,
alignItems: "center"
}}
>
<Paragraph color={colors.secondary.paragraph}>
{strings.noFailedInboxItems()}
</Paragraph>
</View>
</View>
) : null}
{result.status === "fulfilled" && items.length > 0 ? (
<ScrollView
contentContainerStyle={{
gap: DefaultAppStyles.GAP_VERTICAL,
paddingHorizontal: DefaultAppStyles.GAP,
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
paddingBottom: 50
}}
>
{items.map((item) => {
const context = parseErrorContext(item.errorContext);
const { message, description, ...rest } =
context ??
({} as Partial<InboxItemsHistoryErrorContext> &
Record<string, unknown>);
const details =
Object.keys(rest).length > 0
? JSON.stringify(rest, null, 2)
: undefined;
return (
<View
key={item.id}
style={{
borderWidth: 1,
borderColor: colors.secondary.border,
borderRadius: defaultBorderRadius,
backgroundColor: colors.primary.background,
padding: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between"
}}
>
<Paragraph
size={AppFontSize.xs}
color={colors.secondary.paragraph}
>
{getFormattedDate(item.dateSynced, "date-time")}
</Paragraph>
<ErrorBadge message={message} />
</View>
<View
style={{
flexDirection: "row",
gap: DefaultAppStyles.GAP_SMALL
}}
>
<View
style={{
backgroundColor: colors.error.background,
padding: 3,
paddingHorizontal: 6,
borderRadius: 4
}}
>
<Paragraph color={colors.error.paragraph}>Error</Paragraph>
</View>
<Paragraph> {(description as string) || "N/A"}</Paragraph>
</View>
{details ? (
<DetailsBlock value={details} />
) : (
<Paragraph
size={AppFontSize.xs}
color={colors.secondary.paragraph}
>
N/A
</Paragraph>
)}
<View style={{ alignItems: "flex-end" }}>
<Button
title={strings.delete()}
type="error"
style={{
width: "100%"
}}
onPress={() => {
presentDialog({
title: strings.delete(),
paragraph: strings.areYouSure(),
positiveText: strings.delete(),
positiveType: "error",
negativeText: strings.cancel(),
positivePress: async () => {
try {
await deleteItem(item.id);
return true;
} catch (error) {
ToastManager.error(error as Error);
return false;
}
}
});
}}
/>
</View>
</View>
);
})}
</ScrollView>
) : null}
</View>
);
};

View File

@@ -195,6 +195,8 @@ const InboxKeysList = () => {
const apiKeys = apiKeysPromise.value || [];
console.log(apiKeys);
return (
<ScrollView
contentContainerStyle={{
@@ -202,24 +204,43 @@ const InboxKeysList = () => {
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
width: "100%",
paddingHorizontal: DefaultAppStyles.GAP,
paddingBottom: 50
paddingBottom: 50,
minHeight: "100%"
}}
>
{apiKeys.length === 0 ? (
<View
style={{
padding: DefaultAppStyles.GAP * 2,
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.secondary.border,
borderRadius: 5,
backgroundColor: colors.secondary.background,
alignItems: "center"
alignItems: "center",
gap: DefaultAppStyles.GAP_VERTICAL,
flex: 1,
justifyContent: "center"
}}
>
<Paragraph color={colors.secondary.paragraph}>
{strings.createFirstApiKey()}
</Paragraph>
<Button
title={strings.createKey()}
type="accent"
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
width: "100%"
}}
onPress={() => {
if (apiKeys.length >= 10) {
presentDialog({
title: strings.apiKeysLimitReached(),
paragraph: strings.apiKeysLimitReachedMessage(),
positiveText: strings.ok()
});
} else {
AddApiKeySheet.present(() => apiKeysPromise.refresh());
}
}}
/>
</View>
) : (
<View style={{ gap: 0 }}>

View File

@@ -762,6 +762,14 @@ export const settingsGroups: SettingSection[] = [
description: strings.viewAPIKeysDesc(),
type: "screen",
component: "inbox-keys"
},
{
id: "failed-inbox-items",
name: strings.failedInboxItems(),
description: strings.failedInboxItemsDesc(),
type: "screen",
component: "failed-inbox-items",
hideHeader: true
}
]
}

View File

@@ -49,6 +49,7 @@ type DialogProps = SxProp & {
textAlignment?: "left" | "right" | "center";
buttonsAlignment?: "start" | "center" | "end";
title?: string;
titleAction?: React.ReactNode;
description?: string;
positiveButton?: DialogButtonProps | null;
negativeButton?: DialogButtonProps | null;
@@ -140,19 +141,24 @@ function BaseDialog(props: React.PropsWithChildren<DialogProps>) {
{props.title || props.description ? (
<Flex sx={{ flexDirection: "column" }} p={4} pb={0}>
{props.title && (
<Text
variant="heading"
data-test-id="dialog-title"
sx={{
fontSize: "subheading",
textAlign: props.textAlignment || "left",
color: "paragraph",
overflowWrap: "anywhere",
wordSpacing: "wrap"
}}
<Flex
sx={{ alignItems: "center", justifyContent: "space-between" }}
>
{props.title}
</Text>
<Text
variant="heading"
data-test-id="dialog-title"
sx={{
fontSize: "subheading",
textAlign: props.textAlignment || "left",
color: "paragraph",
overflowWrap: "anywhere",
wordSpacing: "wrap"
}}
>
{props.title}
</Text>
{props.titleAction}
</Flex>
)}
{props.description && (
<Text

View File

@@ -0,0 +1,285 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useState } from "react";
import { getFormattedDate, usePromise } from "@notesnook/common";
import { InboxItemsHistoryErrorContext } from "@notesnook/core";
import { Box, Button, Text } from "@theme-ui/components";
import { db } from "../common/db";
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
import Dialog from "../components/dialog";
import { Check, Copy, Trash } from "../components/icons";
import ScrollContainer, {
FlexScrollContainer
} from "../components/scroll-container";
import { strings } from "@notesnook/intl";
import { writeText } from "clipboard-polyfill";
import { showToast } from "../utils/toast";
type InboxHistoryDialogProps = BaseDialogProps<boolean>;
const COLUMNS = [
{ title: strings.dateSynced(), width: "160px" },
{ title: strings.error(), width: "120px" },
{ title: strings.description(), width: "1fr" },
{ title: strings.details(), width: "1fr" },
{ title: "", width: "40px" }
];
const ERROR_CHIP_STYLES: Record<
InboxItemsHistoryErrorContext["message"],
{ bg: string; color: string }
> = {
"Decryption failed": { bg: "background-error", color: "accent-error" },
"Invalid JSON": { bg: "rgba(255, 152, 0, 0.15)", color: "#e65100" },
"Validation failed": { bg: "rgba(255, 193, 7, 0.15)", color: "#8a6000" }
};
function ErrorBadge({
message
}: {
message: InboxItemsHistoryErrorContext["message"] | undefined;
}) {
if (!message) {
return (
<Text variant="body" sx={{ color: "paragraph-secondary" }}>
</Text>
);
}
const style = ERROR_CHIP_STYLES[message] ?? {
bg: "background-error",
color: "accent-error"
};
return (
<Text
sx={{
bg: style.bg,
color: style.color,
borderRadius: "default",
px: "6px",
py: "2px",
fontSize: "0.65em",
fontWeight: 600
}}
>
{message}
</Text>
);
}
function DetailsCell({ value }: { value: string }) {
const [hovered, setHovered] = useState(false);
const [copied, setCopied] = useState(false);
function handleCopy() {
writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1000);
}
return (
<Box
sx={{ position: "relative" }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<ScrollContainer suppressScrollX style={{ maxHeight: 100 }}>
<Box
as="pre"
sx={{
m: 0,
fontSize: "0.72em",
whiteSpace: "pre-wrap",
wordBreak: "break-all"
}}
>
{value}
</Box>
</ScrollContainer>
{(hovered || copied) && (
<Button
variant="icon"
title="Copy"
onClick={handleCopy}
sx={{
position: "absolute",
top: 0,
left: 0,
p: "2px",
bg: "background",
color: copied ? "accent" : undefined
}}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</Button>
)}
</Box>
);
}
function parseErrorContext(
raw: string | undefined
): InboxItemsHistoryErrorContext | null {
if (!raw) return null;
try {
return JSON.parse(raw) as InboxItemsHistoryErrorContext;
} catch {
return null;
}
}
export const InboxHistoryDialog = DialogManager.register(
function InboxHistoryDialog(props: InboxHistoryDialogProps) {
const result = usePromise(() => db.inboxItemsHistory.failed.items());
async function deleteItem(id: string) {
await db.inboxItemsHistory.delete(id);
showToast("success", strings.itemDeleted());
if (result.status !== "pending") {
result.refresh();
}
}
async function deleteAll() {
await db.inboxItemsHistory.deleteFailed();
showToast("success", strings.allItemsDeleted());
if (result.status !== "pending") {
result.refresh();
}
}
return (
<Dialog
isOpen={true}
title={strings.failedInboxItems()}
titleAction={
result.status === "fulfilled" &&
result.value.length > 0 && (
<Button variant="errorSecondary" onClick={deleteAll}>
{strings.deleteAll()}
</Button>
)
}
onClose={() => props.onClose(false)}
negativeButton={{
text: strings.close(),
onClick: () => props.onClose(false)
}}
noScroll
width="80%"
>
{result.status === "pending" ? (
<Text sx={{ p: 3 }}>{strings.loading()}</Text>
) : result.status === "rejected" ? (
<Text sx={{ p: 3 }}>{strings.failed()}</Text>
) : result.value.length === 0 ? (
<Text sx={{ p: 3 }}>{strings.noFailedInboxItems()}</Text>
) : (
<FlexScrollContainer style={{ maxHeight: "70vh" }}>
<Box sx={{ p: 2 }}>
<Box
as="table"
sx={{
width: "100%",
borderCollapse: "collapse",
tableLayout: "fixed",
"th, td": {
px: 2,
py: 1,
textAlign: "left",
verticalAlign: "top",
borderBottom: "1px solid var(--separator)"
}
}}
>
<Box as="thead">
<Box as="tr">
{COLUMNS.map((col) => (
<Box
key={col.title}
as="th"
sx={{ width: col.width, whiteSpace: "nowrap" }}
>
<Text variant="subtitle">{col.title}</Text>
</Box>
))}
</Box>
</Box>
<Box as="tbody">
{result.value.map((item) => {
const ctx = parseErrorContext(item.errorContext);
const { message, description, ...rest } =
ctx ??
({} as Partial<InboxItemsHistoryErrorContext> &
Record<string, unknown>);
const hasExtra = Object.keys(rest).length > 0;
return (
<Box as="tr" key={item.id}>
<Box as="td">
<Text variant="body" sx={{ whiteSpace: "nowrap" }}>
{getFormattedDate(item.dateSynced)}
</Text>
</Box>
<Box as="td">
<ErrorBadge message={message} />
</Box>
<Box as="td">
<Text variant="body">
{(description as string) ?? "—"}
</Text>
</Box>
<Box as="td">
{hasExtra ? (
<DetailsCell
value={JSON.stringify(rest, null, 2)}
/>
) : (
<Text
variant="body"
sx={{ color: "paragraph-secondary" }}
>
</Text>
)}
</Box>
<Box as="td" sx={{ textAlign: "center" }}>
<Button
variant="icon"
title={strings.delete()}
onClick={() => deleteItem(item.id)}
sx={{ color: "accent-error", p: "2px" }}
>
<Trash size={16} />
</Button>
</Box>
</Box>
);
})}
</Box>
</Box>
</Box>
</FlexScrollContainer>
)}
</Dialog>
);
}
);

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useRef, useState, useEffect } from "react";
import { Box, Button, Flex, Input, Text, Select } from "@theme-ui/components";
import { formatDate, InboxApiKey } from "@notesnook/core";
import { InboxApiKey } from "@notesnook/core";
import { db } from "../../../common/db";
import { showToast } from "../../../utils/toast";
import {
@@ -32,7 +32,7 @@ import {
import Field from "../../../components/field";
import { BaseDialogProps, DialogManager } from "../../../common/dialog-manager";
import Dialog from "../../../components/dialog";
import { usePromise } from "@notesnook/common";
import { getFormattedDate, usePromise } from "@notesnook/common";
import { ConfirmDialog } from "../../confirm";
import { showPasswordDialog } from "../../password-dialog";
import { strings } from "@notesnook/intl";
@@ -233,17 +233,17 @@ function ApiKeyItem({ apiKey, onRevoke, isAtEnd }: ApiKeyItemProps) {
<Flex sx={{ mb: 1, flexDirection: "column" }}>
<Text variant="subBody" sx={{ color: "paragraph-secondary" }}>
{apiKey.lastUsedAt
? `Last used on ${formatDate(apiKey.lastUsedAt)}`
? `Last used on ${getFormattedDate(apiKey.lastUsedAt)}`
: "Never used"}
</Text>
<Text variant="subBody" sx={{ color: "paragraph-secondary" }}>
Created on {formatDate(apiKey.dateCreated)}
Created on {getFormattedDate(apiKey.dateCreated)}
</Text>
<Text variant="subBody" sx={{ color: "paragraph-secondary" }}>
{apiKey.expiryDate === -1
? "Never expires"
: `${isApiKeyExpired ? "Expired" : "Expires"} on
${formatDate(apiKey.expiryDate)}`}
${getFormattedDate(apiKey.expiryDate)}`}
</Text>
</Flex>
</Box>

View File

@@ -24,6 +24,7 @@ import { InboxPGPKeysDialog } from "../inbox-pgp-keys-dialog";
import { db } from "../../common/db";
import { showPasswordDialog } from "../password-dialog";
import { strings } from "@notesnook/intl";
import { InboxHistoryDialog } from "../inbox-history-dialog";
export const InboxSettings: SettingsGroup[] = [
{
@@ -81,6 +82,25 @@ export const InboxSettings: SettingsGroup[] = [
}
]
},
{
key: "failed-inbox-items",
title: strings.failedInboxItems(),
description: strings.failedInboxItemsDesc(),
keywords: ["inbox", "failed", "items"],
onStateChange: (listener) =>
useSettingStore.subscribe((s) => s.isInboxEnabled, listener),
isHidden: () => !useSettingStore.getState().isInboxEnabled,
components: [
{
type: "button",
title: strings.show(),
variant: "secondary",
action: () => {
InboxHistoryDialog.show({});
}
}
]
},
{
key: "inbox-api-keys",
title: "",

View File

@@ -305,6 +305,7 @@ class SettingStore extends BaseStore<SettingStore> {
});
if (!ok) return;
await db.inboxItemsHistory.deleteFailed();
await db.user.discardInboxKeys();
this.set({ isInboxEnabled: false });

33
inbox-public.asc Normal file
View File

@@ -0,0 +1,33 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: openpgp-mobile
xsBNBGoNaK8BCAD0B33KK4LRAvN1lZLpJhQMyk/+Srss56PjFphMH1MmqMgIRRBP
3RykX+7+ibha+5WIFYpgBEaPM9osZz22XUGhVrUzUxJMScjhLWR6xyuv0qs0Dctg
ePdcupPbJND3j9W4OnOBXwv+Ko/fX5K+enJfPp6fxyWbf3X/1BnAYlHyLngBLz8P
mt5qj0qax5V4ujUVU8ByNFvQkjcA+ip0vol2xQlmKa5UXJ1KYfM7LQWa4gQqdaY8
8FOl6CaaWsXO/vCnIF47JClWGJfJ0rAaREI/Kj+MV+i98BPAcZG8Kj523QmgIHo6
Puahi1q9wqX6Vwe6hjhiTUJSWvNjZXGi9A4PABEBAAHNDU5OIDxOTkBOTi5OTj7C
wLsEEwEIAG8FgmoNaK8CCwcJkC34qZJdxA5jNRQAAAAAABwAEHNhbHRAbm90YXRp
b25zLm9wZW5wZ3Bqcy5vcmfLP2eEUAk99foAqkNQSyW9AhUIAhYAAhkBApsDAh4B
FiEEjXaqZcVUExRCuEhoLfipkl3EDmMAAKW7CACxh26CVTjRLjeq/GNueceWRJoT
qF/OMQY/3mnuLMbaEMuYSc03ml6jiMdqZy6qeKgjH36qvpfu68HxPYEOn3WQ/k9V
2Iug4xDFlqFw0IN2Gqkgur+FYbFCjG1mkqsrlKsN2QHqG8sf+5EXegpvNibI+43Q
HZp+Q5B8HftnBANvZngFJz3t0hCCpUgVOTK4vwK8IKDK7zbMqfnT4k5vNbfpxd30
5xfZkQXrltJLaHb5pzgVMeoM00TaHd8WBDFYn8BF1OI/TbcChYpRPkW7WoKKuZ/1
tIBfv/O5h3ZxgSc4NdL1FuLZ0nTscPoN+uBE29XQat8IOiYiA4DvKLbIQe89zsBN
BGoNaK8BCACppIfZHotgC8zKvCkj1sWAny5Qq/AkYdIJh/b//7NhnWyUgiSDnoEV
1yik33CiQgoORR42YfVCZCMH2ZydzeKdaNKd/fFLeMsog0ddp4cW68drDDVvkGso
vSMwvpSS/J4JJ2KXqIbvscJXGzAaFZ61BoY3kvRKHymHGe2oLs2bPscNug7mm8pm
18+IhNeOtuS6MZzdXr9rmfuTtY9zUIbIaOgY3EiiaRkvcQLPcBTwsoM9b9zNMK+1
ivA65KrtgN+T7axcIRT+QFFoNOw7mjHNQybb6qRABWS8AWouot4B2g9tNHndlhNV
aimI+P1RHaP9VOxJFneWkmvviXqznv4TABEBAAHCwKwEGAEIAGAFgmoNaK8JkC34
qZJdxA5jNRQAAAAAABwAEHNhbHRAbm90YXRpb25zLm9wZW5wZ3Bqcy5vcmeywJ1O
IyGqDaRT2EGH4ZZpApsMFiEEjXaqZcVUExRCuEhoLfipkl3EDmMAABm8B/9rKv4l
PNwYXLVQlhyGlF/MvdYvT4Fj2CxtO32Fo++dUlEYJqX++GihXr0HyjdE200Ttb1Q
IpZto9rx5X0QHKGEqVGM+kJ6STOWK5jRlADES6GKE3dZde4Z+QS+BBdEdveZtGwR
GsLArPKjhnbkiBXTrMUkoQa4eGanuXA452io5NsiIeNlMUsC6IAXxuZp8+iBtN9K
65iSrvmasdKgwttbqp0qiI5VudiEAQjrkHDMlGftMqSllZWkavlbpYPIN/Omzh8G
kVbBMAvQtDsxSFACnZCGQ1l3r9qDJWUQyn57qQgrQwYXx+sHIlOcdAMGiPbE49KP
kUYOKopIqix0i2/F
=rfLM
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -50,6 +50,7 @@ import { Shortcuts } from "../collections/shortcuts.js";
import { Reminders } from "../collections/reminders.js";
import { Relations } from "../collections/relations.js";
import Subscriptions from "./subscriptions.js";
import { InboxItemsHistory } from "../collections/inbox-items-history.js";
import {
CompressorAccessor,
ConfigStorageAccessor,
@@ -228,6 +229,7 @@ class Database {
settings = new Settings(this);
inboxApiKeys = new InboxApiKeys(this, this.tokenManager);
inboxItemsHistory = new InboxItemsHistory(this);
wrapped = new Wrapped(this);
@@ -350,6 +352,8 @@ class Database {
await this.vaults.init();
await this.monographsCollection.init();
await this.inboxItemsHistory.init();
await this.trash.init();
// legacy collections

View File

@@ -29,6 +29,7 @@ import {
isDeleted
} from "../../types.js";
import { SyncInboxItem } from "./types.js";
import { InboxItemsHistoryErrorContext } from "../../types.js";
import { z } from "zod";
import { sanitizeHtml } from "../../utils/html-parser.js";
@@ -201,24 +202,76 @@ export async function handleInboxItems(
for (const item of inboxItems) {
try {
if (await db.notes.exists(item.id)) {
logger.info("Inbox item already exists, skipping.", {
if (await db.inboxItemsHistory.exists(item.id)) {
logger.info("Inbox item already processed, skipping.", {
inboxItemId: item.id
});
continue;
}
const decryptedItem = await db
.storage()
.decryptPGPMessage(inboxKeys.privateKey, item.cipher);
const validation = RawInboxItemSchema.safeParse(
JSON.parse(decryptedItem)
);
let decryptedItem: string;
try {
decryptedItem = await db
.storage()
.decryptPGPMessage(inboxKeys.privateKey, item.cipher);
} catch (e) {
logger.error(e, "Failed to decrypt inbox item.", {
inboxItemId: item.id
});
await db.inboxItemsHistory.add({
id: item.id,
status: "failed",
errorContext: JSON.stringify({
message: "Decryption failed",
description: (e as Error).message,
inboxItem: { id: item.id, v: item.v, alg: item.alg }
} satisfies InboxItemsHistoryErrorContext)
});
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(decryptedItem);
} catch (e) {
logger.error(e, "Failed to parse inbox item JSON.", {
inboxItemId: item.id
});
await db.inboxItemsHistory.add({
id: item.id,
status: "failed",
errorContext: JSON.stringify({
message: "Invalid JSON",
description: (e as Error).message,
inboxItem: { id: item.id, v: item.v, alg: item.alg },
decryptedItem
} satisfies InboxItemsHistoryErrorContext)
});
continue;
}
const validation = RawInboxItemSchema.safeParse(parsed);
if (!validation.success) {
logger.warn("Failed to validate inbox item.", {
inboxItem: item,
errors: validation.error.issues
});
const { content: _content, ...parsedWithoutContent } = parsed as Record<
string,
unknown
>;
await db.inboxItemsHistory.add({
id: item.id,
status: "failed",
errorContext: JSON.stringify({
message: "Validation failed",
description: validation.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; "),
inboxItem: { id: item.id, v: item.v, alg: item.alg },
parsedItem: parsedWithoutContent
} satisfies InboxItemsHistoryErrorContext)
});
continue;
}
@@ -248,11 +301,19 @@ export async function handleInboxItems(
{ type: "note", id: item.id }
);
}
await db.inboxItemsHistory.add({
id: item.id,
status: "success",
source: data.source
});
await db.relations.add(
{ type: "inboxitemhistory", id: item.id },
{ type: "note", id: item.id }
);
} catch (e) {
logger.error(e, "Failed to process inbox item.", {
inboxItem: item
});
continue;
}
}
}

View File

@@ -45,7 +45,8 @@ export const SYNC_COLLECTIONS_MAP = {
tag: "tags",
color: "colors",
note: "notes",
vault: "vaults"
vault: "vaults",
inboxitemhistory: "inboxItemsHistory"
} as const;
export const SYNC_ITEM_TYPES = Object.keys(

View File

@@ -0,0 +1,82 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { InboxItemHistory } from "../types.js";
import Database from "../api/index.js";
import { ICollection } from "./collection.js";
import { SQLCollection } from "../database/sql-collection.js";
import { isFalse } from "../database/index.js";
export class InboxItemsHistory implements ICollection {
name = "inboxitemshistory";
readonly collection: SQLCollection<"inboxitemshistory", InboxItemHistory>;
constructor(private readonly db: Database) {
this.collection = new SQLCollection(
db.sql,
db.transaction,
"inboxitemshistory",
db.eventManager,
db.sanitizer
);
}
init() {
return this.collection.init();
}
async add(item: {
id: string;
status: "failed" | "success";
source?: string;
errorContext?: string;
}) {
const now = Date.now();
await this.collection.upsert({
id: item.id,
type: "inboxitemhistory",
dateCreated: now,
dateModified: now,
dateSynced: now,
status: item.status,
source: item.source,
errorContext: item.errorContext
});
return item.id;
}
get failed() {
return this.collection.createFilter<InboxItemHistory>(
(qb) => qb.where(isFalse("deleted")).where("status", "==", "failed"),
this.db.options?.batchSize
);
}
async delete(id: string) {
await this.collection.softDelete([id]);
}
async deleteFailed() {
const ids = await this.failed.ids();
await this.collection.softDelete(ids);
}
exists(id: string) {
return this.collection.exists(id);
}
}

View File

@@ -42,6 +42,7 @@ import {
Color,
ContentItem,
HistorySession,
InboxItemHistory,
ItemReference,
ItemReferences,
ItemType,
@@ -92,6 +93,7 @@ export interface DatabaseSchema {
shortcuts: SQLiteItem<Shortcut>;
vaults: SQLiteItem<Vault>;
monographs: SQLiteItem<Monograph>;
inboxitemshistory: SQLiteItem<InboxItemHistory>;
}
export type RawDatabaseSchema = DatabaseSchema & {

View File

@@ -441,6 +441,19 @@ export class NNMigrationProvider implements MigrationProvider {
.addColumn("spellcheck", "boolean", (c) => c.defaultTo(true))
.execute();
}
},
"a-2026-05-07": {
async up(db) {
await db.schema
.createTable("inboxitemshistory")
.modifyEnd(sql`without rowid`)
.$call(addBaseColumns)
.addColumn("dateSynced", "integer")
.addColumn("status", "text")
.addColumn("source", "text")
.addColumn("errorContext", "text")
.execute();
}
}
};
}

View File

@@ -702,7 +702,8 @@ const VALID_SORT_OPTIONS: Record<
settings: [],
shortcuts: [],
vaults: [],
monographs: []
monographs: [],
inboxitemshistory: []
};
function sanitizeSortOptions(type: keyof DatabaseSchema, options: SortOptions) {

View File

@@ -83,6 +83,7 @@ export type Collections = {
settingsv2: "settingitem";
vaults: "vault";
monographs: "monograph";
inboxitemshistory: "inboxitemhistory";
/**
* @deprecated only kept here for migration purposes
@@ -114,6 +115,7 @@ export type GroupableItem = ValueOf<
| "settingitem"
| "vault"
| "monograph"
| "inboxitemhistory"
>
>;
@@ -136,6 +138,7 @@ export type ItemMap = {
vault: Vault;
searchResult: HighlightedResult;
monograph: Monograph;
inboxitemhistory: InboxItemHistory;
/**
* @deprecated only kept here for migration purposes
@@ -512,6 +515,31 @@ export interface Monograph extends BaseItem<"monograph"> {
publishUrl?: string;
}
type InboxItemHistoryErrorContextBase = {
description: string;
inboxItem: { id: string; v: number; alg: string };
};
export type InboxItemsHistoryErrorContext =
| (InboxItemHistoryErrorContextBase & {
message: "Decryption failed";
})
| (InboxItemHistoryErrorContextBase & {
message: "Invalid JSON";
decryptedItem: string;
})
| (InboxItemHistoryErrorContextBase & {
message: "Validation failed";
parsedItem: Record<string, unknown>;
});
export interface InboxItemHistory extends BaseItem<"inboxitemhistory"> {
dateSynced: number;
status: "failed" | "success";
source?: string;
errorContext?: string;
}
export type Match = {
prefix: string;
match: string;

View File

@@ -30,7 +30,12 @@ export const parseHTML = (input: string) =>
: null;
export const sanitizeHtml = (html: string): string => {
if (!isHtmlValid(html)) {
return wrapInCodeBlock(html);
}
const inputHtml = normalizeToHtmlBody(html);
return getDomPurify().sanitize(inputHtml, {
RETURN_DOM: false,
ADD_TAGS: ["iframe"]

View File

@@ -1,6 +1,7 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-05-20 09:27+0500\n"
"POT-Creation-Date: 2026-05-21 08:29+0500\n"
"POT-Creation-Date: 2026-05-21 08:29+0500\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
@@ -820,6 +821,10 @@ msgstr "All fields are required"
msgid "All files"
msgstr "All files"
#: src/strings.ts:2756
msgid "All items deleted"
msgstr "All items deleted"
#: src/strings.ts:1176
msgid "All locked notes will be re-encrypted with the new password."
msgstr "All locked notes will be re-encrypted with the new password."
@@ -975,6 +980,10 @@ msgstr "Are you sure you want to clear all logs from {key}?"
msgid "Are you sure you want to clear trash?"
msgstr "Are you sure you want to clear trash?"
#: src/strings.ts:2757
msgid "Are you sure you want to delete all failed inbox items?"
msgstr "Are you sure you want to delete all failed inbox items?"
#: src/strings.ts:2734
msgid "Are you sure you want to delete this attachment?"
msgstr "Are you sure you want to delete this attachment?"
@@ -2154,6 +2163,10 @@ msgstr "Date format"
msgid "Date modified"
msgstr "Date modified"
#: src/strings.ts:2749
msgid "Date synced"
msgstr "Date synced"
#: src/strings.ts:2044
msgid "Date uploaded"
msgstr "Date uploaded"
@@ -2239,6 +2252,10 @@ msgstr "Delete"
msgid "Delete account"
msgstr "Delete account"
#: src/strings.ts:2755
msgid "Delete all"
msgstr "Delete all"
#: src/strings.ts:2732
msgid "Delete attachment"
msgstr "Delete attachment"
@@ -2307,6 +2324,10 @@ msgstr "Desktop app"
msgid "Desktop integration"
msgstr "Desktop integration"
#: src/strings.ts:2748
msgid "Details"
msgstr "Details"
#: src/strings.ts:871
msgid "Did you save recovery key?"
msgstr "Did you save recovery key?"
@@ -2911,6 +2932,10 @@ msgstr "Faced an issue or have a suggestion? Click here to create a bug report"
msgid "Failed"
msgstr "Failed"
#: src/strings.ts:2750
msgid "Failed inbox items"
msgstr "Failed inbox items"
#: src/strings.ts:2653
msgid "Failed to attach file"
msgstr "Failed to attach file"
@@ -3664,6 +3689,10 @@ msgstr "item"
msgid "Item"
msgstr "Item"
#: src/strings.ts:2754
msgid "Item deleted"
msgstr "Item deleted"
#: src/strings.ts:311
#: src/strings.ts:1705
msgid "items"
@@ -4360,6 +4389,10 @@ msgstr "No downloads in progress."
msgid "No encryption key found"
msgstr "No encryption key found"
#: src/strings.ts:2752
msgid "No failed inbox items"
msgstr "No failed inbox items"
#: src/strings.ts:1667
msgid "No headings found"
msgstr "No headings found"
@@ -6281,6 +6314,10 @@ msgstr "shortcuts"
msgid "Shortcuts"
msgstr "Shortcuts"
#: src/strings.ts:2753
msgid "Show"
msgstr "Show"
#: src/strings.ts:96
msgid "Sign up"
msgstr "Sign up"
@@ -7405,6 +7442,10 @@ msgstr "View and manage inbox API keys"
msgid "View and share debug logs"
msgstr "View and share debug logs"
#: src/strings.ts:2751
msgid "View failed inbox items and error contexts"
msgstr "View failed inbox items and error contexts"
#: src/strings.ts:1749
msgid "View receipt"
msgstr "View receipt"

View File

@@ -1,6 +1,7 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-05-20 09:27+0500\n"
"POT-Creation-Date: 2026-05-21 08:29+0500\n"
"POT-Creation-Date: 2026-05-21 08:29+0500\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
@@ -820,6 +821,10 @@ msgstr ""
msgid "All files"
msgstr ""
#: src/strings.ts:2756
msgid "All items deleted"
msgstr ""
#: src/strings.ts:1176
msgid "All locked notes will be re-encrypted with the new password."
msgstr ""
@@ -973,7 +978,11 @@ msgstr ""
#: src/strings.ts:1326
msgid "Are you sure you want to clear trash?"
msgstr ""
msgstr "<<<<<<< HEAD======="
#: src/strings.ts:2757
msgid "Are you sure you want to delete all failed inbox items?"
msgstr ">>>>>>> 101851125 (mobile: add failed inbox item history)"
#: src/strings.ts:2734
msgid "Are you sure you want to delete this attachment?"
@@ -2143,6 +2152,10 @@ msgstr ""
msgid "Date modified"
msgstr ""
#: src/strings.ts:2749
msgid "Date synced"
msgstr ""
#: src/strings.ts:2044
msgid "Date uploaded"
msgstr ""
@@ -2226,7 +2239,11 @@ msgstr ""
#: src/strings.ts:1078
msgid "Delete account"
msgstr ""
msgstr "<<<<<<< HEAD======="
#: src/strings.ts:2755
msgid "Delete all"
msgstr ">>>>>>> 101851125 (mobile: add failed inbox item history)"
#: src/strings.ts:2732
msgid "Delete attachment"
@@ -2296,6 +2313,10 @@ msgstr ""
msgid "Desktop integration"
msgstr ""
#: src/strings.ts:2748
msgid "Details"
msgstr ""
#: src/strings.ts:871
msgid "Did you save recovery key?"
msgstr ""
@@ -2900,6 +2921,10 @@ msgstr ""
msgid "Failed"
msgstr ""
#: src/strings.ts:2750
msgid "Failed inbox items"
msgstr ""
#: src/strings.ts:2653
msgid "Failed to attach file"
msgstr ""
@@ -3644,6 +3669,10 @@ msgstr ""
msgid "Item"
msgstr ""
#: src/strings.ts:2754
msgid "Item deleted"
msgstr ""
#: src/strings.ts:311
#: src/strings.ts:1705
msgid "items"
@@ -4340,6 +4369,10 @@ msgstr ""
msgid "No encryption key found"
msgstr ""
#: src/strings.ts:2752
msgid "No failed inbox items"
msgstr ""
#: src/strings.ts:1667
msgid "No headings found"
msgstr ""
@@ -5151,11 +5184,11 @@ msgstr ""
#: src/strings.ts:2193
msgid "Proxy"
msgstr ""
msgstr "<<<<<<< HEAD"
#: src/strings.ts:2746
msgid "Public key required"
msgstr ""
msgstr "=======>>>>>>> 101851125 (mobile: add failed inbox item history)"
#: src/strings.ts:2712
msgid "Public Key:"
@@ -6247,6 +6280,10 @@ msgstr ""
msgid "Shortcuts"
msgstr ""
#: src/strings.ts:2753
msgid "Show"
msgstr ""
#: src/strings.ts:96
msgid "Sign up"
msgstr ""
@@ -7355,6 +7392,10 @@ msgstr ""
msgid "View and share debug logs"
msgstr ""
#: src/strings.ts:2751
msgid "View failed inbox items and error contexts"
msgstr ""
#: src/strings.ts:1749
msgid "View receipt"
msgstr ""

View File

@@ -2744,5 +2744,16 @@ Continue without attachments?`,
pleaseLoginToDownloadAttachments: () =>
t`Please login to download attachments.`,
publicKeyRequired: () => t`Public key required`,
privateKeyRequired: () => t`Private key required`
privateKeyRequired: () => t`Private key required`,
details: () => t`Details`,
dateSynced: () => t`Date synced`,
failedInboxItems: () => t`Failed inbox items`,
failedInboxItemsDesc: () => t`View failed inbox items and error contexts`,
noFailedInboxItems: () => t`No failed inbox items`,
show: () => t`Show`,
itemDeleted: () => t`Item deleted`,
deleteAll: () => t`Delete all`,
deleteAllFailedItemsDesc: () =>
t`Are you sure you want to delete all failed inbox items?`,
allItemsDeleted: () => t`All items deleted`
};