mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
import { Platform } from 'react-native';
|
|
import { Directory, File, Paths } from 'expo-file-system';
|
|
import * as IntentLauncher from 'expo-intent-launcher';
|
|
import * as Sharing from 'expo-sharing';
|
|
import { jmapClient } from '../api/jmap-client';
|
|
import { getDownloadUrl } from '../api/blob';
|
|
import type { Attachment, Email } from '../api/types';
|
|
import { useSettingsStore } from '../stores/settings-store';
|
|
import {
|
|
attachmentDownloadFilename,
|
|
emailExportFilename,
|
|
type EmailFilenameOptions,
|
|
} from './download-filename';
|
|
import { getClientCertAlias, secureFetch } from './client-cert';
|
|
import { sniffImageMime } from './email-html';
|
|
|
|
const RFC822 = 'message/rfc822';
|
|
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
|
|
|
|
// Largest inline image we base64-encode in JS memory. Anything bigger is
|
|
// streamed to a cache file and encoded natively so the JS thread doesn't
|
|
// freeze on megabytes of string concatenation.
|
|
const MAX_IN_MEMORY_INLINE_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
|
|
// Temp files handed to viewers / the share sheet older than this are swept.
|
|
const STALE_EXPORT_MS = 24 * 60 * 60 * 1000;
|
|
|
|
// Read the user's filename template + transform preferences for exports.
|
|
function emailFileOptions(): EmailFilenameOptions {
|
|
const s = useSettingsStore.getState();
|
|
return {
|
|
template: s.emailExportTemplate,
|
|
spaceReplacement: s.exportSpaceReplacement,
|
|
lowercase: s.exportLowercase,
|
|
stripDiacritics: s.exportStripDiacritics,
|
|
};
|
|
}
|
|
|
|
function attachmentFileOptions(): EmailFilenameOptions {
|
|
const s = useSettingsStore.getState();
|
|
return {
|
|
template: s.attachmentExportTemplate,
|
|
spaceReplacement: s.exportSpaceReplacement,
|
|
lowercase: s.exportLowercase,
|
|
stripDiacritics: s.exportStripDiacritics,
|
|
};
|
|
}
|
|
|
|
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
|
|
const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
|
|
const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim();
|
|
if (cleaned) return cleaned.slice(0, 120);
|
|
return `attachment.${fallbackExt}`;
|
|
}
|
|
|
|
// ─── Temp-file housekeeping ─────────────────────────────────────────────
|
|
|
|
// Every temp file we hand to another app lives in one sub-directory of the
|
|
// cache so the sweep below only ever touches our own files.
|
|
function exportsDir(): Directory {
|
|
return new Directory(Paths.cache, 'bulwark-exports');
|
|
}
|
|
|
|
function ensureDir(dir: Directory): void {
|
|
try {
|
|
if (!dir.exists) dir.create({ intermediates: true, idempotent: true });
|
|
} catch { /* best effort - the download will surface a real error */ }
|
|
}
|
|
|
|
function deleteQuietly(file: File): void {
|
|
try {
|
|
if (file.exists) file.delete();
|
|
} catch { /* already gone or still in use - the sweep gets it later */ }
|
|
}
|
|
|
|
/**
|
|
* Remove temp files older than a day. Files shared into other apps could not
|
|
* always be deleted right after the share sheet closed (the receiving app may
|
|
* still be reading them), so anything that slipped through is collected here.
|
|
* Runs once per process, lazily before the first export, and is also safe to
|
|
* call at launch.
|
|
*/
|
|
let sweptThisProcess = false;
|
|
export async function sweepStaleExportFiles(maxAgeMs = STALE_EXPORT_MS): Promise<void> {
|
|
if (sweptThisProcess) return;
|
|
sweptThisProcess = true;
|
|
try {
|
|
const dir = exportsDir();
|
|
if (!dir.exists) return;
|
|
const now = Date.now();
|
|
for (const entry of dir.list()) {
|
|
if (!(entry instanceof File)) continue;
|
|
const modified = (entry as { modificationTime?: number | null }).modificationTime;
|
|
const age = typeof modified === 'number' ? now - modified : Number.POSITIVE_INFINITY;
|
|
if (age > maxAgeMs) deleteQuietly(entry);
|
|
}
|
|
} catch { /* housekeeping only */ }
|
|
}
|
|
|
|
// Delete a temp file once the share sheet has resolved. iOS copies the file
|
|
// for the receiving extension, so it can go immediately; Android hands the
|
|
// content URI to the target app, which may still be streaming it, so give it
|
|
// a grace period and leave the rest to the sweep.
|
|
function scheduleTempCleanup(file: File): void {
|
|
if (Platform.OS === 'android') {
|
|
setTimeout(() => deleteQuietly(file), 60_000);
|
|
} else {
|
|
deleteQuietly(file);
|
|
}
|
|
}
|
|
|
|
// expo-sharing only accepts `file://` URLs and rejects `content://` with
|
|
// "Only local file URLs are supported". On Android it then wraps the file
|
|
// itself with its bundled SharingFileProvider before launching the share
|
|
// intent, so every `Sharing.shareAsync` below gets `downloaded.uri` and must
|
|
// NOT be pre-translated to `downloaded.contentUri`. `openWithViewer` is the
|
|
// one exception: it assembles the intent itself, so there it's the reverse —
|
|
// only a content URI is grantable to another app.
|
|
|
|
// Android-only: hand the file to whichever app owns the type (PDF viewer,
|
|
// gallery, video player, ...) rather than to the share sheet. Returns false
|
|
// when the handoff didn't happen so the caller can fall back to sharing —
|
|
// most often because no installed app handles the MIME type, in which case
|
|
// startActivityAsync rejects with ActivityNotFoundException.
|
|
async function openWithViewer(file: File, mimeType: string): Promise<boolean> {
|
|
try {
|
|
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
|
// Read-only grant, scoped to the receiving app for the life of the
|
|
// intent. Deliberately no FLAG_ACTIVITY_NEW_TASK: expo-intent-launcher
|
|
// uses startActivityForResult, and a new task cancels that result.
|
|
data: file.contentUri,
|
|
type: mimeType,
|
|
flags: FLAG_GRANT_READ_URI_PERMISSION,
|
|
});
|
|
return true;
|
|
} catch (e) {
|
|
console.warn('[attachments] no viewer for', mimeType, '- falling back to share:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isUnauthorizedError(err: unknown): boolean {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return /\b401\b|unauthori[sz]ed/i.test(msg);
|
|
}
|
|
|
|
// Routes the download via the client-cert-aware native module when the user
|
|
// has picked a cert, and via expo-file-system's native streaming downloader
|
|
// otherwise. The streaming path scales to large attachments without buffering
|
|
// in JS, so we keep using it as the default.
|
|
//
|
|
// Both paths bypass the JMAP client's fetch wrapper, so the OAuth token has to
|
|
// be refreshed here: proactively before the header is captured, and once
|
|
// reactively when the server still answers 401 (a token that expired between
|
|
// the check and the request).
|
|
async function downloadInto(
|
|
url: string,
|
|
dest: File,
|
|
parent: Directory,
|
|
): Promise<File> {
|
|
await jmapClient.ensureFreshToken();
|
|
const alias = await getClientCertAlias();
|
|
ensureDir(parent);
|
|
const attempt = async (): Promise<File> => {
|
|
if (!alias) {
|
|
// The static returns a separately-typed `FileSystemFile`; we already
|
|
// have a fully-typed `File` referencing the same uri, so we ignore the
|
|
// return value and re-use our `dest` reference for downstream code.
|
|
await File.downloadFileAsync(url, dest, {
|
|
headers: { Authorization: jmapClient.authHeader },
|
|
idempotent: true,
|
|
});
|
|
return dest;
|
|
}
|
|
const response = await secureFetch(url, {
|
|
headers: { Authorization: jmapClient.authHeader },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Download failed: ${response.status}`);
|
|
}
|
|
if (dest.exists) dest.delete();
|
|
const buffer = await response.arrayBuffer();
|
|
dest.create();
|
|
dest.write(new Uint8Array(buffer));
|
|
return dest;
|
|
};
|
|
try {
|
|
return await attempt();
|
|
} catch (err) {
|
|
if (isUnauthorizedError(err) && (await jmapClient.forceRefreshToken())) {
|
|
return attempt();
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** Download a blob into the exports cache directory (caller cleans up). */
|
|
export async function cacheBlobFile(
|
|
blobId: string,
|
|
filename: string,
|
|
mimeType: string,
|
|
accountId?: string,
|
|
): Promise<File> {
|
|
void sweepStaleExportFiles();
|
|
const dir = exportsDir();
|
|
const dest = new File(dir, filename);
|
|
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
|
|
return downloadInto(url, dest, dir);
|
|
}
|
|
|
|
export async function shareAttachment(
|
|
blobId: string,
|
|
name: string | undefined,
|
|
type: string | undefined,
|
|
email?: Email | null,
|
|
// Owning account for blobs shared by another principal (Files app).
|
|
accountId?: string,
|
|
): Promise<void> {
|
|
const filename = email
|
|
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
|
|
: safeAttachmentName(name, type);
|
|
const mimeType = type || 'application/octet-stream';
|
|
const downloaded = await cacheBlobFile(blobId, filename, mimeType, accountId);
|
|
|
|
if (Platform.OS === 'android' && (await openWithViewer(downloaded, mimeType))) {
|
|
// The viewer may keep reading the content URI after the activity result;
|
|
// the daily sweep removes the file.
|
|
return;
|
|
}
|
|
if (!(await Sharing.isAvailableAsync())) {
|
|
throw new Error('Sharing is not available on this device');
|
|
}
|
|
try {
|
|
await Sharing.shareAsync(downloaded.uri, {
|
|
mimeType,
|
|
dialogTitle: filename,
|
|
});
|
|
} finally {
|
|
scheduleTempCleanup(downloaded);
|
|
}
|
|
}
|
|
|
|
// Save-to-disk variant. iOS doesn't expose a user-visible "Downloads" folder,
|
|
// so on both platforms we land the file in the document directory and hand it
|
|
// to the share sheet — which on iOS surfaces "Save to Files" and on Android
|
|
// surfaces the system save dialog. Unlike the 'preview' counterpart
|
|
// (shareAttachment), this one keeps the share sheet on Android as well: "save
|
|
// a copy" is a share-sheet destination, not something a viewer app handles.
|
|
export async function downloadAttachment(
|
|
blobId: string,
|
|
name: string | undefined,
|
|
type: string | undefined,
|
|
email?: Email | null,
|
|
// Owning account for blobs shared by another principal (Files app).
|
|
accountId?: string,
|
|
): Promise<void> {
|
|
const filename = email
|
|
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
|
|
: safeAttachmentName(name, type);
|
|
const mimeType = type || 'application/octet-stream';
|
|
const dest = new File(Paths.document, filename);
|
|
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
|
|
const downloaded = await downloadInto(url, dest, Paths.document);
|
|
if (!(await Sharing.isAvailableAsync())) {
|
|
throw new Error('Sharing is not available on this device');
|
|
}
|
|
await Sharing.shareAsync(downloaded.uri, {
|
|
mimeType,
|
|
dialogTitle: filename,
|
|
});
|
|
}
|
|
|
|
/** Write bytes into the exports cache (caller shares / previews / cleans up). */
|
|
export function writeTempFile(bytes: Uint8Array, filename: string, mimeType?: string): File {
|
|
void sweepStaleExportFiles();
|
|
const dir = exportsDir();
|
|
ensureDir(dir);
|
|
const file = new File(dir, safeAttachmentName(filename, mimeType));
|
|
if (file.exists) file.delete();
|
|
file.create();
|
|
file.write(bytes);
|
|
return file;
|
|
}
|
|
|
|
/**
|
|
* Share an already-materialised local file (zip bundles, extracted parts).
|
|
* `forceSheet` skips the Android viewer handoff and always shows the share
|
|
* sheet - the explicit "Share" action, as opposed to "Open".
|
|
*/
|
|
export async function shareLocalFile(
|
|
file: File,
|
|
mimeType: string,
|
|
dialogTitle?: string,
|
|
opts: { forceSheet?: boolean } = {},
|
|
): Promise<void> {
|
|
if (!opts.forceSheet && Platform.OS === 'android' && (await openWithViewer(file, mimeType))) return;
|
|
if (!(await Sharing.isAvailableAsync())) {
|
|
throw new Error('Sharing is not available on this device');
|
|
}
|
|
try {
|
|
await Sharing.shareAsync(file.uri, { mimeType, dialogTitle: dialogTitle ?? file.name });
|
|
} finally {
|
|
scheduleTempCleanup(file);
|
|
}
|
|
}
|
|
|
|
/** Write bytes to the exports cache and hand them to a viewer / the share sheet. */
|
|
export async function shareBytes(
|
|
bytes: Uint8Array,
|
|
filename: string,
|
|
mimeType: string,
|
|
opts: { forceSheet?: boolean } = {},
|
|
): Promise<void> {
|
|
const file = writeTempFile(bytes, filename, mimeType);
|
|
await shareLocalFile(file, mimeType, filename, opts);
|
|
}
|
|
|
|
/** Share a blob through the share sheet only (no viewer handoff). */
|
|
export async function shareAttachmentViaSheet(
|
|
blobId: string,
|
|
name: string | undefined,
|
|
type: string | undefined,
|
|
email?: Email | null,
|
|
accountId?: string,
|
|
): Promise<void> {
|
|
const filename = email
|
|
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
|
|
: safeAttachmentName(name, type);
|
|
const mimeType = type || 'application/octet-stream';
|
|
const downloaded = await cacheBlobFile(blobId, filename, mimeType, accountId);
|
|
await shareLocalFile(downloaded, mimeType, filename, { forceSheet: true });
|
|
}
|
|
|
|
async function authedBlobFetch(url: string): Promise<Response> {
|
|
await jmapClient.ensureFreshToken();
|
|
let r = await secureFetch(url, { headers: { Authorization: jmapClient.authHeader } });
|
|
if (r.status === 401 && (await jmapClient.forceRefreshToken())) {
|
|
r = await secureFetch(url, { headers: { Authorization: jmapClient.authHeader } });
|
|
}
|
|
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
|
|
return r;
|
|
}
|
|
|
|
export async function fetchRawEmail(blobId: string, accountId?: string): Promise<string> {
|
|
const url = getDownloadUrl(blobId, 'email.eml', RFC822, accountId);
|
|
const r = await authedBlobFetch(url);
|
|
return r.text();
|
|
}
|
|
|
|
/** Fetch a blob's bytes with the same auth/refresh handling as the downloads. */
|
|
export async function fetchBlobBytes(
|
|
blobId: string,
|
|
name: string | undefined,
|
|
type: string | undefined,
|
|
accountId?: string,
|
|
): Promise<Uint8Array> {
|
|
const url = getDownloadUrl(blobId, name ?? 'blob', type ?? 'application/octet-stream', accountId);
|
|
const r = await authedBlobFetch(url);
|
|
return new Uint8Array(await r.arrayBuffer());
|
|
}
|
|
|
|
export function bytesToBase64(bytes: Uint8Array): string {
|
|
let binary = '';
|
|
const chunk = 0x8000;
|
|
for (let i = 0; i < bytes.length; i += chunk) {
|
|
binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
|
|
}
|
|
return global.btoa ? global.btoa(binary) : btoa(binary);
|
|
}
|
|
|
|
function base64PrefixBytes(b64: string, count: number): Uint8Array {
|
|
const slice = b64.slice(0, Math.ceil(count / 3) * 4);
|
|
try {
|
|
const bin = global.atob ? global.atob(slice) : atob(slice);
|
|
const out = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
return out;
|
|
} catch {
|
|
return new Uint8Array(0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve an inline (cid) image part to a `data:` URI for the body WebView.
|
|
* Small parts are fetched into memory; larger ones are streamed to a cache
|
|
* file and base64-encoded natively (the JS thread would otherwise stall for
|
|
* seconds on a multi-megabyte photo). The MIME is sniffed from the bytes so
|
|
* `application/octet-stream` image parts still render (#543).
|
|
*/
|
|
export async function fetchInlineImageDataUri(
|
|
att: Pick<Attachment, 'blobId' | 'name' | 'type' | 'size'>,
|
|
accountId?: string,
|
|
): Promise<string | null> {
|
|
if (!att.blobId) return null;
|
|
if (!att.size || att.size <= MAX_IN_MEMORY_INLINE_IMAGE_BYTES) {
|
|
const buf = await jmapClient.fetchBlobArrayBuffer(att.blobId, att.name, att.type, accountId);
|
|
const bytes = new Uint8Array(buf);
|
|
const mime = sniffImageMime(bytes, att.type);
|
|
return `data:${mime};base64,${bytesToBase64(bytes)}`;
|
|
}
|
|
const file = await cacheBlobFile(
|
|
att.blobId,
|
|
`inline-${att.blobId.replace(/[^a-z0-9]/gi, '_')}`,
|
|
att.type || 'application/octet-stream',
|
|
accountId,
|
|
);
|
|
try {
|
|
const b64 = await file.base64();
|
|
const mime = sniffImageMime(base64PrefixBytes(b64, 16), att.type);
|
|
return `data:${mime};base64,${b64}`;
|
|
} finally {
|
|
deleteQuietly(file);
|
|
}
|
|
}
|
|
|
|
function safeFilename(subject: string | undefined): string {
|
|
const base = (subject ?? 'email').replace(/[^\w\s-]/g, '').trim().slice(0, 60) || 'email';
|
|
return `${base}.eml`;
|
|
}
|
|
|
|
export async function shareEmailEml(
|
|
blobId: string,
|
|
email?: Email | null,
|
|
subjectFallback?: string,
|
|
// Owning account when the message lives in a shared/group mailbox.
|
|
accountId?: string,
|
|
): Promise<void> {
|
|
const filename = email
|
|
? emailExportFilename(email, emailFileOptions())
|
|
: safeFilename(subjectFallback);
|
|
const downloaded = await cacheBlobFile(blobId, filename, RFC822, accountId);
|
|
if (!(await Sharing.isAvailableAsync())) {
|
|
throw new Error('Sharing is not available on this device');
|
|
}
|
|
try {
|
|
await Sharing.shareAsync(downloaded.uri, {
|
|
mimeType: RFC822,
|
|
dialogTitle: 'Share email',
|
|
UTI: 'public.email-message',
|
|
});
|
|
} finally {
|
|
scheduleTempCleanup(downloaded);
|
|
}
|
|
}
|