Initial commit: Outlook-competitor desktop mail client

Tauri v2 (Rust backend, React/TypeScript frontend). IMAP/POP3/SMTP core,
Google OAuth (mail/calendar/contacts sync), CalDAV, local calendar with
recurrence, rules engine, contacts, tasks, unified inbox, context menus,
drag & drop, snooze, templates, and more.
This commit is contained in:
2026-07-28 20:56:05 +02:00
commit 20bfd2cf6b
107 changed files with 25728 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
export function parseAddressList(raw: string): string[] {
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => {
const match = s.match(/<([^>]+)>/)
return (match ? match[1] : s).trim().toLowerCase()
})
.filter(Boolean)
}
+147
View File
@@ -0,0 +1,147 @@
import { invoke } from '@tauri-apps/api/core'
import type {
Account,
CaldavAccount,
ComposeMessage,
Contact,
ContactGroup,
DraftPayload,
Event,
Folder,
InviteInfo,
InviteResponse,
MessageFull,
MessageSummary,
NewAccount,
NewContact,
NewContactGroup,
NewEvent,
NewCaldavAccount,
NewRule,
NewTask,
NewTemplate,
NewVacationSettings,
Rule,
SnoozedMessage,
Task,
Template,
VacationSettings,
} from '../types'
export const api = {
addAccount: (newAccount: NewAccount) => invoke<Account>('add_account', { newAccount }),
listAccounts: () => invoke<Account[]>('list_accounts'),
deleteAccount: (accountId: string) => invoke<void>('delete_account', { accountId }),
updateAccount: (account: Account, newPassword?: string) =>
invoke<void>('update_account', { account, newPassword: newPassword || undefined }),
listFolders: (accountId: string) => invoke<Folder[]>('list_folders', { accountId }),
createFolder: (accountId: string, name: string) => invoke<void>('create_folder', { accountId, name }),
renameFolder: (accountId: string, from: string, to: string) => invoke<void>('rename_folder', { accountId, from, to }),
deleteFolder: (accountId: string, name: string) => invoke<void>('delete_folder', { accountId, name }),
markFolderRead: (accountId: string, folder: string) => invoke<number>('mark_folder_read', { accountId, folder }),
applyRulesToFolder: (accountId: string, folder: string) => invoke<number>('apply_rules_to_folder', { accountId, folder }),
syncMessages: (accountId: string, folder: string) =>
invoke<MessageSummary[]>('sync_messages', { accountId, folder }),
listCachedMessages: (accountId: string, folder: string) =>
invoke<MessageSummary[]>('list_cached_messages', { accountId, folder }),
getMessage: (accountId: string, folder: string, uid: number) =>
invoke<MessageFull>('get_message', { accountId, folder, uid }),
sendMessage: (message: ComposeMessage) => invoke<void>('send_message', { message }),
sendMessageAt: (message: ComposeMessage, sendAtTs: number) => invoke<string>('send_message_at', { message, sendAtTs }),
cancelScheduledSend: (id: string) => invoke<boolean>('cancel_scheduled_send', { id }),
markRead: (accountId: string, folder: string, uid: number, isRead: boolean) =>
invoke<void>('mark_read', { accountId, folder, uid, isRead }),
deleteMessage: (accountId: string, folder: string, uid: number) =>
invoke<void>('delete_message', { accountId, folder, uid }),
searchMessages: (accountId: string, query: string) =>
invoke<MessageSummary[]>('search_messages', { accountId, query }),
searchAllMessages: (query: string) => invoke<MessageSummary[]>('search_all_messages', { query }),
setMessageCategory: (accountId: string, folder: string, uid: number, category: string | null) =>
invoke<void>('set_message_category', { accountId, folder, uid, category }),
toggleFlag: (accountId: string, folder: string, uid: number, flagged: boolean) =>
invoke<void>('toggle_flag', { accountId, folder, uid, flagged }),
moveMessage: (accountId: string, folder: string, uid: number, targetFolder: string) =>
invoke<void>('move_message', { accountId, folder, uid, targetFolder }),
listContacts: () => invoke<Contact[]>('list_contacts'),
searchContacts: (query: string) => invoke<Contact[]>('search_contacts', { query }),
addContact: (newContact: NewContact) => invoke<Contact>('add_contact', { newContact }),
updateContact: (contact: Contact) => invoke<void>('update_contact', { contact }),
deleteContact: (contactId: string) => invoke<void>('delete_contact', { contactId }),
toggleContactFavorite: (contactId: string, favorite: boolean) =>
invoke<void>('toggle_contact_favorite', { contactId, favorite }),
exportContactsVcard: (path: string) => invoke<number>('export_contacts_vcard', { path }),
importContactsVcard: (path: string) => invoke<number>('import_contacts_vcard', { path }),
exportMessageEml: (accountId: string, folder: string, uid: number, path: string) =>
invoke<void>('export_message_eml', { accountId, folder, uid, path }),
exportFolderMbox: (accountId: string, folder: string, path: string) =>
invoke<number>('export_folder_mbox', { accountId, folder, path }),
exportFullBackup: (dir: string) => invoke<number>('export_full_backup', { dir }),
importEmlFile: (accountId: string, folder: string, path: string) =>
invoke<void>('import_eml_file', { accountId, folder, path }),
importMboxFile: (accountId: string, folder: string, path: string) =>
invoke<number>('import_mbox_file', { accountId, folder, path }),
saveDraft: (uid: number | null, message: ComposeMessage) =>
invoke<number>('save_draft', { uid, message }),
loadDraft: (uid: number) => invoke<DraftPayload>('load_draft', { uid }),
googleOAuthAddAccount: () => invoke<Account>('google_oauth_add_account'),
listRules: () => invoke<Rule[]>('list_rules'),
createRule: (newRule: NewRule) => invoke<Rule>('create_rule', { newRule }),
setRuleEnabled: (ruleId: string, enabled: boolean) => invoke<void>('set_rule_enabled', { ruleId, enabled }),
deleteRule: (ruleId: string) => invoke<void>('delete_rule', { ruleId }),
listEvents: (startTs: number, endTs: number) => invoke<Event[]>('list_events', { startTs, endTs }),
createEvent: (newEvent: NewEvent) => invoke<Event>('create_event', { newEvent }),
updateEvent: (event: Event) => invoke<void>('update_event', { event }),
deleteEvent: (eventId: string) => invoke<void>('delete_event', { eventId }),
updateEventOccurrence: (baseId: string, occurrenceStart: number, data: NewEvent) =>
invoke<Event>('update_event_occurrence', { baseId, occurrenceStart, data }),
deleteEventOccurrence: (baseId: string, occurrenceStart: number) =>
invoke<void>('delete_event_occurrence', { baseId, occurrenceStart }),
listCaldavAccounts: () => invoke<CaldavAccount[]>('list_caldav_accounts'),
addCaldavAccount: (newAccount: NewCaldavAccount) => invoke<CaldavAccount>('add_caldav_account', { newAccount }),
deleteCaldavAccount: (accountId: string) => invoke<void>('delete_caldav_account', { accountId }),
syncCaldavCalendar: (accountId: string) => invoke<number>('sync_caldav_calendar', { accountId }),
importIcsFile: (path: string) => invoke<number>('import_ics_file', { path }),
importIcsText: (text: string) => invoke<number>('import_ics_text', { text }),
previewInvite: (icsBase64: string) => invoke<InviteInfo | null>('preview_invite', { icsBase64 }),
replyToInvite: (accountId: string, icsBase64: string, response: InviteResponse) =>
invoke<void>('reply_to_invite', { accountId, icsBase64, response }),
getVacationSettings: (accountId: string) => invoke<VacationSettings>('get_vacation_settings', { accountId }),
saveVacationSettings: (accountId: string, settings: NewVacationSettings) =>
invoke<void>('save_vacation_settings', { accountId, settings }),
snoozeMessage: (accountId: string, folder: string, uid: number, untilTs: number) =>
invoke<void>('snooze_message', { accountId, folder, uid, untilTs }),
unsnoozeMessage: (accountId: string, folder: string, uid: number) =>
invoke<void>('unsnooze_message', { accountId, folder, uid }),
listSnoozedMessages: () => invoke<SnoozedMessage[]>('list_snoozed_messages'),
listTemplates: () => invoke<Template[]>('list_templates'),
createTemplate: (newTemplate: NewTemplate) => invoke<Template>('create_template', { newTemplate }),
updateTemplate: (template: Template) => invoke<void>('update_template', { template }),
deleteTemplate: (templateId: string) => invoke<void>('delete_template', { templateId }),
searchMessagesAdvanced: (
query: string,
from: string | undefined,
dateFrom: number | undefined,
dateTo: number | undefined,
hasAttachment: boolean,
) => invoke<MessageSummary[]>('search_messages_advanced', { query, from, dateFrom, dateTo, hasAttachment }),
exportEventsIcs: (path: string) => invoke<number>('export_events_ics', { path }),
getEvent: (eventId: string) => invoke<Event>('get_event', { eventId }),
listUpcomingReminders: (withinHours: number) => invoke<Event[]>('list_upcoming_reminders', { withinHours }),
syncGoogleCalendar: (accountId: string) => invoke<number>('sync_google_calendar', { accountId }),
syncGoogleContacts: (accountId: string) => invoke<number>('sync_google_contacts', { accountId }),
reconnectGoogleAccount: (accountId: string) => invoke<void>('reconnect_google_account', { accountId }),
setDesktopNotificationsEnabled: (enabled: boolean) =>
invoke<void>('set_desktop_notifications_enabled', { enabled }),
listTasks: () => invoke<Task[]>('list_tasks'),
createTask: (newTask: NewTask) => invoke<Task>('create_task', { newTask }),
updateTask: (task: Task) => invoke<void>('update_task', { task }),
deleteTask: (taskId: string) => invoke<void>('delete_task', { taskId }),
setTaskCompleted: (taskId: string, completed: boolean) =>
invoke<void>('set_task_completed', { taskId, completed }),
createTaskFromMessage: (accountId: string, folder: string, uid: number, title: string) =>
invoke<Task>('create_task_from_message', { accountId, folder, uid, title }),
listContactGroups: () => invoke<ContactGroup[]>('list_contact_groups'),
createContactGroup: (newGroup: NewContactGroup) => invoke<ContactGroup>('create_contact_group', { newGroup }),
updateContactGroup: (group: ContactGroup) => invoke<void>('update_contact_group', { group }),
deleteContactGroup: (groupId: string) => invoke<void>('delete_contact_group', { groupId }),
}
+19
View File
@@ -0,0 +1,19 @@
export interface CategoryColor {
id: string
label: string
hex: string
}
export const CATEGORY_COLORS: CategoryColor[] = [
{ id: 'red', label: 'Rot', hex: '#ef4444' },
{ id: 'orange', label: 'Orange', hex: '#f97316' },
{ id: 'yellow', label: 'Gelb', hex: '#eab308' },
{ id: 'green', label: 'Grün', hex: '#22c55e' },
{ id: 'blue', label: 'Blau', hex: '#3b82f6' },
{ id: 'purple', label: 'Lila', hex: '#a855f7' },
]
export function categoryHex(id: string | null | undefined): string | null {
if (!id) return null
return CATEGORY_COLORS.find((c) => c.id === id)?.hex ?? null
}
+42
View File
@@ -0,0 +1,42 @@
import { format, isThisYear, isToday } from 'date-fns'
const AVATAR_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#f59e0b',
'#10b981', '#06b6d4', '#3b82f6', '#ef4444',
]
export function initials(name: string): string {
const clean = name.trim()
if (!clean) return '?'
const parts = clean.split(/\s+/).filter(Boolean)
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
export function avatarColor(seed: string): string {
let hash = 0
for (let i = 0; i < seed.length; i++) {
hash = seed.charCodeAt(i) + ((hash << 5) - hash)
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]
}
export function formatListDate(timestamp: number): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
if (isToday(date)) return format(date, 'HH:mm')
if (isThisYear(date)) return format(date, 'd. MMM')
return format(date, 'd. MMM yyyy')
}
export function formatFullDate(rawDate: string): string {
const date = new Date(rawDate)
if (Number.isNaN(date.getTime())) return rawDate
return format(date, 'EEEE, d. MMMM yyyy, HH:mm')
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
+86
View File
@@ -0,0 +1,86 @@
import type { Protocol } from '../types'
export interface ProviderPreset {
id: string
label: string
color: string
protocol: Protocol
incoming_host: string
incoming_port: number
smtp_host: string
smtp_port: number
smtp_starttls: boolean
hint?: string
}
export const providerPresets: ProviderPreset[] = [
{
id: 'gmail',
label: 'Google Mail',
color: '#ea4335',
protocol: 'imap',
incoming_host: 'imap.gmail.com',
incoming_port: 993,
smtp_host: 'smtp.gmail.com',
smtp_port: 465,
smtp_starttls: false,
hint: 'Nutze ein App-Passwort (Google-Konto -> Sicherheit -> App-Passwörter), nicht dein normales Passwort.',
},
{
id: 'outlook',
label: 'Outlook / Microsoft 365',
color: '#0078d4',
protocol: 'imap',
incoming_host: 'outlook.office365.com',
incoming_port: 993,
smtp_host: 'smtp.office365.com',
smtp_port: 587,
smtp_starttls: true,
},
{
id: 'yahoo',
label: 'Yahoo Mail',
color: '#6001d2',
protocol: 'imap',
incoming_host: 'imap.mail.yahoo.com',
incoming_port: 993,
smtp_host: 'smtp.mail.yahoo.com',
smtp_port: 465,
smtp_starttls: false,
hint: 'Erfordert ein App-Passwort aus den Yahoo-Kontoeinstellungen.',
},
{
id: 'icloud',
label: 'iCloud Mail',
color: '#3693f3',
protocol: 'imap',
incoming_host: 'imap.mail.me.com',
incoming_port: 993,
smtp_host: 'smtp.mail.me.com',
smtp_port: 587,
smtp_starttls: true,
hint: 'Erfordert ein app-spezifisches Passwort von appleid.apple.com.',
},
{
id: 'custom-imap',
label: 'Custom IMAP',
color: '#64748b',
protocol: 'imap',
incoming_host: '',
incoming_port: 993,
smtp_host: '',
smtp_port: 587,
smtp_starttls: true,
},
{
id: 'custom-pop3',
label: 'Custom POP3',
color: '#64748b',
protocol: 'pop3',
incoming_host: '',
incoming_port: 995,
smtp_host: '',
smtp_port: 587,
smtp_starttls: true,
},
]
+7
View File
@@ -0,0 +1,7 @@
export function htmlIsEmpty(html: string): boolean {
return !html || html.replace(/<[^>]*>/g, '').trim().length === 0
}
export function escapeHtml(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]!)
}
+28
View File
@@ -0,0 +1,28 @@
export interface AppSettings {
desktop: boolean
popup: boolean
composePopup: boolean
}
const KEY = 'mailclient.notifySettings'
const DEFAULTS: AppSettings = { desktop: true, popup: true, composePopup: false }
export function loadAppSettings(): AppSettings {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return DEFAULTS
const parsed = JSON.parse(raw) as Partial<AppSettings>
return {
desktop: parsed.desktop ?? DEFAULTS.desktop,
popup: parsed.popup ?? DEFAULTS.popup,
composePopup: parsed.composePopup ?? DEFAULTS.composePopup,
}
} catch {
return DEFAULTS
}
}
export function saveAppSettings(v: AppSettings) {
localStorage.setItem(KEY, JSON.stringify(v))
}
+92
View File
@@ -0,0 +1,92 @@
export interface Signature {
id: string
name: string
body: string
}
interface AccountSignatures {
signatures: Signature[]
defaultId: string | null
}
const KEY = 'mailclient.signatures.v2'
const LEGACY_KEY = 'mailclient.signatures'
function migrateLegacy(): Record<string, AccountSignatures> {
const out: Record<string, AccountSignatures> = {}
try {
const raw = localStorage.getItem(LEGACY_KEY)
if (!raw) return out
const legacy = JSON.parse(raw) as Record<string, string>
for (const [accountId, body] of Object.entries(legacy)) {
if (!body.trim()) continue
const id = crypto.randomUUID()
out[accountId] = { signatures: [{ id, name: 'Standard', body }], defaultId: id }
}
localStorage.setItem(KEY, JSON.stringify(out))
} catch {
// ignore malformed legacy data
}
return out
}
function loadAll(): Record<string, AccountSignatures> {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return migrateLegacy()
return JSON.parse(raw) as Record<string, AccountSignatures>
} catch {
return {}
}
}
function saveAll(all: Record<string, AccountSignatures>) {
localStorage.setItem(KEY, JSON.stringify(all))
}
export function listSignatures(accountId: string): Signature[] {
return loadAll()[accountId]?.signatures ?? []
}
export function getDefaultSignature(accountId: string): Signature | null {
const acc = loadAll()[accountId]
if (!acc || acc.signatures.length === 0) return null
return acc.signatures.find((s) => s.id === acc.defaultId) ?? acc.signatures[0]
}
export function getDefaultSignatureId(accountId: string): string | null {
return loadAll()[accountId]?.defaultId ?? null
}
export function saveSignatureList(accountId: string, signatures: Signature[], defaultId: string | null) {
const all = loadAll()
if (signatures.length === 0) {
delete all[accountId]
} else {
const resolvedDefault = signatures.some((s) => s.id === defaultId) ? defaultId : signatures[0].id
all[accountId] = { signatures, defaultId: resolvedDefault }
}
saveAll(all)
}
/// Back-compat helper: the single "signature text" a fresh compose should
/// start with — the account's default signature body, or empty.
export function loadSignature(accountId: string): string {
return getDefaultSignature(accountId)?.body ?? ''
}
/// Sets the body of the account's default signature (creating a "Standard"
/// signature if none exists yet), leaving any other named signatures intact.
/// Used by the simple single-field signature editor in account settings.
export function saveSignature(accountId: string, body: string) {
const existing = listSignatures(accountId)
const defaultId = getDefaultSignatureId(accountId)
const current = existing.find((s) => s.id === defaultId) ?? existing[0]
if (current) {
const next = existing.map((s) => (s.id === current.id ? { ...s, body } : s))
saveSignatureList(accountId, next, current.id)
} else if (body.trim()) {
const sig: Signature = { id: crypto.randomUUID(), name: 'Standard', body }
saveSignatureList(accountId, [sig], sig.id)
}
}
+52
View File
@@ -0,0 +1,52 @@
export type Theme = 'light' | 'dark' | 'system'
export type Density = 'comfortable' | 'compact'
const THEME_KEY = 'mailclient.theme'
const DENSITY_KEY = 'mailclient.density'
const FAVORITES_KEY = 'mailclient.favorites'
const CONVERSATIONS_KEY = 'mailclient.conversations'
export function loadTheme(): Theme {
const v = localStorage.getItem(THEME_KEY)
return v === 'light' || v === 'dark' || v === 'system' ? v : 'system'
}
export function saveTheme(t: Theme) {
localStorage.setItem(THEME_KEY, t)
}
export function applyTheme(t: Theme) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const dark = t === 'dark' || (t === 'system' && prefersDark)
document.documentElement.classList.toggle('dark', dark)
}
export function loadDensity(): Density {
const v = localStorage.getItem(DENSITY_KEY)
return v === 'compact' ? 'compact' : 'comfortable'
}
export function saveDensity(d: Density) {
localStorage.setItem(DENSITY_KEY, d)
}
export function loadFavoritesRaw<T>(): T[] {
try {
const raw = localStorage.getItem(FAVORITES_KEY)
return raw ? (JSON.parse(raw) as T[]) : []
} catch {
return []
}
}
export function saveFavoritesRaw<T>(favorites: T[]) {
localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites))
}
export function loadConversationsEnabled(): boolean {
return localStorage.getItem(CONVERSATIONS_KEY) === '1'
}
export function saveConversationsEnabled(enabled: boolean) {
localStorage.setItem(CONVERSATIONS_KEY, enabled ? '1' : '0')
}