638 lines
21 KiB
TypeScript
638 lines
21 KiB
TypeScript
import { google, type Auth } from 'googleapis';
|
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'fs';
|
|
import { dirname, resolve } from 'path';
|
|
import { homedir } from 'os';
|
|
import type { v1 } from '@google-cloud/pubsub';
|
|
import type { GmailConfig } from '../config/schema.js';
|
|
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
|
import { parseInterval } from './heartbeat.js';
|
|
import { sanitizeHtml } from '../utils/html.js';
|
|
import { auditLogger } from '../audit/index.js';
|
|
|
|
/** Minimal interface for the parts of ChannelRegistry we need. */
|
|
interface ChannelLookup {
|
|
get(name: string): { send(peerId: string, message: OutboundMessage): Promise<void> } | undefined;
|
|
}
|
|
|
|
/** Parsed email information used for template rendering. */
|
|
interface EmailInfo {
|
|
id: string;
|
|
from: string;
|
|
to: string;
|
|
subject: string;
|
|
snippet: string;
|
|
date: string;
|
|
labels: string[];
|
|
}
|
|
|
|
/** Pub/Sub push notification payload from Google. */
|
|
interface PubSubNotification {
|
|
emailAddress: string;
|
|
historyId: string;
|
|
}
|
|
|
|
const DEFAULT_TOPIC_ID = 'gmail-push';
|
|
|
|
// Watch expires after ~7 days; renew at 6 days (in ms).
|
|
const WATCH_RENEWAL_MS = 6 * 24 * 60 * 60 * 1000;
|
|
|
|
/**
|
|
* GmailWatcher monitors a Gmail inbox for new messages and forwards them
|
|
* as InboundMessages via the channel adapter pattern.
|
|
*
|
|
* Supports two modes:
|
|
* - **Pub/Sub push**: Google sends push notifications when new emails arrive.
|
|
* Requires a POST /gmail/push route on the gateway.
|
|
* - **Polling fallback**: Periodically polls Gmail History API for changes.
|
|
*
|
|
* Authentication uses OAuth2 with a stored refresh token.
|
|
*/
|
|
export class GmailWatcher implements ChannelAdapter {
|
|
readonly name = 'gmail';
|
|
private _status: ChannelStatus = 'disconnected';
|
|
private messageHandler?: (msg: InboundMessage) => void;
|
|
private oauth2Client?: Auth.OAuth2Client;
|
|
private lastHistoryId?: string;
|
|
private pollTimer?: ReturnType<typeof setInterval>;
|
|
private watchTimer?: ReturnType<typeof setInterval>;
|
|
private pullTimer?: ReturnType<typeof setInterval>;
|
|
private pubsubSubscriber?: v1.SubscriberClient;
|
|
private pullInFlight = false;
|
|
private readonly config: NonNullable<GmailConfig>;
|
|
private googleProjectId?: string;
|
|
|
|
constructor(
|
|
config: NonNullable<GmailConfig>,
|
|
private readonly channelLookup: ChannelLookup,
|
|
) {
|
|
this.config = config;
|
|
}
|
|
|
|
get status(): ChannelStatus {
|
|
return this._status;
|
|
}
|
|
|
|
async connect(): Promise<void> {
|
|
this._status = 'connecting';
|
|
|
|
try {
|
|
this.oauth2Client = await this.authorize();
|
|
} catch (error) {
|
|
const errMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
console.error(`GmailWatcher: Authorization failed — ${errMsg}`);
|
|
console.error('GmailWatcher: Run "flynn gmail-auth" to set up Gmail credentials.');
|
|
this._status = 'error';
|
|
return;
|
|
}
|
|
|
|
// Set up Gmail push watch (Pub/Sub). Polling is always enabled.
|
|
if (!this.config.disable_push) {
|
|
try {
|
|
await this.setupWatch();
|
|
} catch (error) {
|
|
const errMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
const hint = this.buildWatchErrorHint(errMsg);
|
|
console.warn(`GmailWatcher: Watch setup failed (will use polling only) — ${errMsg}${hint}`);
|
|
}
|
|
} else {
|
|
const configured = (this.config.pubsub_topic ?? process.env.FLYNN_GMAIL_PUBSUB_TOPIC ?? '').trim();
|
|
if (configured) {
|
|
console.log('GmailWatcher: Push disabled (disable_push=true)');
|
|
}
|
|
}
|
|
|
|
// Set up Pub/Sub pull subscription (optional).
|
|
try {
|
|
await this.setupPullSubscription();
|
|
} catch (error) {
|
|
const errMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
console.warn(`GmailWatcher: Pull setup failed (will continue without pull) — ${errMsg}`);
|
|
}
|
|
|
|
// Start polling fallback
|
|
const pollMs = parseInterval(this.config.poll_interval ?? '300s');
|
|
this.pollTimer = setInterval(() => {
|
|
this.pollForNewMessages().catch((err) => {
|
|
console.error('GmailWatcher: Poll error —', err instanceof Error ? err.message : err);
|
|
});
|
|
}, pollMs);
|
|
|
|
this._status = 'connected';
|
|
|
|
const modes: string[] = [];
|
|
const pushConfigured = Boolean((this.config.pubsub_topic ?? process.env.FLYNN_GMAIL_PUBSUB_TOPIC ?? '').trim());
|
|
const pullConfigured = Boolean((this.config.pubsub_subscription_id ?? '').trim());
|
|
if (pushConfigured && !this.config.disable_push) {modes.push('push');}
|
|
if (pullConfigured) {modes.push('pull');}
|
|
modes.push('poll');
|
|
|
|
console.log(
|
|
`GmailWatcher: Connected (${modes.join('+')}, poll_interval=${this.config.poll_interval ?? '300s'}${pullConfigured ? `, pubsub_pull_interval=${this.config.pubsub_pull_interval ?? '60s'}` : ''})`,
|
|
);
|
|
auditLogger?.systemStart('GmailWatcher', {
|
|
modes: modes.join('+'),
|
|
poll_interval: this.config.poll_interval,
|
|
pubsub_topic: pushConfigured ? 'configured' : 'none',
|
|
pubsub_subscription_id: pullConfigured ? 'configured' : 'none',
|
|
});
|
|
}
|
|
|
|
async disconnect(): Promise<void> {
|
|
if (this.pollTimer) {
|
|
clearInterval(this.pollTimer);
|
|
this.pollTimer = undefined;
|
|
}
|
|
if (this.watchTimer) {
|
|
clearInterval(this.watchTimer);
|
|
this.watchTimer = undefined;
|
|
}
|
|
if (this.pullTimer) {
|
|
clearInterval(this.pullTimer);
|
|
this.pullTimer = undefined;
|
|
}
|
|
if (this.pubsubSubscriber) {
|
|
try {
|
|
await this.pubsubSubscriber.close();
|
|
} catch {
|
|
// Ignore shutdown errors
|
|
}
|
|
this.pubsubSubscriber = undefined;
|
|
}
|
|
this.oauth2Client = undefined;
|
|
this._status = 'disconnected';
|
|
auditLogger?.systemStop('GmailWatcher');
|
|
}
|
|
|
|
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
|
// Route responses to the configured output channel
|
|
const outputAdapter = this.channelLookup.get(this.config.output.channel);
|
|
if (!outputAdapter) {
|
|
console.warn(`GmailWatcher: Output channel '${this.config.output.channel}' not found`);
|
|
return;
|
|
}
|
|
|
|
await outputAdapter.send(this.config.output.peer, message);
|
|
}
|
|
|
|
onMessage(handler: (msg: InboundMessage) => void): void {
|
|
this.messageHandler = handler;
|
|
}
|
|
|
|
/**
|
|
* Handle a Pub/Sub push notification from Google.
|
|
* Called by the gateway when POST /gmail/push is received.
|
|
* @param data Base64-encoded Pub/Sub message data
|
|
*/
|
|
async handlePushNotification(data: string): Promise<void> {
|
|
try {
|
|
const decoded = Buffer.from(data, 'base64').toString('utf-8');
|
|
const notification = JSON.parse(decoded) as PubSubNotification;
|
|
|
|
if (!notification.historyId) {
|
|
console.warn('GmailWatcher: Push notification missing historyId');
|
|
return;
|
|
}
|
|
|
|
// Only process if the new historyId is greater than our last known one
|
|
if (this.lastHistoryId && BigInt(notification.historyId) <= BigInt(this.lastHistoryId)) {
|
|
return;
|
|
}
|
|
|
|
if (this.lastHistoryId) {
|
|
await this.processHistoryChanges(this.lastHistoryId);
|
|
}
|
|
|
|
this.lastHistoryId = notification.historyId;
|
|
} catch (error) {
|
|
console.error('GmailWatcher: Push notification error —', error instanceof Error ? error.message : error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Authorize with Gmail using stored OAuth2 credentials.
|
|
* Reads client credentials and stored token from config paths.
|
|
*/
|
|
private async authorize(): Promise<Auth.OAuth2Client> {
|
|
const credentialsPath = this.config.credentials_file;
|
|
if (!credentialsPath) {
|
|
throw new Error('No credentials_file configured. Set automation.gmail.credentials_file in config.');
|
|
}
|
|
|
|
const expandedCredsPath = this.expandPath(credentialsPath);
|
|
if (!existsSync(expandedCredsPath)) {
|
|
throw new Error(`Credentials file not found: ${expandedCredsPath}`);
|
|
}
|
|
|
|
const credentials = JSON.parse(readFileSync(expandedCredsPath, 'utf-8'));
|
|
const { client_id, client_secret, redirect_uris, project_id } = credentials.installed ?? credentials.web ?? {};
|
|
if (project_id && typeof project_id === 'string') {
|
|
this.googleProjectId = project_id;
|
|
}
|
|
|
|
if (!client_id || !client_secret) {
|
|
throw new Error('Invalid credentials file — missing client_id or client_secret');
|
|
}
|
|
|
|
const oauth2Client = new google.auth.OAuth2(
|
|
client_id,
|
|
client_secret,
|
|
redirect_uris?.[0] ?? 'http://localhost',
|
|
);
|
|
|
|
// Load stored token
|
|
const tokenPath = this.expandPath(this.config.token_file ?? '~/.config/flynn/gmail-token.json');
|
|
if (!existsSync(tokenPath)) {
|
|
throw new Error(
|
|
`Token file not found: ${tokenPath}. Run "flynn gmail-auth" to authenticate.`,
|
|
);
|
|
}
|
|
|
|
const token = JSON.parse(readFileSync(tokenPath, 'utf-8'));
|
|
oauth2Client.setCredentials(token);
|
|
|
|
// Auto-save refreshed tokens
|
|
oauth2Client.on('tokens', (newTokens) => {
|
|
const merged = { ...token, ...newTokens };
|
|
this.saveToken(merged);
|
|
});
|
|
|
|
return oauth2Client;
|
|
}
|
|
|
|
/**
|
|
* Set up Gmail Pub/Sub watch for push notifications.
|
|
* Calls gmail.users.watch() and schedules renewal before expiry.
|
|
*/
|
|
private async setupWatch(): Promise<void> {
|
|
if (!this.oauth2Client) {return;}
|
|
|
|
if (this.watchTimer) {
|
|
clearInterval(this.watchTimer);
|
|
this.watchTimer = undefined;
|
|
}
|
|
|
|
const topicName = this.resolvePubSubTopicName();
|
|
if (!topicName) {
|
|
// Push notifications are optional; polling is always enabled.
|
|
return;
|
|
}
|
|
|
|
const gmail = google.gmail({ version: 'v1', auth: this.oauth2Client });
|
|
|
|
const watchResponse = await gmail.users.watch({
|
|
userId: 'me',
|
|
requestBody: {
|
|
labelIds: this.config.watch_labels ?? ['INBOX'],
|
|
topicName,
|
|
},
|
|
});
|
|
|
|
if (watchResponse.data.historyId) {
|
|
this.lastHistoryId = watchResponse.data.historyId.toString();
|
|
}
|
|
|
|
console.log(`GmailWatcher: Watch registered (historyId=${this.lastHistoryId})`);
|
|
|
|
// Schedule renewal before watch expiry (~7 days)
|
|
this.watchTimer = setInterval(() => {
|
|
this.setupWatch().catch((err) => {
|
|
console.error('GmailWatcher: Watch renewal failed —', err instanceof Error ? err.message : err);
|
|
});
|
|
}, WATCH_RENEWAL_MS);
|
|
}
|
|
|
|
private buildWatchErrorHint(errMsg: string): string {
|
|
const hints: string[] = [];
|
|
|
|
if (errMsg.includes('Invalid topicName')) {
|
|
hints.push(
|
|
`Tip: set automation.gmail.pubsub_topic to "projects/${this.googleProjectId ?? '<project-id>'}/topics/${DEFAULT_TOPIC_ID}"`,
|
|
);
|
|
}
|
|
|
|
if (/permission denied|PERMISSION_DENIED/i.test(errMsg)) {
|
|
hints.push('Tip: ensure Gmail has permission to publish to the Pub/Sub topic (IAM)');
|
|
}
|
|
|
|
hints.push('Tip: if Google cannot reach your gateway, set automation.gmail.pubsub_subscription_id for pull mode');
|
|
|
|
return hints.length > 0 ? `\n ${hints.join('\n ')}` : '';
|
|
}
|
|
|
|
/**
|
|
* Resolve the Pub/Sub topic resource name for Gmail push notifications.
|
|
*
|
|
* Priority:
|
|
* 1) automation.gmail.pubsub_topic
|
|
* 2) FLYNN_GMAIL_PUBSUB_TOPIC env var
|
|
* If neither is provided, push notifications are disabled.
|
|
*/
|
|
private resolvePubSubTopicName(): string | null {
|
|
const configured = this.config.pubsub_topic ?? process.env.FLYNN_GMAIL_PUBSUB_TOPIC;
|
|
let topic = (configured ?? '').trim();
|
|
|
|
if (!topic) {return null;}
|
|
|
|
// Allow shorthand: just the topic id (e.g. "gmail-push")
|
|
if (!topic.includes('/')) {
|
|
if (!this.googleProjectId) {
|
|
throw new Error(
|
|
`pubsub_topic '${topic}' must be fully qualified (projects/<project-id>/topics/<topic>) because project_id was not found in credentials`,
|
|
);
|
|
}
|
|
topic = `projects/${this.googleProjectId}/topics/${topic}`;
|
|
}
|
|
|
|
const isValid = /^projects\/[^/]+\/topics\/[^/]+$/.test(topic);
|
|
if (!isValid) {
|
|
throw new Error(
|
|
`Invalid pubsub_topic '${topic}'. Expected: projects/<project-id>/topics/<topic>`,
|
|
);
|
|
}
|
|
|
|
return topic;
|
|
}
|
|
|
|
private resolvePubSubSubscriptionName(): string | null {
|
|
let sub = (this.config.pubsub_subscription_id ?? '').trim();
|
|
if (!sub) {return null;}
|
|
|
|
// Allow shorthand: just the subscription id (e.g. "gmail-pull")
|
|
if (!sub.includes('/')) {
|
|
if (!this.googleProjectId) {
|
|
throw new Error(
|
|
`pubsub_subscription_id '${sub}' must be fully qualified (projects/<project-id>/subscriptions/<subscription>) because project_id was not found in credentials`,
|
|
);
|
|
}
|
|
sub = `projects/${this.googleProjectId}/subscriptions/${sub}`;
|
|
}
|
|
|
|
const isValid = /^projects\/[^/]+\/subscriptions\/[^/]+$/.test(sub);
|
|
if (!isValid) {
|
|
throw new Error(
|
|
`Invalid pubsub_subscription_id '${sub}'. Expected: projects/<project-id>/subscriptions/<subscription>`,
|
|
);
|
|
}
|
|
|
|
return sub;
|
|
}
|
|
|
|
private async setupPullSubscription(): Promise<void> {
|
|
const subscriptionName = this.resolvePubSubSubscriptionName();
|
|
if (!subscriptionName) {return;}
|
|
|
|
if (this.pullTimer) {
|
|
clearInterval(this.pullTimer);
|
|
this.pullTimer = undefined;
|
|
}
|
|
|
|
const pullMs = parseInterval(this.config.pubsub_pull_interval ?? '60s');
|
|
|
|
// Kick once immediately, then on interval.
|
|
await this.pullSubscriptionMessages().catch((err) => {
|
|
console.error('GmailWatcher: Pub/Sub pull error —', err instanceof Error ? err.message : err);
|
|
});
|
|
|
|
this.pullTimer = setInterval(() => {
|
|
this.pullSubscriptionMessages().catch((err) => {
|
|
console.error('GmailWatcher: Pub/Sub pull error —', err instanceof Error ? err.message : err);
|
|
});
|
|
}, pullMs);
|
|
|
|
console.log(
|
|
`GmailWatcher: Pull enabled (subscription=${subscriptionName}, interval=${this.config.pubsub_pull_interval ?? '60s'})`,
|
|
);
|
|
}
|
|
|
|
private async getSubscriberClient(): Promise<v1.SubscriberClient> {
|
|
if (this.pubsubSubscriber) {return this.pubsubSubscriber;}
|
|
const mod = await import('@google-cloud/pubsub');
|
|
this.pubsubSubscriber = new mod.v1.SubscriberClient();
|
|
return this.pubsubSubscriber;
|
|
}
|
|
|
|
private async pullSubscriptionMessages(): Promise<void> {
|
|
const subscription = this.resolvePubSubSubscriptionName();
|
|
if (!subscription) {return;}
|
|
if (this.pullInFlight) {return;}
|
|
this.pullInFlight = true;
|
|
|
|
try {
|
|
const client = await this.getSubscriberClient();
|
|
const maxMessages = this.config.pubsub_max_messages ?? 10;
|
|
|
|
const [response] = await client.pull({
|
|
subscription,
|
|
maxMessages,
|
|
});
|
|
|
|
const received = response.receivedMessages ?? [];
|
|
if (received.length === 0) {return;}
|
|
|
|
const ackIds: string[] = [];
|
|
for (const receivedMessage of received) {
|
|
const ackId = receivedMessage.ackId;
|
|
const data = receivedMessage.message?.data;
|
|
if (!ackId || !data) {continue;}
|
|
|
|
const base64 = Buffer.from(data as Uint8Array).toString('base64');
|
|
try {
|
|
await this.handlePushNotification(base64);
|
|
ackIds.push(ackId);
|
|
} catch {
|
|
// If processing fails, leave message unacked for retry.
|
|
}
|
|
}
|
|
|
|
if (ackIds.length > 0) {
|
|
await client.acknowledge({ subscription, ackIds });
|
|
}
|
|
} finally {
|
|
this.pullInFlight = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Poll Gmail History API for new messages since lastHistoryId.
|
|
* Fallback mechanism when Pub/Sub push is not available.
|
|
*/
|
|
private async pollForNewMessages(): Promise<void> {
|
|
if (!this.oauth2Client) {return;}
|
|
|
|
const gmail = google.gmail({ version: 'v1', auth: this.oauth2Client });
|
|
|
|
// If no historyId yet, initialize it from the profile
|
|
if (!this.lastHistoryId) {
|
|
try {
|
|
const profile = await gmail.users.getProfile({ userId: 'me' });
|
|
if (profile.data.historyId) {
|
|
this.lastHistoryId = profile.data.historyId.toString();
|
|
}
|
|
} catch (error) {
|
|
console.error('GmailWatcher: Failed to get profile —', error instanceof Error ? error.message : error);
|
|
}
|
|
return; // First poll — just establish the baseline
|
|
}
|
|
|
|
await this.processHistoryChanges(this.lastHistoryId);
|
|
}
|
|
|
|
/**
|
|
* Fetch history changes since the given historyId and process new messages.
|
|
* Updates lastHistoryId to the latest value from the response.
|
|
*/
|
|
private async processHistoryChanges(startHistoryId: string): Promise<void> {
|
|
if (!this.oauth2Client) {return;}
|
|
|
|
const gmail = google.gmail({ version: 'v1', auth: this.oauth2Client });
|
|
|
|
try {
|
|
const historyResponse = await gmail.users.history.list({
|
|
userId: 'me',
|
|
startHistoryId: startHistoryId,
|
|
labelId: (this.config.watch_labels ?? ['INBOX'])[0],
|
|
historyTypes: ['messageAdded'],
|
|
});
|
|
|
|
const history = historyResponse.data.history ?? [];
|
|
const processedIds = new Set<string>();
|
|
|
|
for (const record of history) {
|
|
const addedMessages = record.messagesAdded ?? [];
|
|
for (const added of addedMessages) {
|
|
const messageId = added.message?.id;
|
|
if (!messageId || processedIds.has(messageId)) {continue;}
|
|
processedIds.add(messageId);
|
|
|
|
const email = await this.getMessageDetails(messageId);
|
|
if (!email) {continue;}
|
|
|
|
// Skip messages before history_start if configured
|
|
if (this.config.history_start) {
|
|
const emailDate = new Date(email.date);
|
|
const startDate = new Date(this.config.history_start);
|
|
if (emailDate < startDate) {continue;}
|
|
}
|
|
|
|
const text = this.renderTemplate(email);
|
|
|
|
const msg: InboundMessage = {
|
|
id: `gmail-${email.id}-${Date.now()}`,
|
|
channel: 'gmail',
|
|
senderId: email.from,
|
|
senderName: `gmail:${email.from}`,
|
|
text,
|
|
timestamp: Date.now(),
|
|
metadata: {
|
|
emailId: email.id,
|
|
from: email.from,
|
|
to: email.to,
|
|
subject: email.subject,
|
|
labels: email.labels,
|
|
},
|
|
};
|
|
|
|
this.messageHandler?.(msg);
|
|
}
|
|
}
|
|
|
|
// Update historyId to the latest
|
|
if (historyResponse.data.historyId) {
|
|
this.lastHistoryId = historyResponse.data.historyId.toString();
|
|
}
|
|
} catch (error: unknown) {
|
|
// 404 means historyId is too old — reset by fetching profile
|
|
if (error instanceof Error && 'code' in error && (error as { code: number }).code === 404) {
|
|
console.warn('GmailWatcher: History expired, re-syncing...');
|
|
try {
|
|
const profile = await gmail.users.getProfile({ userId: 'me' });
|
|
if (profile.data.historyId) {
|
|
this.lastHistoryId = profile.data.historyId.toString();
|
|
}
|
|
} catch (profileError) {
|
|
console.error('GmailWatcher: Failed to re-sync profile —', profileError instanceof Error ? profileError.message : profileError);
|
|
}
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch full message details by ID and extract relevant headers.
|
|
*/
|
|
private async getMessageDetails(messageId: string): Promise<EmailInfo | null> {
|
|
if (!this.oauth2Client) {return null;}
|
|
|
|
const gmail = google.gmail({ version: 'v1', auth: this.oauth2Client });
|
|
|
|
try {
|
|
const msg = await gmail.users.messages.get({
|
|
userId: 'me',
|
|
id: messageId,
|
|
format: 'metadata',
|
|
metadataHeaders: ['From', 'To', 'Subject', 'Date'],
|
|
});
|
|
|
|
const headers = msg.data.payload?.headers ?? [];
|
|
const getHeader = (name: string): string =>
|
|
headers.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value ?? '';
|
|
|
|
return {
|
|
id: messageId,
|
|
from: getHeader('From'),
|
|
to: getHeader('To'),
|
|
subject: getHeader('Subject'),
|
|
snippet: sanitizeHtml(msg.data.snippet ?? ''),
|
|
date: getHeader('Date'),
|
|
labels: msg.data.labelIds ?? [],
|
|
};
|
|
} catch (error) {
|
|
console.error(`GmailWatcher: Failed to fetch message ${messageId} —`, error instanceof Error ? error.message : error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render the message template with email placeholders.
|
|
* Supported: {{from}}, {{to}}, {{subject}}, {{snippet}}, {{date}}, {{id}}, {{labels}}
|
|
*/
|
|
renderTemplate(email: EmailInfo): string {
|
|
return this.config.message
|
|
.replace(/\{\{from\}\}/g, email.from)
|
|
.replace(/\{\{to\}\}/g, email.to)
|
|
.replace(/\{\{subject\}\}/g, email.subject)
|
|
.replace(/\{\{snippet\}\}/g, email.snippet)
|
|
.replace(/\{\{date\}\}/g, email.date)
|
|
.replace(/\{\{id\}\}/g, email.id)
|
|
.replace(/\{\{labels\}\}/g, email.labels.join(', '));
|
|
}
|
|
|
|
/**
|
|
* Expand ~ to the user's home directory.
|
|
*/
|
|
expandPath(p: string): string {
|
|
if (p.startsWith('~/') || p === '~') {
|
|
return resolve(homedir(), p.slice(2));
|
|
}
|
|
return resolve(p);
|
|
}
|
|
|
|
/**
|
|
* Save token to disk with restrictive permissions (0o600).
|
|
*/
|
|
private saveToken(token: unknown): void {
|
|
const tokenPath = this.expandPath(this.config.token_file ?? '~/.config/flynn/gmail-token.json');
|
|
const dir = dirname(tokenPath);
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
writeFileSync(tokenPath, JSON.stringify(token, null, 2), 'utf-8');
|
|
try {
|
|
chmodSync(tokenPath, 0o600);
|
|
} catch {
|
|
// chmod may fail on some filesystems — not critical
|
|
}
|
|
}
|
|
}
|