Skip to content
This repository was archived by the owner on Jul 30, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@
"vitest": "^4.1.6"
},
"dependencies": {
"@e4a/pg-js": "^1.7.1"
"@e4a/pg-js": "^1.8.0"
}
}
3 changes: 3 additions & 0 deletions public/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@
"sentCopyError": {
"message": "Failed to save the sent copy of your encrypted message."
},
"uploadSessionExpired": {
"message": "The encrypted upload session expired before it could finish. Please send the message again."
},
"decryptingButton": {
"message": "Decrypting..."
}
Expand Down
3 changes: 3 additions & 0 deletions public/_locales/nl/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@
"sentCopyError": {
"message": "Het opslaan van de verzonden kopie van je versleutelde bericht is mislukt."
},
"uploadSessionExpired": {
"message": "De versleutelde uploadsessie is verlopen voordat deze kon worden voltooid. Verstuur het bericht opnieuw."
},
"decryptingButton": {
"message": "Ontsleutelen..."
}
Expand Down
102 changes: 96 additions & 6 deletions src/background/background.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
/// <reference path="../types/thunderbird.d.ts" />

import { buildMime, extractCiphertext, extractUploadUuid, injectMimeHeaders } from "@e4a/pg-js";
import { composeTabs, decryptedMessages, persistEncryptState, restoreEncryptState } from "./state";
import { buildMime, extractCiphertext, extractUploadUuid, injectMimeHeaders, resumeUpload, UploadSessionExpiredError } from "@e4a/pg-js";
import {
composeTabs,
decryptedMessages,
persistEncryptState,
restoreEncryptState,
inFlightUploads,
recordInFlightUpload,
clearInFlightUpload,
persistInFlightUploads,
loadInFlightUploads,
} from "./state";
import { PKG_URL, CRYPTIFY_URL, POSTGUARD_WEBSITE_URL } from "../lib/pkg-client";
import { toBase64, fromBase64 } from "../lib/encoding";
import { toEmail, EMAIL_ATTRIBUTE_TYPE, findHtmlBody } from "../lib/utils";
Expand Down Expand Up @@ -54,6 +64,10 @@ const pendingCryptoPopups = new Map<
number,
{
data: CryptoPopupInitData;
/** Compose tab that owns the popup, when the operation is `encrypt`.
* Used to associate `cryptoPopupUploadInit` callbacks with the
* right tab's in-flight-upload record. Absent on decrypt popups. */
composeTabId?: number;
resolve: (result: CryptoPopupResult) => void;
reject: (err: Error) => void;
}
Expand Down Expand Up @@ -119,6 +133,12 @@ browser.runtime.onMessage.addListener(
msg.windowId as number | undefined ?? sender.tab?.windowId,
msg.error as string
));
case "cryptoPopupUploadInit":
return Promise.resolve(handleCryptoPopupUploadInit(
msg.windowId as number | undefined ?? sender.tab?.windowId,
msg.uuid as string,
msg.recoveryToken as string
));
case "decryptMessage":
return handleDecryptMessage(msg.messageId as number);
default:
Expand Down Expand Up @@ -155,7 +175,9 @@ browser.compose.onAfterSend.addListener(async (tab, sendInfo) => {
notifyError("sentCopyError");
} finally {
composeTabs.delete(tab.id);
clearInFlightUpload(tab.id);
persistEncryptState().catch(console.warn);
persistInFlightUploads().catch(console.warn);
}
});

Expand Down Expand Up @@ -230,6 +252,43 @@ for (const tab of existingTabs) {
}
}

// Probe any in-flight Cryptify uploads that survived a background
// restart. pg-js doesn't yet expose a "continue createEnvelope from a
// rehydrated FileState" entry point, so we cannot transparently finish
// the previous upload — but we can detect a definitively-dead session
// (`UploadSessionExpiredError`) and surface it to the user instead of
// silently dropping the record on the next send attempt.
checkInFlightUploadsOnStartup().catch((e) =>
console.warn("[PostGuard] in-flight upload probe failed:", e)
);

async function checkInFlightUploadsOnStartup() {
if (!CRYPTIFY_URL) return;
const records = await loadInFlightUploads();
if (records.length === 0) return;
let sawExpired = false;
for (const { tabId, record } of records) {
try {
await resumeUpload(CRYPTIFY_URL, record.uuid, record.recoveryToken);
// Session still alive on the server side. Keep the record so a
// future SDK release with a continue-from-FileState entry point
// can rehydrate it.
inFlightUploads.set(tabId, record);
} catch (e) {
if (e instanceof UploadSessionExpiredError) {
sawExpired = true;
// The session is gone; drop the record.
} else {
// Transient network error — keep the record for the next probe.
console.warn("[PostGuard] resumeUpload probe failed:", e);
inFlightUploads.set(tabId, record);
}
}
}
await persistInFlightUploads();
if (sawExpired) notifyError("uploadSessionExpired");
}

async function shouldEncrypt(tabId: number): Promise<boolean> {
try {
const details = await browser.compose.getComposeDetails(tabId);
Expand Down Expand Up @@ -284,7 +343,10 @@ function keepAlive(name: string, promise: Promise<unknown>) {

// --- Crypto popup: opens popup that owns encrypt/decrypt ---

async function openCryptoPopup(data: CryptoPopupInitData): Promise<CryptoPopupResult> {
async function openCryptoPopup(
data: CryptoPopupInitData,
composeTabId?: number,
): Promise<CryptoPopupResult> {
const { promise, resolve, reject } = Promise.withResolvers<CryptoPopupResult>();

const popup = await browser.windows.create({
Expand All @@ -297,7 +359,7 @@ async function openCryptoPopup(data: CryptoPopupInitData): Promise<CryptoPopupRe
const popupId = popup.id;

// Register IMMEDIATELY after create, before the popup script can send cryptoPopupInit
pendingCryptoPopups.set(popupId, { data, resolve, reject });
pendingCryptoPopups.set(popupId, { data, composeTabId, resolve, reject });

const closeListener = (closedId: number) => {
if (closedId === popupId) {
Expand Down Expand Up @@ -333,6 +395,10 @@ function handleCryptoPopupDone(
if (windowId == null) return;
const pending = pendingCryptoPopups.get(windowId);
if (!pending) return;
if (pending.composeTabId != null) {
clearInFlightUpload(pending.composeTabId);
persistInFlightUploads().catch(console.warn);
}
pending.resolve(result);
pendingCryptoPopups.delete(windowId);
}
Expand All @@ -344,10 +410,31 @@ function handleCryptoPopupError(
if (windowId == null) return;
const pending = pendingCryptoPopups.get(windowId);
if (!pending) return;
if (pending.composeTabId != null) {
clearInFlightUpload(pending.composeTabId);
persistInFlightUploads().catch(console.warn);
}
pending.reject(new Error(error));
pendingCryptoPopups.delete(windowId);
}

/** Popup → background hop fired from pg-js's `onUploadInit` callback,
* once the Cryptify session exists but before any chunk PUT. Records
* `{uuid, recoveryToken}` against the popup's owning compose tab so
* the session can be queried via `resumeUpload` after a background
* suspension or Thunderbird restart. */
function handleCryptoPopupUploadInit(
windowId: number | undefined,
uuid: string,
recoveryToken: string,
) {
if (windowId == null || !uuid || !recoveryToken) return;
const pending = pendingCryptoPopups.get(windowId);
if (!pending || pending.composeTabId == null) return;
recordInFlightUpload(pending.composeTabId, uuid, recoveryToken);
persistInFlightUploads().catch(console.warn);
}

// --- onBeforeSend: encryption hook ---

async function handleBeforeSend(tab: { id: number }, details: any) {
Expand Down Expand Up @@ -451,7 +538,10 @@ async function handleBeforeSend(tab: { id: number }, details: any) {
}

// Delegate encryption to popup — popup creates its own pg instance,
// renders Yivi QR, encrypts, and returns the envelope
// renders Yivi QR, encrypts, and returns the envelope. The popup
// also forwards pg-js's `onUploadInit` callback as a
// `cryptoPopupUploadInit` message, which we associate back to this
// compose tab via the `composeTabId` thread.
const result = await openCryptoPopup({
operation: "encrypt",
config: {
Expand All @@ -465,7 +555,7 @@ async function handleBeforeSend(tab: { id: number }, details: any) {
from: details.from,
websiteUrl: POSTGUARD_WEBSITE_URL,
senderAttributes,
}) as EncryptPopupResult;
}, tab.id) as EncryptPopupResult;

// pg-js 1.1.0+ may already decide to skip the attachment in tier 3.
// We additionally enforce a stricter 5 MB local cap for Thunderbird
Expand Down
67 changes: 67 additions & 0 deletions src/background/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,70 @@ export async function restoreEncryptState(): Promise<void> {
console.warn("[PostGuard] Failed to restore encrypt state:", e);
}
}

// --- In-flight Cryptify upload tracking ---
// Captured from pg-js's `onUploadInit` callback on the popup side and
// persisted here so a background suspension / Thunderbird restart can
// still query the upload session via `resumeUpload(uuid, recoveryToken)`.
// Records are cleared on successful send (or popup error) and pruned on
// startup against `MAX_IN_FLIGHT_AGE_MS`.

export interface InFlightUpload {
uuid: string;
recoveryToken: string;
/** Millisecond timestamp captured at `onUploadInit`. Used to drop
* records older than the Cryptify session TTL on restart. */
startedAt: number;
}

export const inFlightUploads = new Map<number, InFlightUpload>();

const IN_FLIGHT_KEY = "inFlightUploads";

/** Drop records older than this on restart instead of querying Cryptify.
* Cryptify's default session TTL is hours; 24h is a generous upper
* bound that avoids hammering the server with confirmed-dead sessions. */
export const MAX_IN_FLIGHT_AGE_MS = 24 * 60 * 60 * 1000;

export async function persistInFlightUploads(): Promise<void> {
if (inFlightUploads.size === 0) {
await browser.storage.local.remove(IN_FLIGHT_KEY);
return;
}
const out: Record<string, InFlightUpload> = {};
for (const [tabId, record] of inFlightUploads) {
out[String(tabId)] = record;
}
await browser.storage.local.set({ [IN_FLIGHT_KEY]: out });
}

/** Read persisted records and return the ones still within the
* freshness window. Stale records are dropped without a network call.
* Callers are expected to feed the returned list into `resumeUpload` to
* classify each as alive / expired and react accordingly. */
export async function loadInFlightUploads(): Promise<Array<{ tabId: number; record: InFlightUpload }>> {
try {
const data = await browser.storage.local.get(IN_FLIGHT_KEY);
const saved = data[IN_FLIGHT_KEY] as Record<string, InFlightUpload> | undefined;
if (!saved) return [];
const now = Date.now();
const fresh: Array<{ tabId: number; record: InFlightUpload }> = [];
for (const [tabIdStr, record] of Object.entries(saved)) {
if (now - record.startedAt <= MAX_IN_FLIGHT_AGE_MS) {
fresh.push({ tabId: Number(tabIdStr), record });
}
}
return fresh;
} catch (e) {
console.warn("[PostGuard] Failed to load in-flight uploads:", e);
return [];
}
}

export function recordInFlightUpload(tabId: number, uuid: string, recoveryToken: string): void {
inFlightUploads.set(tabId, { uuid, recoveryToken, startedAt: Date.now() });
}

export function clearInFlightUpload(tabId: number): void {
inFlightUploads.delete(tabId);
}
29 changes: 26 additions & 3 deletions src/pages/yivi-popup/yivi-popup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference path="../../types/thunderbird.d.ts" />
export {};

import { PostGuard } from "@e4a/pg-js";
import { PostGuard, UploadSessionExpiredError } from "@e4a/pg-js";
import type { DecryptDataResult, DecryptFileResult } from "@e4a/pg-js";
import { toBase64, fromBase64 } from "../../lib/encoding";
import type {
Expand Down Expand Up @@ -76,7 +76,13 @@ async function init() {
setTimeout(() => browser.windows.remove(windowId), 750);
} catch (e) {
console.error("[PostGuard] Crypto popup error:", e);
const message = e instanceof Error ? e.message : "Operation failed.";
// pg-js surfaces a dead Cryptify session as `UploadSessionExpiredError`.
// The generic encryption-error wording is misleading here — the local
// encryption succeeded, the server side dropped the upload. Use the
// dedicated i18n string so the user knows to start a fresh send.
const message = e instanceof UploadSessionExpiredError
? browser.i18n.getMessage("uploadSessionExpired")
: e instanceof Error ? e.message : "Operation failed.";
await browser.runtime.sendMessage({
type: "cryptoPopupError",
windowId,
Expand Down Expand Up @@ -115,12 +121,29 @@ async function handleEncrypt(pg: PostGuard, data: EncryptPopupData, windowId: nu
data: mimeData,
});

// Create encrypted email envelope
// Create encrypted email envelope. `onUploadInit` fires once, after
// Cryptify's `upload_init` resolves and before any chunk PUT, so the
// background can persist `{uuid, recoveryToken}` against this compose
// tab from the moment the session exists. The callback runs inside
// pg-js's upload-stream start handler — a throw would abort the
// upload — so we fire-and-forget the sendMessage.
const envelope = await pg.email.createEnvelope({
sealed,
from: data.from,
websiteUrl: data.websiteUrl,
senderAttributes: data.senderAttributes?.map((attr) => attr.v),
onUploadInit: ({ uuid, recoveryToken }) => {
browser.runtime
.sendMessage({
type: "cryptoPopupUploadInit",
windowId,
uuid,
recoveryToken,
})
.catch((err: unknown) => {
console.warn("[PostGuard Popup] cryptoPopupUploadInit send failed:", err);
});
},
});

// pg-js 1.1.0+: envelope.attachment is null in tier 3 (the encrypted
Expand Down
Loading