diff --git a/apps/mobile/app/screens/settings/components.tsx b/apps/mobile/app/screens/settings/components.tsx
index 4413648bf..f18de97aa 100644
--- a/apps/mobile/app/screens/settings/components.tsx
+++ b/apps/mobile/app/screens/settings/components.tsx
@@ -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: ,
@@ -82,5 +83,6 @@ export const components: { [name: string]: ReactElement } = {
"change-email": ,
"notesnook-circle": ,
"manage-inbox-keys": ,
- "inbox-keys":
+ "inbox-keys": ,
+ "failed-inbox-items":
};
diff --git a/apps/mobile/app/screens/settings/failed-inbox-items.tsx b/apps/mobile/app/screens/settings/failed-inbox-items.tsx
new file mode 100644
index 000000000..9beb1dfb9
--- /dev/null
+++ b/apps/mobile/app/screens/settings/failed-inbox-items.tsx
@@ -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 .
+*/
+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 (
+
+ N/A
+
+ );
+ }
+
+ 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 (
+
+
+ {message}
+
+
+ );
+}
+
+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 (
+
+
+ {
+ try {
+ Clipboard.setString(value);
+ setCopied(true);
+ ToastManager.show({
+ message: strings.copied(),
+ type: "success"
+ });
+ } catch {
+ ToastManager.show({
+ message: strings.failedToCopyToClipboard(),
+ type: "error"
+ });
+ }
+ }}
+ />
+
+
+
+
+ {value}
+
+
+
+ );
+}
+
+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 (
+
+ 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" ? (
+
+
+
+ {strings.loading()}
+
+
+ ) : null}
+
+ {result.status === "rejected" ? (
+
+
+ {strings.failed()}
+
+
+
+ ) : null}
+
+ {items.length === 0 ? (
+
+
+
+ {strings.noFailedInboxItems()}
+
+
+
+ ) : null}
+
+ {result.status === "fulfilled" && items.length > 0 ? (
+
+ {items.map((item) => {
+ const context = parseErrorContext(item.errorContext);
+ const { message, description, ...rest } =
+ context ??
+ ({} as Partial &
+ Record);
+ const details =
+ Object.keys(rest).length > 0
+ ? JSON.stringify(rest, null, 2)
+ : undefined;
+
+ return (
+
+
+
+ {getFormattedDate(item.dateSynced, "date-time")}
+
+
+
+
+
+
+
+ Error
+
+ {(description as string) || "N/A"}
+
+
+ {details ? (
+
+ ) : (
+
+ N/A
+
+ )}
+
+
+
+
+ );
+ })}
+
+ ) : null}
+
+ );
+};
diff --git a/apps/mobile/app/screens/settings/manage-inbox-keys.tsx b/apps/mobile/app/screens/settings/manage-inbox-keys.tsx
index a1d72d609..606c5c70a 100644
--- a/apps/mobile/app/screens/settings/manage-inbox-keys.tsx
+++ b/apps/mobile/app/screens/settings/manage-inbox-keys.tsx
@@ -195,6 +195,8 @@ const InboxKeysList = () => {
const apiKeys = apiKeysPromise.value || [];
+ console.log(apiKeys);
+
return (
{
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
width: "100%",
paddingHorizontal: DefaultAppStyles.GAP,
- paddingBottom: 50
+ paddingBottom: 50,
+ minHeight: "100%"
}}
>
{apiKeys.length === 0 ? (
{strings.createFirstApiKey()}
+
+
) : (
diff --git a/apps/mobile/app/screens/settings/settings-data.tsx b/apps/mobile/app/screens/settings/settings-data.tsx
index 073e9a78e..85cef957b 100644
--- a/apps/mobile/app/screens/settings/settings-data.tsx
+++ b/apps/mobile/app/screens/settings/settings-data.tsx
@@ -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
}
]
}
diff --git a/apps/web/src/components/dialog/index.tsx b/apps/web/src/components/dialog/index.tsx
index 2216f9b31..e969b02f8 100644
--- a/apps/web/src/components/dialog/index.tsx
+++ b/apps/web/src/components/dialog/index.tsx
@@ -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) {
{props.title || props.description ? (
{props.title && (
-
- {props.title}
-
+
+ {props.title}
+
+ {props.titleAction}
+
)}
{props.description && (
.
+*/
+
+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;
+
+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 (
+
+ —
+
+ );
+ }
+
+ const style = ERROR_CHIP_STYLES[message] ?? {
+ bg: "background-error",
+ color: "accent-error"
+ };
+ return (
+
+ {message}
+
+ );
+}
+
+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 (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ >
+
+
+ {value}
+
+
+ {(hovered || copied) && (
+
+ )}
+
+ );
+}
+
+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 (
+
+ );
+ }
+);
diff --git a/apps/web/src/dialogs/settings/components/inbox-api-keys.tsx b/apps/web/src/dialogs/settings/components/inbox-api-keys.tsx
index 3206a83ec..2a770aea8 100644
--- a/apps/web/src/dialogs/settings/components/inbox-api-keys.tsx
+++ b/apps/web/src/dialogs/settings/components/inbox-api-keys.tsx
@@ -19,7 +19,7 @@ along with this program. If not, see .
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) {
{apiKey.lastUsedAt
- ? `Last used on ${formatDate(apiKey.lastUsedAt)}`
+ ? `Last used on ${getFormattedDate(apiKey.lastUsedAt)}`
: "Never used"}
- Created on {formatDate(apiKey.dateCreated)}
+ Created on {getFormattedDate(apiKey.dateCreated)}
{apiKey.expiryDate === -1
? "Never expires"
: `${isApiKeyExpired ? "Expired" : "Expires"} on
- ${formatDate(apiKey.expiryDate)}`}
+ ${getFormattedDate(apiKey.expiryDate)}`}
diff --git a/apps/web/src/dialogs/settings/inbox-settings.ts b/apps/web/src/dialogs/settings/inbox-settings.ts
index bdec99565..de0281c3c 100644
--- a/apps/web/src/dialogs/settings/inbox-settings.ts
+++ b/apps/web/src/dialogs/settings/inbox-settings.ts
@@ -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: "",
diff --git a/apps/web/src/stores/setting-store.ts b/apps/web/src/stores/setting-store.ts
index c7230a524..b0d0fa10d 100644
--- a/apps/web/src/stores/setting-store.ts
+++ b/apps/web/src/stores/setting-store.ts
@@ -305,6 +305,7 @@ class SettingStore extends BaseStore {
});
if (!ok) return;
+ await db.inboxItemsHistory.deleteFailed();
await db.user.discardInboxKeys();
this.set({ isInboxEnabled: false });
diff --git a/inbox-public.asc b/inbox-public.asc
new file mode 100644
index 000000000..bdaf88521
--- /dev/null
+++ b/inbox-public.asc
@@ -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-----
diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts
index 979a2f0ee..fea4fd6f6 100644
--- a/packages/core/src/api/index.ts
+++ b/packages/core/src/api/index.ts
@@ -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
diff --git a/packages/core/src/api/sync/merger.ts b/packages/core/src/api/sync/merger.ts
index 16be28c63..bc5441d88 100644
--- a/packages/core/src/api/sync/merger.ts
+++ b/packages/core/src/api/sync/merger.ts
@@ -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;
}
}
}
diff --git a/packages/core/src/api/sync/types.ts b/packages/core/src/api/sync/types.ts
index efba6eb92..e8dde136f 100644
--- a/packages/core/src/api/sync/types.ts
+++ b/packages/core/src/api/sync/types.ts
@@ -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(
diff --git a/packages/core/src/collections/inbox-items-history.ts b/packages/core/src/collections/inbox-items-history.ts
new file mode 100644
index 000000000..9d72c3b5a
--- /dev/null
+++ b/packages/core/src/collections/inbox-items-history.ts
@@ -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 .
+*/
+
+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(
+ (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);
+ }
+}
diff --git a/packages/core/src/database/index.ts b/packages/core/src/database/index.ts
index f9e03c45a..550bc2bab 100644
--- a/packages/core/src/database/index.ts
+++ b/packages/core/src/database/index.ts
@@ -42,6 +42,7 @@ import {
Color,
ContentItem,
HistorySession,
+ InboxItemHistory,
ItemReference,
ItemReferences,
ItemType,
@@ -92,6 +93,7 @@ export interface DatabaseSchema {
shortcuts: SQLiteItem;
vaults: SQLiteItem;
monographs: SQLiteItem;
+ inboxitemshistory: SQLiteItem;
}
export type RawDatabaseSchema = DatabaseSchema & {
diff --git a/packages/core/src/database/migrations.ts b/packages/core/src/database/migrations.ts
index e1fc4e5c3..6fa22a5c5 100644
--- a/packages/core/src/database/migrations.ts
+++ b/packages/core/src/database/migrations.ts
@@ -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();
+ }
}
};
}
diff --git a/packages/core/src/database/sql-collection.ts b/packages/core/src/database/sql-collection.ts
index b13f0f6a6..4c3e11da1 100644
--- a/packages/core/src/database/sql-collection.ts
+++ b/packages/core/src/database/sql-collection.ts
@@ -702,7 +702,8 @@ const VALID_SORT_OPTIONS: Record<
settings: [],
shortcuts: [],
vaults: [],
- monographs: []
+ monographs: [],
+ inboxitemshistory: []
};
function sanitizeSortOptions(type: keyof DatabaseSchema, options: SortOptions) {
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index eba45d003..4f988823f 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -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;
+ });
+
+export interface InboxItemHistory extends BaseItem<"inboxitemhistory"> {
+ dateSynced: number;
+ status: "failed" | "success";
+ source?: string;
+ errorContext?: string;
+}
+
export type Match = {
prefix: string;
match: string;
diff --git a/packages/core/src/utils/html-parser.ts b/packages/core/src/utils/html-parser.ts
index 4ce7e83f5..d3774f522 100644
--- a/packages/core/src/utils/html-parser.ts
+++ b/packages/core/src/utils/html-parser.ts
@@ -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"]
diff --git a/packages/intl/locale/en.po b/packages/intl/locale/en.po
index daa93beaf..8d937f8f4 100644
--- a/packages/intl/locale/en.po
+++ b/packages/intl/locale/en.po
@@ -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"
diff --git a/packages/intl/locale/pseudo-LOCALE.po b/packages/intl/locale/pseudo-LOCALE.po
index aba1e728f..737e6d398 100644
--- a/packages/intl/locale/pseudo-LOCALE.po
+++ b/packages/intl/locale/pseudo-LOCALE.po
@@ -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 ""
diff --git a/packages/intl/src/strings.ts b/packages/intl/src/strings.ts
index 67722ff2b..b5bce3913 100644
--- a/packages/intl/src/strings.ts
+++ b/packages/intl/src/strings.ts
@@ -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`
};