diff --git a/apps/web/src/dialogs/InboxHistoryDialog.tsx b/apps/web/src/dialogs/InboxHistoryDialog.tsx
new file mode 100644
index 000000000..f2fc7a780
--- /dev/null
+++ b/apps/web/src/dialogs/InboxHistoryDialog.tsx
@@ -0,0 +1,267 @@
+/*
+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 { useState } from "react";
+import { usePromise } from "@notesnook/common";
+import { formatDate, 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";
+
+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);
+ if (result.status !== "pending") {
+ result.refresh();
+ }
+ }
+
+ return (
+
+ );
+ }
+);
diff --git a/apps/web/src/dialogs/settings/inbox-settings.ts b/apps/web/src/dialogs/settings/inbox-settings.ts
index bdec99565..34288e302 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 "../InboxHistoryDialog";
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/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..60cbb4366 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.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..c93cc5b3a
--- /dev/null
+++ b/packages/core/src/collections/inbox-items-history.ts
@@ -0,0 +1,77 @@
+/*
+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]);
+ }
+
+ 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/intl/locale/en.po b/packages/intl/locale/en.po
index daa93beaf..3dd7a2fb3 100644
--- a/packages/intl/locale/en.po
+++ b/packages/intl/locale/en.po
@@ -2154,6 +2154,10 @@ msgstr "Date format"
msgid "Date modified"
msgstr "Date modified"
+#: src/strings.ts:2747
+msgid "Date synced"
+msgstr "Date synced"
+
#: src/strings.ts:2044
msgid "Date uploaded"
msgstr "Date uploaded"
@@ -2307,6 +2311,10 @@ msgstr "Desktop app"
msgid "Desktop integration"
msgstr "Desktop integration"
+#: src/strings.ts:2746
+msgid "Details"
+msgstr "Details"
+
#: src/strings.ts:871
msgid "Did you save recovery key?"
msgstr "Did you save recovery key?"
@@ -2911,6 +2919,10 @@ msgstr "Faced an issue or have a suggestion? Click here to create a bug report"
msgid "Failed"
msgstr "Failed"
+#: src/strings.ts:2748
+msgid "Failed inbox items"
+msgstr "Failed inbox items"
+
#: src/strings.ts:2653
msgid "Failed to attach file"
msgstr "Failed to attach file"
@@ -4360,6 +4372,10 @@ msgstr "No downloads in progress."
msgid "No encryption key found"
msgstr "No encryption key found"
+#: src/strings.ts:2750
+msgid "No failed inbox items"
+msgstr "No failed inbox items"
+
#: src/strings.ts:1667
msgid "No headings found"
msgstr "No headings found"
@@ -6281,6 +6297,10 @@ msgstr "shortcuts"
msgid "Shortcuts"
msgstr "Shortcuts"
+#: src/strings.ts:2751
+msgid "Show"
+msgstr "Show"
+
#: src/strings.ts:96
msgid "Sign up"
msgstr "Sign up"
@@ -7405,6 +7425,10 @@ msgstr "View and manage inbox API keys"
msgid "View and share debug logs"
msgstr "View and share debug logs"
+#: src/strings.ts:2749
+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..ae4c03c68 100644
--- a/packages/intl/locale/pseudo-LOCALE.po
+++ b/packages/intl/locale/pseudo-LOCALE.po
@@ -2143,6 +2143,10 @@ msgstr ""
msgid "Date modified"
msgstr ""
+#: src/strings.ts:2747
+msgid "Date synced"
+msgstr ""
+
#: src/strings.ts:2044
msgid "Date uploaded"
msgstr ""
@@ -2296,6 +2300,10 @@ msgstr ""
msgid "Desktop integration"
msgstr ""
+#: src/strings.ts:2746
+msgid "Details"
+msgstr ""
+
#: src/strings.ts:871
msgid "Did you save recovery key?"
msgstr ""
@@ -2900,6 +2908,10 @@ msgstr ""
msgid "Failed"
msgstr ""
+#: src/strings.ts:2748
+msgid "Failed inbox items"
+msgstr ""
+
#: src/strings.ts:2653
msgid "Failed to attach file"
msgstr ""
@@ -4340,6 +4352,10 @@ msgstr ""
msgid "No encryption key found"
msgstr ""
+#: src/strings.ts:2750
+msgid "No failed inbox items"
+msgstr ""
+
#: src/strings.ts:1667
msgid "No headings found"
msgstr ""
@@ -6247,6 +6263,10 @@ msgstr ""
msgid "Shortcuts"
msgstr ""
+#: src/strings.ts:2751
+msgid "Show"
+msgstr ""
+
#: src/strings.ts:96
msgid "Sign up"
msgstr ""
@@ -7355,6 +7375,10 @@ msgstr ""
msgid "View and share debug logs"
msgstr ""
+#: src/strings.ts:2749
+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..eced6fba2 100644
--- a/packages/intl/src/strings.ts
+++ b/packages/intl/src/strings.ts
@@ -2744,5 +2744,11 @@ 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`
};