Skip to content
Open
117 changes: 111 additions & 6 deletions src/main/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,35 @@ const NUMBERED_MIGRATIONS: Migration[] = [
`);
},
},
{
version: 2,
name: "add_send_as_aliases_and_from_address",
up: (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS send_as_aliases (
email TEXT NOT NULL,
account_id TEXT NOT NULL,
display_name TEXT,
is_default INTEGER DEFAULT 0,
reply_to_address TEXT,
verification_status TEXT,
fetched_at INTEGER NOT NULL,
PRIMARY KEY (email, account_id),
FOREIGN KEY (account_id) REFERENCES accounts(id)
);
CREATE INDEX IF NOT EXISTS idx_send_as_account ON send_as_aliases(account_id);
`);

// ALTER TABLE only for existing databases — fresh DBs get the column from SCHEMA
const tables = ["local_drafts", "outbox", "scheduled_messages"];
for (const table of tables) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
if (cols.length > 0 && !cols.some((c) => c.name === "from_address")) {
db.exec(`ALTER TABLE ${table} ADD COLUMN from_address TEXT`);
}
}
},
},
];

function runNumberedMigrations(db: DatabaseInstance): void {
Expand Down Expand Up @@ -2074,6 +2103,7 @@ export function removeAccount(accountId: string): void {
db.prepare("DELETE FROM calendar_sync_state WHERE account_id = ?").run(accountId);
db.prepare("DELETE FROM memories WHERE account_id = ?").run(accountId);
db.prepare("DELETE FROM agent_audit_log WHERE account_id = ?").run(accountId);
db.prepare("DELETE FROM send_as_aliases WHERE account_id = ?").run(accountId);
db.prepare("DELETE FROM emails WHERE account_id = ?").run(accountId);
db.prepare("DELETE FROM accounts WHERE id = ?").run(accountId);
});
Expand All @@ -2086,6 +2116,65 @@ export function setPrimaryAccount(accountId: string): void {
db.prepare("UPDATE accounts SET is_primary = 1 WHERE id = ?").run(accountId);
}

// ============================================
// Send-as alias operations
// ============================================

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's remove all this from this PR. seems unrelated


import type { SendAsAlias } from "../../shared/types";

export function upsertSendAsAliases(accountId: string, aliases: SendAsAlias[]): void {
const db = getDatabase();
const now = Date.now();

const run = db.transaction(() => {
// Clear stale aliases for this account, then insert fresh
db.prepare("DELETE FROM send_as_aliases WHERE account_id = ?").run(accountId);

const stmt = db.prepare(`
INSERT INTO send_as_aliases (email, account_id, display_name, is_default, reply_to_address, verification_status, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);

for (const alias of aliases) {
stmt.run(
alias.email,
accountId,
alias.displayName || null,
alias.isDefault ? 1 : 0,
alias.replyToAddress || null,
"accepted",
now,
);
}
});
run();
}

export function getSendAsAliases(accountId: string): SendAsAlias[] {
const db = getDatabase();
const rows = db
.prepare(
`SELECT email, display_name as displayName, is_default as isDefault, reply_to_address as replyToAddress
FROM send_as_aliases WHERE account_id = ? ORDER BY is_default DESC, email ASC`,
)
.all(accountId) as Array<Record<string, unknown>>;

return rows.map((row) => ({
email: row.email as string,
displayName: (row.displayName as string | null) ?? undefined,
isDefault: Boolean(row.isDefault),
replyToAddress: (row.replyToAddress as string | null) ?? undefined,
}));
}

export function getSendAsAliasFetchedAt(accountId: string): number | null {
const db = getDatabase();
const row = db
.prepare("SELECT MAX(fetched_at) as fetchedAt FROM send_as_aliases WHERE account_id = ?")
.get(accountId) as { fetchedAt: number | null } | undefined;
return row?.fetchedAt ?? null;
}

// ============================================
// Sender profile operations
// ============================================
Expand Down Expand Up @@ -2685,10 +2774,10 @@ export function saveLocalDraft(draft: LocalDraft): void {
INSERT OR REPLACE INTO local_drafts (
id, account_id, gmail_draft_id, thread_id, in_reply_to,
to_addresses, cc_addresses, bcc_addresses, subject,
body_html, body_text, is_reply, is_forward,
body_html, body_text, from_address, is_reply, is_forward,
created_at, updated_at, synced_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
draft.id,
Expand All @@ -2702,6 +2791,7 @@ export function saveLocalDraft(draft: LocalDraft): void {
draft.subject,
draft.bodyHtml,
draft.bodyText || null,
draft.fromAddress || null,
draft.isReply ? 1 : 0,
draft.isForward ? 1 : 0,
draft.createdAt,
Expand All @@ -2718,6 +2808,7 @@ export function getLocalDraft(draftId: string): LocalDraft | null {
to_addresses as toAddresses, cc_addresses as ccAddresses,
bcc_addresses as bccAddresses, subject,
body_html as bodyHtml, body_text as bodyText,
from_address as fromAddress,
is_reply as isReply, is_forward as isForward,
created_at as createdAt, updated_at as updatedAt,
synced_at as syncedAt
Expand All @@ -2737,6 +2828,7 @@ export function getLocalDrafts(accountId?: string): LocalDraft[] {
to_addresses as toAddresses, cc_addresses as ccAddresses,
bcc_addresses as bccAddresses, subject,
body_html as bodyHtml, body_text as bodyText,
from_address as fromAddress,
is_reply as isReply, is_forward as isForward,
created_at as createdAt, updated_at as updatedAt,
synced_at as syncedAt
Expand Down Expand Up @@ -2781,6 +2873,7 @@ function rowToLocalDraft(row: Record<string, unknown>): LocalDraft {
subject: row.subject as string,
bodyHtml: row.bodyHtml as string,
bodyText: (row.bodyText as string | null) ?? undefined,
fromAddress: (row.fromAddress as string | null) ?? undefined,
isReply: Boolean(row.isReply),
isForward: Boolean(row.isForward),
createdAt: row.createdAt as number,
Expand Down Expand Up @@ -3156,6 +3249,7 @@ export type OutboxItem = {
accountId: string;
type: OutboxType;
threadId?: string;
from?: string;
to: string[];
cc?: string[];
bcc?: string[];
Expand Down Expand Up @@ -3194,9 +3288,9 @@ export function insertOutboxMessage(
INSERT INTO outbox (
id, account_id, type, thread_id, to_addresses, cc_addresses, bcc_addresses,
subject, body_html, body_text, in_reply_to, references_header,
attachments, status, retry_count, created_at, updated_at
attachments, from_address, status, retry_count, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
`);
const now = Date.now();
stmt.run(
Expand All @@ -3213,6 +3307,7 @@ export function insertOutboxMessage(
item.inReplyTo || null,
item.references || null,
item.attachments ? JSON.stringify(item.attachments) : null,
item.from || null,
item.createdAt,
now,
);
Expand Down Expand Up @@ -3249,6 +3344,7 @@ export function getPendingOutbox(accountId?: string, limit: number = 10): Outbox
to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses,
subject, body_html as bodyHtml, body_text as bodyText,
in_reply_to as inReplyTo, references_header as referencesHeader, attachments,
from_address as fromAddress,
status, error_message as errorMessage, retry_count as retryCount,
created_at as createdAt, updated_at as updatedAt, sent_at as sentAt
FROM outbox
Expand All @@ -3271,6 +3367,7 @@ export function getOutboxItems(accountId?: string): OutboxItem[] {
to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses,
subject, body_html as bodyHtml, body_text as bodyText,
in_reply_to as inReplyTo, references_header as referencesHeader, attachments,
from_address as fromAddress,
status, error_message as errorMessage, retry_count as retryCount,
created_at as createdAt, updated_at as updatedAt, sent_at as sentAt
FROM outbox
Expand All @@ -3293,6 +3390,7 @@ export function getOutboxItem(id: string): OutboxItem | null {
to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses,
subject, body_html as bodyHtml, body_text as bodyText,
in_reply_to as inReplyTo, references_header as referencesHeader, attachments,
from_address as fromAddress,
status, error_message as errorMessage, retry_count as retryCount,
created_at as createdAt, updated_at as updatedAt, sent_at as sentAt
FROM outbox
Expand Down Expand Up @@ -3596,6 +3694,7 @@ export type ScheduledMessageRow = {
accountId: string;
type: "send" | "reply";
threadId?: string;
from?: string;
to: string[];
cc?: string[];
bcc?: string[];
Expand All @@ -3620,9 +3719,9 @@ export function insertScheduledMessage(
INSERT INTO scheduled_messages (
id, account_id, type, thread_id, to_addresses, cc_addresses, bcc_addresses,
subject, body_html, body_text, in_reply_to, references_header,
scheduled_at, status, created_at, updated_at
from_address, scheduled_at, status, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scheduled', ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scheduled', ?, ?)
`);
const now = Date.now();
stmt.run(
Expand All @@ -3638,6 +3737,7 @@ export function insertScheduledMessage(
item.bodyText || null,
item.inReplyTo || null,
item.references || null,
item.from || null,
item.scheduledAt,
item.createdAt,
now,
Expand All @@ -3652,6 +3752,7 @@ export function getDueScheduledMessages(limit: number = 10): ScheduledMessageRow
to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses,
subject, body_html as bodyHtml, body_text as bodyText,
in_reply_to as inReplyTo, references_header as referencesHeader,
from_address as fromAddress,
scheduled_at as scheduledAt, status, error_message as errorMessage,
created_at as createdAt, updated_at as updatedAt, sent_at as sentAt
FROM scheduled_messages
Expand All @@ -3670,6 +3771,7 @@ export function getScheduledMessages(accountId?: string): ScheduledMessageRow[]
to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses,
subject, body_html as bodyHtml, body_text as bodyText,
in_reply_to as inReplyTo, references_header as referencesHeader,
from_address as fromAddress,
scheduled_at as scheduledAt, status, error_message as errorMessage,
created_at as createdAt, updated_at as updatedAt, sent_at as sentAt
FROM scheduled_messages
Expand All @@ -3692,6 +3794,7 @@ export function getScheduledMessage(id: string): ScheduledMessageRow | null {
to_addresses as toAddresses, cc_addresses as ccAddresses, bcc_addresses as bccAddresses,
subject, body_html as bodyHtml, body_text as bodyText,
in_reply_to as inReplyTo, references_header as referencesHeader,
from_address as fromAddress,
scheduled_at as scheduledAt, status, error_message as errorMessage,
created_at as createdAt, updated_at as updatedAt, sent_at as sentAt
FROM scheduled_messages
Expand Down Expand Up @@ -3772,6 +3875,7 @@ function rowToScheduledMessage(row: Record<string, unknown>): ScheduledMessageRo
accountId: row.accountId as string,
type: row.type as "send" | "reply",
threadId: (row.threadId as string | null) ?? undefined,
from: (row.fromAddress as string | null) ?? undefined,
to: JSON.parse(row.toAddresses as string) as string[],
cc: row.ccAddresses ? (JSON.parse(row.ccAddresses as string) as string[]) : undefined,
bcc: row.bccAddresses ? (JSON.parse(row.bccAddresses as string) as string[]) : undefined,
Expand Down Expand Up @@ -4045,6 +4149,7 @@ function rowToOutboxItem(row: Record<string, unknown>): OutboxItem {
accountId: row.accountId as string,
type: row.type as OutboxType,
threadId: (row.threadId as string | null) ?? undefined,
from: (row.fromAddress as string | null) ?? undefined,
to: JSON.parse(row.toAddresses as string) as string[],
cc: row.ccAddresses ? (JSON.parse(row.ccAddresses as string) as string[]) : undefined,
bcc: row.bccAddresses ? (JSON.parse(row.bccAddresses as string) as string[]) : undefined,
Expand Down
17 changes: 17 additions & 0 deletions src/main/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ CREATE TABLE IF NOT EXISTS accounts (
added_at INTEGER NOT NULL
);

-- Gmail send-as aliases per account (cached from Gmail settings API)
CREATE TABLE IF NOT EXISTS send_as_aliases (
email TEXT NOT NULL,
account_id TEXT NOT NULL,
display_name TEXT,
is_default INTEGER DEFAULT 0,
reply_to_address TEXT,
verification_status TEXT,
fetched_at INTEGER NOT NULL,
PRIMARY KEY (email, account_id),
FOREIGN KEY (account_id) REFERENCES accounts(id)
);

-- Sync state for each account (for incremental sync)
CREATE TABLE IF NOT EXISTS sync_state (
account_id TEXT PRIMARY KEY,
Expand Down Expand Up @@ -131,6 +144,7 @@ CREATE TABLE IF NOT EXISTS local_drafts (
gmail_draft_id TEXT,
thread_id TEXT,
in_reply_to TEXT,
from_address TEXT,
to_addresses TEXT NOT NULL,
cc_addresses TEXT,
bcc_addresses TEXT,
Expand Down Expand Up @@ -174,6 +188,7 @@ CREATE TABLE IF NOT EXISTS scheduled_messages (
account_id TEXT NOT NULL,
type TEXT NOT NULL,
thread_id TEXT,
from_address TEXT,
to_addresses TEXT NOT NULL,
cc_addresses TEXT,
bcc_addresses TEXT,
Expand All @@ -197,6 +212,7 @@ CREATE TABLE IF NOT EXISTS outbox (
account_id TEXT NOT NULL,
type TEXT NOT NULL,
thread_id TEXT,
from_address TEXT,
to_addresses TEXT NOT NULL,
cc_addresses TEXT,
bcc_addresses TEXT,
Expand Down Expand Up @@ -355,6 +371,7 @@ CREATE INDEX IF NOT EXISTS idx_memories_account ON memories(account_id);
CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope, scope_value);
CREATE INDEX IF NOT EXISTS idx_emails_message_id ON emails(message_id);
CREATE INDEX IF NOT EXISTS idx_emails_in_reply_to ON emails(in_reply_to);
CREATE INDEX IF NOT EXISTS idx_send_as_account ON send_as_aliases(account_id);
`;

// FTS5 full-text search schema (separate because SQLite can't IF NOT EXISTS for virtual tables)
Expand Down
43 changes: 43 additions & 0 deletions src/main/ipc/compose.ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import {
getArchiveReadyForThread,
getEmailsByThread,
updateEmailLabelIds,
getSendAsAliases,
getSendAsAliasFetchedAt,
upsertSendAsAliases,
} from "../db";
import { networkMonitor } from "../services/network-monitor";
import { outboxService } from "../services/outbox-service";
Expand All @@ -27,6 +30,7 @@ import type {
ReplyInfo,
SendMessageOptions,
SendMessageResult,
SendAsAlias,
} from "../../shared/types";
import { formatAddressesWithNames, extractThreadNames } from "../utils/address-formatting";
import { createLogger } from "../services/logger";
Expand Down Expand Up @@ -54,6 +58,7 @@ function queueToOutbox(options: SendMessageOptions & { accountId: string }): Sen
accountId: options.accountId,
type: options.threadId ? "reply" : "send",
threadId: options.threadId,
from: options.from,
to: formattedTo,
cc: formattedCc,
bcc: formattedBcc,
Expand Down Expand Up @@ -847,4 +852,42 @@ export function registerComposeIpc(): void {
}
},
);

// Fetch send-as aliases for an account (cached with 1h TTL)
const SEND_AS_CACHE_TTL_MS = 60 * 60 * 1000;
ipcMain.handle(
"compose:get-send-as-aliases",
async (_, { accountId }: { accountId: string }): Promise<IpcResponse<SendAsAlias[]>> => {
try {
// Check cache freshness
const fetchedAt = getSendAsAliasFetchedAt(accountId);
if (fetchedAt && Date.now() - fetchedAt < SEND_AS_CACHE_TTL_MS) {
return { success: true, data: getSendAsAliases(accountId) };
}

// Fetch fresh from Gmail API
const syncService = getEmailSyncService();
const client = syncService.getClientForAccount(accountId);
if (!client) {
// Offline or no client — return cached if available
const cached = getSendAsAliases(accountId);
return { success: true, data: cached };
}

const aliases = await client.fetchSendAsAliases();
upsertSendAsAliases(accountId, aliases);
return { success: true, data: aliases };
} catch (error) {
// Fall back to cache on API error
const cached = getSendAsAliases(accountId);
if (cached.length > 0) {
return { success: true, data: cached };
}
return {
success: false,
error: error instanceof Error ? error.message : "Failed to fetch send-as aliases",
};
}
},
);
}
Loading