Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/159.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Give Sentient a task by forwarding an email.** Turn on Tasks by email in Settings > Tasks and pick your Sentient address (for example you+sentient@gmail.com). Forward or copy an email to it and Sentient plans a task from it, which waits for your OK. Only the people you allow can do this, checked by their real email address and never by the name shown next to it, and anything Sentient would send because of the email still asks you first, saying where each address came from.
1 change: 1 addition & 0 deletions changes/159.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**A sender's name can no longer pose as another address.** An email whose name reads like a different address (for example "Maya Rao <maya@example.com>" sent from another address) is now always treated as coming from its real address, in privacy filters, task triggers and safety checks.
1 change: 1 addition & 0 deletions desktop/src/features/settings/SchemaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const LABELS: Record<string, FieldLabel> = {
'tasks.away_pause_days': { label: 'Pause routines when you are away for', unit: 'days' },
'tasks.away_pause_after_results': { label: 'Or when unseen results reach', unit: 'results' },
'tasks.away_ask_hours': { label: 'Ask before pausing, then wait', unit: 'hours' },
'tasks.email': { hidden: true }, // its own section: Tasks by email (#159)
// proactivity
'proactivity.enabled': { label: 'Proactive suggestions' },
'proactivity.poll_interval_minutes': { label: 'Check connected apps every', unit: 'min' },
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/features/settings/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ export const SETTINGS_SECTIONS: SettingsSectionMeta[] = [
{
id: 'tasks',
label: 'Tasks',
description: 'Long-running work, scheduling and plan approval.',
description: 'Long-running work, scheduling, plan approval and tasks by email.',
icon: IconListCheck,
keywords: ['scheduler', 'runs', 'swarm', 'plan', 'timeout', 'away', 'pause', 'routines', 'vacation'],
keywords: ['scheduler', 'runs', 'swarm', 'plan', 'timeout', 'away', 'pause', 'routines', 'vacation', 'email', 'forward', 'tasks by email', 'sentient address'],
schemaSections: ['tasks']
},
{
Expand Down
165 changes: 165 additions & 0 deletions desktop/src/features/settings/sections/EmailTasks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* Tasks by email (#159): forward or copy an email to your Sentient address and Sentient plans a task from it.
* Settings live in `tasks.email` (PATCH /api/config); what happened to the latest emails comes from
* `GET /api/tasks/email`.
*/
import { IconArrowRight, IconBan, IconCircleCheck, IconMailForward } from '@tabler/icons-react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { Alert, Badge, Button, FormRow, FormSection, Input, Skeleton, StatusDot, Switch } from '@/components/ui'
import { useConfigEditor } from '@/hooks/config'
import { useEmailTasks } from '@/hooks/tasks'
import { isNotImplemented } from '@/lib/api'
import type { EmailTaskLogEntry, EmailTasksConfig } from '@/lib/types'
import { relativeTime } from '@/lib/utils'

const KEYWORDS = 'tasks by email forward copy plus address sentient address who can send allowed senders label folder spoofed'
const EMPTY: EmailTasksConfig = { enabled: false, address: '', label: '', allowed_senders: [] }
const APP_LABELS: Record<string, string> = { gmail: 'Gmail', email_imap: 'Email (IMAP)' }

/** Comma, semicolon or space separated addresses, lower-cased and without repeats. */
function parseAddresses(text: string): string[] {
return Array.from(new Set(text.split(/[\s,;]+/).map((a) => a.trim().toLowerCase()).filter((a) => a.includes('@'))))
}

/** A text field that saves when you leave it or press Enter, so a half-typed address is never saved. */
function CommitInput({ value, onCommit, ...props }: { value: string; onCommit: (v: string) => void } & Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange'>) {
const [draft, setDraft] = useState(value)
useEffect(() => setDraft(value), [value])
const commit = () => {
if (draft.trim() !== value) onCommit(draft.trim())
}
return <Input {...props} value={draft} onChange={(e) => setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && commit()} />
}

export function EmailTasksSection({ query }: { query?: string }) {
const navigate = useNavigate()
const { config, patch } = useConfigEditor()
const status = useEmailTasks()
const cfg: EmailTasksConfig = { ...EMPTY, ...((config?.tasks?.email as EmailTasksConfig | undefined) ?? {}) }
const q = (query ?? '').trim().toLowerCase()
if (q && !KEYWORDS.includes(q)) return null

const s = status.data
const missing = status.isError && isNotImplemented(status.error)
const suggested = s?.suggested_address ?? ''
const own = s?.own_addresses ?? []
const set = (p: Partial<EmailTasksConfig>) => patch({ tasks: { email: p } }, { immediate: true })
const noAccount = !!s && s.watching.length === 0

return (
<FormSection
title="Tasks by email"
description="Forward or copy an email to your Sentient address and Sentient plans a task from it. The plan waits for your OK, and anything it would send because of the email asks you first."
>
<FormRow label="Take tasks by email" description={cfg.enabled ? (s?.active ? 'On. Email to your Sentient address becomes a task.' : 'Pick your Sentient address below to start.') : 'Off. Turn it on to pick an address.'}>
<Switch
checked={cfg.enabled}
disabled={missing}
onCheckedChange={(on) => set(on && !cfg.address && suggested ? { enabled: true, address: suggested } : { enabled: on })}
/>
</FormRow>
{cfg.enabled && (
<>
{noAccount && (
<div className="px-4 py-3">
<Alert tone="warning" title="Connect your email first">
New email reaches Sentient through Gmail or Email (IMAP). Connect one in Integrations.
<Button size="sm" variant="ghost" className="ml-1" rightIcon={<IconArrowRight size={14} />} onClick={() => navigate('/integrations')}>
Integrations
</Button>
</Alert>
</div>
)}
<FormRow
label="Your Sentient address"
stack
description={
<>
Email sent or copied to this address becomes a task. Most email services deliver an address with +something to your normal inbox, so{' '}
{suggested ? <b className="font-medium text-fg-muted">{suggested}</b> : 'you+sentient@gmail.com'} reaches you without a new account.
</>
}
>
<div className="flex w-full items-center gap-2">
<CommitInput value={cfg.address} placeholder={suggested || 'you+sentient@gmail.com'} onCommit={(v) => set({ address: v.toLowerCase() })} aria-label="Your Sentient address" />
{suggested && cfg.address !== suggested && (
<Button size="sm" variant="secondary" onClick={() => set({ address: suggested })}>
Use {suggested}
</Button>
)}
</div>
</FormRow>
<FormRow label="Label or folder" description="Optional. Email with this Gmail label, or in this email folder, becomes a task too. Over IMAP, also add the folder to the ones the account watches.">
<CommitInput value={cfg.label} placeholder="Sentient" onCommit={(v) => set({ label: v })} aria-label="Label or folder" className="w-56" />
</FormRow>
<FormRow
label="Who can send tasks"
stack
description={
<>
Your own addresses can always send tasks{own.length ? ` (${own.join(', ')})` : ''}. Add others here. Sentient checks the real email address, never the name shown next to it.
</>
}
>
<CommitInput
value={cfg.allowed_senders.join(', ')}
placeholder="a.colleague@example.com, a.friend@example.com"
onCommit={(v) => set({ allowed_senders: parseAddresses(v) })}
aria-label="Who can send tasks"
/>
</FormRow>
<RecentEmails entries={s?.recent ?? []} loading={status.isLoading} onOpen={(id) => navigate(`/tasks?task=${encodeURIComponent(id)}`)} />
</>
)}
</FormSection>
)
}

function RecentEmails({ entries, loading, onOpen }: { entries: EmailTaskLogEntry[]; loading: boolean; onOpen: (taskId: string) => void }) {
return (
<div className="px-4 py-3.5">
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-fg">
<IconMailForward size={15} className="text-fg-subtle" />
Latest emails to your Sentient address
</div>
{loading ? (
<Skeleton className="h-12" />
) : !entries.length ? (
<p className="text-xs text-fg-subtle">Nothing yet. Forward an email to your Sentient address to try it.</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{entries.slice(0, 8).map((e, i) => {
const made = e.outcome === 'task'
const shown = e.from.replace(/\s+/g, ' ').trim()
const really = e.sender && !shown.toLowerCase().endsWith(`<${e.sender}>`) && shown.toLowerCase() !== e.sender
return (
<li key={`${e.at}-${i}`} className="flex items-start gap-2.5 px-3 py-2.5">
{made ? <IconCircleCheck size={16} className="mt-0.5 shrink-0 text-success" /> : <IconBan size={16} className="mt-0.5 shrink-0 text-danger" />}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-fg">
<span className="truncate">{shown || 'Unknown sender'}</span>
{e.sender && (really || !made) && <Badge size="xs" tone={made ? 'neutral' : 'danger'}>really {e.sender}</Badge>}
<span className="text-2xs text-fg-faint">
{APP_LABELS[e.app] ?? e.app} · {relativeTime(e.at)}
</span>
</div>
<div className="truncate text-xs text-fg-subtle">{e.subject || '(no subject)'}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-xs">
<StatusDot tone={made ? 'success' : 'danger'} />
<span className={made ? 'text-fg-muted' : 'text-danger'}>{made ? 'Made a task' : `Ignored. ${e.reason ?? ''}`}</span>
</div>
</div>
{made && e.task_id && (
<Button size="sm" variant="ghost" rightIcon={<IconArrowRight size={14} />} onClick={() => onOpen(e.task_id as string)}>
Open
</Button>
)}
</li>
)
})}
</ul>
)}
</div>
)
}
8 changes: 7 additions & 1 deletion desktop/src/features/settings/sections/SchemaSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { WAKE_KEYS, WakeWordSection } from './AbilitySections'
import { ApprovalGrantsSection } from './ApprovalGrants'
import { ApprovalRulesSection } from './ApprovalRules'
import { DICTATION_KEYS, DictationSection } from './Dictation'
import { EmailTasksSection } from './EmailTasks'

export function MemorySection({ query }: SectionProps) {
const navigate = useNavigate()
Expand All @@ -36,7 +37,12 @@ export function MemorySection({ query }: SectionProps) {
}

export function TasksSection({ query }: SectionProps) {
return <SchemaForm section="tasks" title="Running tasks" filter={query} />
return (
<div className="space-y-8">
<EmailTasksSection query={query} />
<SchemaForm section="tasks" title="Running tasks" filter={query} />
</div>
)
}

export function ProactivitySection({ query }: SectionProps) {
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/tasks/TaskToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const SOURCE_OPTIONS = [
{ value: 'all', label: 'Any source' },
...(Object.keys(SOURCE_META) as TaskSource[]).map((s) => {
const I = SOURCE_META[s].icon
return { value: s, label: s === 'you' ? 'Created by you' : s === 'chat' ? 'From chat' : 'Proactive', icon: <I size={14} /> }
return { value: s, label: s === 'you' ? 'Created by you' : s === 'chat' ? 'From chat' : s === 'email' ? 'From email' : 'Proactive', icon: <I size={14} /> }
})
]

Expand Down
29 changes: 29 additions & 0 deletions desktop/src/features/tasks/detail/TaskDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
IconDots,
IconFileDescription,
IconFlask,
IconMailForward,
IconHelpCircle,
IconPencil,
IconPlayerPause,
Expand Down Expand Up @@ -570,6 +571,8 @@ function Overview({ task, tz, ops, missing, layout, onShowRuns }: { task: Task;
<SetupChecklist task={task} tz={tz} ops={ops} canTry={canTryFirst(task) && missing.length === 0} onTry={() => void ops.dryRun(id).then((t) => t && onShowRuns())} />
)}

{task.original_context?.source === 'email' && <EmailOriginCard task={task} />}

<DescriptionCard task={task} onSave={(description) => ops.update(id, { description }, 'Description saved')} />

<ScheduleCard task={task} tz={tz} onSave={(schedule) => ops.update(id, { schedule }, 'Schedule updated')} saving={ops.isBusy('update', id)} />
Expand Down Expand Up @@ -620,6 +623,32 @@ function Overview({ task, tz, ops, missing, layout, onShowRuns }: { task: Task;
)
}

/** Where a task by email came from (#159): who sent it, its subject and how Sentient treats its text. */
function EmailOriginCard({ task }: { task: Task }) {
const email = (task.original_context?.email ?? {}) as { from?: string; sender_email?: string; subject?: string; date?: string; app_name?: string; to?: string }
const from = (email.from ?? email.sender_email ?? '').replace(/\s+/g, ' ').trim()
return (
<section className="space-y-2">
<div className="flex items-center gap-2">
<IconMailForward size={16} className="text-fg-subtle" />
<h3 className="text-sm font-semibold text-fg">From an email</h3>
{email.app_name && <span className="text-xs text-fg-subtle">{email.app_name}</span>}
</div>
<div className="rounded-lg border border-border bg-surface px-3.5 py-3 text-sm">
<div className="selectable text-fg">{email.subject || '(no subject)'}</div>
<div className="selectable mt-0.5 text-xs text-fg-subtle">
{from || 'Unknown sender'}
{email.sender_email && !from.toLowerCase().includes(email.sender_email) ? ` (really ${email.sender_email})` : ''}
{email.date ? ` · ${formatDateTime(email.date)}` : ''}
</div>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
Sentient planned this from the email. Its text says what to do, but addresses and links in it don't count as yours, so anything this task would send asks you first and says where each address came from.
</p>
</div>
</section>
)
}

function DescriptionCard({ task, onSave }: { task: Task; onSave: (d: string) => Promise<unknown> }) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(task.description)
Expand Down
7 changes: 5 additions & 2 deletions desktop/src/features/tasks/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
IconUser,
IconMessageCircle,
IconBulb,
IconMailForward,
IconRadar,
type Icon
} from '@tabler/icons-react'
Expand Down Expand Up @@ -110,18 +111,20 @@ export function taskKind(task: Pick<Task, 'task_type' | 'schedule'>): TaskKind {
}

// ---------------------------------------------------------------------------- sources
export type TaskSource = 'you' | 'chat' | 'proactive'
export type TaskSource = 'you' | 'chat' | 'proactive' | 'email'

export const SOURCE_META: Record<TaskSource, { label: string; icon: Icon }> = {
you: { label: 'You', icon: IconUser },
chat: { label: 'Chat', icon: IconMessageCircle },
proactive: { label: 'Proactive', icon: IconBulb }
proactive: { label: 'Proactive', icon: IconBulb },
email: { label: 'Email', icon: IconMailForward }
}

export function taskSource(task: Pick<Task, 'original_context'>): TaskSource {
const s = task.original_context?.source
if (s === 'chat') return 'chat'
if (s === 'proactive' || s === 'trigger') return 'proactive'
if (s === 'email') return 'email'
return 'you'
}

Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/tasks/tools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface ToolIdentity {

const PLUGINS: Record<string, ToolIdentity> = {
gmail: { icon: IconMail, label: 'Gmail', tint: 'text-[#ea4335]' },
email_imap: { icon: IconMail, label: 'Email (IMAP)' },
gcalendar: { icon: IconCalendarEvent, label: 'Google Calendar', tint: 'text-[#4285f4]' },
gdrive: { icon: IconBrandGoogleDrive, label: 'Google Drive', tint: 'text-[#1fa463]' },
gdocs: { icon: IconFileText, label: 'Google Docs', tint: 'text-[#4285f4]' },
Expand Down Expand Up @@ -71,6 +72,7 @@ const PLUGINS: Record<string, ToolIdentity> = {
}

const PREFIXES: Array<[RegExp, string]> = [
[/^email_imap/, 'email_imap'], // before the gmail rule: an IMAP plan step is not Gmail (#159)
[/^(gmail|mail|email)/, 'gmail'],
[/^(gcal|gcalendar|calendar)/, 'gcalendar'],
[/^gdrive/, 'gdrive'],
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/hooks/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export const qk = {
detail: (id: string) => ['tasks', id] as const,
runEvents: (id: string, runId: string) => ['tasks', id, 'runs', runId, 'events'] as const,
/** Kept outside the ['tasks'] prefix so task list updates never touch it. */
away: ['tasks-away'] as const
away: ['tasks-away'] as const,
/** Tasks by email (#159), outside the ['tasks'] prefix too. */
email: ['tasks-email'] as const
},

integrations: {
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/hooks/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export function useAway() {
return useQuery({ queryKey: qk.tasks.away, queryFn: api.tasks.away, retry: noRetryWhenMissing })
}

/** Tasks by email (#159): the address, who may send tasks and the latest emails. Checked every half minute. */
export function useEmailTasks() {
return useQuery({ queryKey: qk.tasks.email, queryFn: () => api.tasks.email(), retry: noRetryWhenMissing, refetchInterval: 30_000 })
}

export function useAwayActions() {
const qc = useQueryClient()
const refresh = () => {
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
ForgetCounts,
Forgotten,
AwayStatus,
EmailTasksStatus,
BackupEntry,
BackupInspect,
BackupOverview,
Expand Down Expand Up @@ -605,6 +606,8 @@ export const api = {
away: () => http.get<AwayStatus>('/api/tasks/away'),
awayResume: () => http.post<{ resumed: number; status: AwayStatus }>('/api/tasks/away/resume'),
awayKeep: () => http.post<AwayStatus>('/api/tasks/away/keep'),
/** Tasks by email (#159). */
email: (limit = 20) => http.get<EmailTasksStatus>(`/api/tasks/email?limit=${limit}`),
get: (id: string) => http.get<Task>(`/api/tasks/${enc(id)}`),
create: (body: TaskCreateRequest) => http.post<Task>('/api/tasks', body),
preview: (prompt: string) => http.post<TaskPreview>('/api/tasks/preview', { prompt }),
Expand Down
Loading
Loading