mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(email): on-demand attachment download, search clear, per-category read prefs
- Download search-result attachments on demand before opening (fixes Linux where xdg-open reports success on a missing file so the old open-then- download fallback never fired) - Add a clear button to the search box that dismisses search and returns to the inbox - Remove the read toggle from the Important section - Replace the Everything else "Read" toggle with a "Mark as read" preference: turning it on marks all current and future "Everything else" mail read; turning it off only stops auto-reading future mail, leaving current threads untouched
This commit is contained in:
parent
ab9bce6203
commit
063f6892a8
7 changed files with 269 additions and 50 deletions
|
|
@ -20,29 +20,48 @@ const MAX_MAX_EMAILS = 5000;
|
|||
|
||||
interface GmailSyncConfig {
|
||||
maxEmails: number;
|
||||
/**
|
||||
* When true, threads the classifier labels as "Everything else" are
|
||||
* automatically marked read as they arrive during sync. Toggling this on
|
||||
* only governs FUTURE mail; existing threads are marked read separately at
|
||||
* toggle time (and are never touched when this is turned off).
|
||||
*/
|
||||
autoReadEverythingElse?: boolean;
|
||||
}
|
||||
|
||||
function clampMaxEmails(value: number): number {
|
||||
return Math.max(MIN_MAX_EMAILS, Math.min(MAX_MAX_EMAILS, Math.floor(value)));
|
||||
}
|
||||
|
||||
function readConfig(): Partial<GmailSyncConfig> {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
||||
return JSON.parse(raw) as Partial<GmailSyncConfig>;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[GmailSyncConfig] Failed to read gmail_sync.json:', err);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function writeConfig(config: Partial<GmailSyncConfig>): void {
|
||||
const configDir = path.dirname(CONFIG_FILE);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the configured max email count for the onboarding/full sync.
|
||||
* Falls back to {@link DEFAULT_MAX_EMAILS} when the file is missing, malformed,
|
||||
* or holds an out-of-range value.
|
||||
*/
|
||||
export function getMaxEmails(): number {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Partial<GmailSyncConfig>;
|
||||
const value = Number(parsed?.maxEmails);
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
return clampMaxEmails(value);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[GmailSyncConfig] Failed to read gmail_sync.json:', err);
|
||||
const value = Number(readConfig()?.maxEmails);
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
return clampMaxEmails(value);
|
||||
}
|
||||
return DEFAULT_MAX_EMAILS;
|
||||
}
|
||||
|
|
@ -52,10 +71,18 @@ export function getMaxEmails(): number {
|
|||
* clamped into the supported range before writing.
|
||||
*/
|
||||
export function setMaxEmails(maxEmails: number): void {
|
||||
const configDir = path.dirname(CONFIG_FILE);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
const config: GmailSyncConfig = { maxEmails: clampMaxEmails(maxEmails) };
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
writeConfig({ ...readConfig(), maxEmails: clampMaxEmails(maxEmails) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether newly-arriving "Everything else" threads should be auto-marked read
|
||||
* during sync. Defaults to false (off) when unset.
|
||||
*/
|
||||
export function getAutoReadEverythingElse(): boolean {
|
||||
return readConfig()?.autoReadEverythingElse === true;
|
||||
}
|
||||
|
||||
/** Persist the "auto-mark Everything else as read" preference. */
|
||||
export function setAutoReadEverythingElse(enabled: boolean): void {
|
||||
writeConfig({ ...readConfig(), autoReadEverythingElse: enabled });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { google, gmail_v1 as gmail } from 'googleapis';
|
|||
import { NodeHtmlMarkdown } from 'node-html-markdown'
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { getMaxEmails } from '../config/gmail_sync_config.js';
|
||||
import { getMaxEmails, getAutoReadEverythingElse } from '../config/gmail_sync_config.js';
|
||||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
|
|
@ -303,6 +303,8 @@ export interface GmailThreadSnapshot {
|
|||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
savedPath: string;
|
||||
messageId?: string;
|
||||
attachmentId?: string;
|
||||
}>;
|
||||
messageIdHeader?: string;
|
||||
isDraft?: boolean;
|
||||
|
|
@ -458,6 +460,10 @@ interface ExtractedAttachment {
|
|||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
savedPath: string;
|
||||
// Gmail identifiers needed to fetch the attachment on demand (e.g. when a
|
||||
// search result's attachment hasn't been downloaded to disk yet).
|
||||
messageId?: string;
|
||||
attachmentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -494,6 +500,8 @@ function extractAttachments(msgId: string, payload: gmail.Schema$MessagePart, ht
|
|||
mimeType: part.mimeType ?? undefined,
|
||||
sizeBytes: typeof part.body?.size === 'number' ? part.body.size : undefined,
|
||||
savedPath: `gmail_sync/attachments/${safeName}`,
|
||||
messageId: msgId,
|
||||
attachmentId: attId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -921,6 +929,23 @@ async function buildAndCacheSnapshot(
|
|||
console.warn(`[Gmail] classify failed for ${threadId}:`, err);
|
||||
}
|
||||
|
||||
// Auto-mark newly-classified "Everything else" mail as read when the user
|
||||
// has opted in. Governs future mail only — existing threads are handled at
|
||||
// toggle time via markSectionRead.
|
||||
if (snapshot.importance === 'other' && snapshot.unread && getAutoReadEverythingElse()) {
|
||||
try {
|
||||
await gmailClient.users.threads.modify({
|
||||
userId: 'me',
|
||||
id: threadId,
|
||||
requestBody: { removeLabelIds: ['UNREAD'] },
|
||||
});
|
||||
for (const m of snapshot.messages) m.unread = false;
|
||||
snapshot.unread = false;
|
||||
} catch (err) {
|
||||
console.warn(`[Gmail] auto-read (Everything else) failed for ${threadId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (threadData.historyId) {
|
||||
writeCachedSnapshot(threadId, threadData.historyId, snapshot);
|
||||
}
|
||||
|
|
@ -1050,6 +1075,83 @@ async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string,
|
|||
return null;
|
||||
}
|
||||
|
||||
export interface DownloadAttachmentResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an attachment referenced by a snapshot exists on disk, downloading it
|
||||
* on demand when it doesn't. Inbox attachments are saved during sync, but
|
||||
* search results build snapshots without downloading, so opening one of their
|
||||
* attachments needs this. `savedPath` is the workspace-relative path stored on
|
||||
* the attachment; `attachmentId` (when supplied) is tried first, falling back
|
||||
* to re-fetching the message and locating the part by filename — attachment ids
|
||||
* can go stale on a cached snapshot, whereas the file name is stable.
|
||||
*/
|
||||
export async function downloadAttachment(args: {
|
||||
messageId: string;
|
||||
savedPath: string;
|
||||
attachmentId?: string;
|
||||
}): Promise<DownloadAttachmentResult> {
|
||||
try {
|
||||
const { messageId, savedPath, attachmentId } = args;
|
||||
if (!messageId || !savedPath) return { ok: false, error: 'Missing attachment reference.' };
|
||||
|
||||
const absPath = path.join(WorkDir, savedPath);
|
||||
if (fs.existsSync(absPath)) return { ok: true };
|
||||
|
||||
const gmailClient = await getGmailClientOrThrow();
|
||||
const dir = path.dirname(absPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const fetchData = async (attId: string): Promise<string | null> => {
|
||||
const res = await gmailClient.users.messages.attachments.get({
|
||||
userId: 'me',
|
||||
messageId,
|
||||
id: attId,
|
||||
});
|
||||
return res.data.data ?? null;
|
||||
};
|
||||
|
||||
let data: string | null = null;
|
||||
if (attachmentId) {
|
||||
try {
|
||||
data = await fetchData(attachmentId);
|
||||
} catch (err) {
|
||||
console.warn(`[Gmail] attachment fetch by id failed for ${messageId}, retrying by filename:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
// Re-fetch the message and locate the attachment part whose derived
|
||||
// saved name matches the requested savedPath.
|
||||
const wanted = path.basename(savedPath);
|
||||
const msg = await gmailClient.users.messages.get({ userId: 'me', id: messageId, format: 'full' });
|
||||
let foundAttId: string | undefined;
|
||||
const walk = (part: gmail.Schema$MessagePart): void => {
|
||||
if (foundAttId) return;
|
||||
const fn = part.filename;
|
||||
const attId = part.body?.attachmentId;
|
||||
if (fn && attId && `${messageId}_${cleanFilename(fn)}` === wanted) {
|
||||
foundAttId = attId;
|
||||
return;
|
||||
}
|
||||
if (part.parts) for (const sub of part.parts) walk(sub);
|
||||
};
|
||||
if (msg.data.payload) walk(msg.data.payload);
|
||||
if (!foundAttId) return { ok: false, error: 'Attachment not found in message.' };
|
||||
data = await fetchData(foundAttId);
|
||||
}
|
||||
|
||||
if (!data) return { ok: false, error: 'Attachment had no data.' };
|
||||
fs.writeFileSync(absPath, Buffer.from(data, 'base64'));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sync Logic ---
|
||||
|
||||
async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string): Promise<SyncedThread | null> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue