Feat: enable keyword reordering #174 and multi-tag support per email #173

This commit is contained in:
Linus Rath
2026-04-10 17:31:13 +02:00
parent 3e85e07363
commit 4f54f768e8
8 changed files with 255 additions and 153 deletions
+15 -9
View File
@@ -830,16 +830,22 @@ export default function Home() {
const keywords = { ...email.keywords };
// Remove old label and legacy color tags - set to false for JMAP to remove them
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
if (color === null) {
// Remove all label/color tags
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
}
});
} else {
const jmapKey = `$label:${color}`;
if (keywords[jmapKey] === true) {
// Toggle off if already active
keywords[jmapKey] = false;
} else {
// Add the tag without disturbing others
keywords[jmapKey] = true;
}
});
// Add new label tag if specified (using new $label: prefix)
if (color) {
keywords[`$label:${color}`] = true;
}
// Update email keywords via JMAP
+30 -26
View File
@@ -89,17 +89,18 @@ const getMailboxIcon = (role?: string) => {
}
};
// Get current label/color from email keywords (supports both $label: and legacy $color:)
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
// Get all active label/color tag IDs from email keywords
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:")
? key.slice("$label:".length)
: key.slice("$color:".length);
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return null;
return tags;
};
export function EmailContextMenu({
@@ -137,7 +138,7 @@ export function EmailContextMenu({
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isDraft = email.keywords?.['$draft'] === true;
const currentColor = getCurrentColor(email.keywords);
const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
@@ -306,24 +307,27 @@ export function EmailContextMenu({
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{currentColor === option.value && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
))}
{currentColor && (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{isActive && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
);
})}
{currentColors.length > 0 && (
<>
<ContextMenuSeparator />
<ContextMenuItem
+15 -11
View File
@@ -15,7 +15,7 @@ import { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { EmailHoverActions } from "./email-hover-actions";
import { getEmailColorTag } from "@/lib/thread-utils";
import { getEmailColorTags } from "@/lib/thread-utils";
interface EmailListItemProps {
email: Email;
@@ -51,9 +51,11 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tag using keyword definitions from settings
const colorTagId = getEmailColorTag(email.keywords);
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null;
// Resolve color tags using keyword definitions from settings
const colorTagIds = getEmailColorTags(email.keywords);
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
// Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality
@@ -199,7 +201,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</>
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -249,15 +253,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
)}
))}
<span className={cn(
"text-xs tabular-nums",
isUnread
+96 -76
View File
@@ -193,16 +193,17 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment';
};
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:")
? key.slice("$label:".length)
: key.slice("$color:".length);
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return null;
return tags;
};
// Helper function to format recipients with contextual display
@@ -933,7 +934,8 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentColor = getCurrentColor(email?.keywords);
const currentColors = getCurrentColors(email?.keywords);
const currentColor = currentColors[0] ?? null;
// S/MIME state
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
@@ -3055,43 +3057,51 @@ export function EmailViewer({
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn(
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
currentColor && "bg-muted/50"
currentColors.length > 0 && "bg-muted/50"
)}
title={t('set_color')}
>
{(() => {
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null;
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
return dotClass ? (
<>
<span className={cn("w-3 h-3 rounded-full", dotClass)} />
{showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>}
</>
) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
);
})()}
{currentColors.length > 0 ? (
<>
<span className="flex items-center gap-0.5">
{currentColors.slice(0, 3).map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId);
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
})}
</span>
{showToolbarLabels && currentColors.length === 1 && (
<span className="text-xs font-medium text-foreground">
{emailKeywords.find(k => k.id === currentColors[0])?.label}
</span>
)}
</>
) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
)}
</button>
{tagMenuOpen && (
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
))}
{currentColor && (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
@@ -3299,21 +3309,24 @@ export function EmailViewer({
</button>
{moreMenuSub === 'tag' && (
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
))}
{currentColor && (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
@@ -3489,21 +3502,24 @@ export function EmailViewer({
<>
<div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{currentColor === option.value && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
</button>
))}
{currentColor && (
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
@@ -3649,14 +3665,18 @@ export function EmailViewer({
)} />
</button>
)}
{/* Color tag dot */}
{currentColor && (() => {
const kw = emailKeywords.find(k => k.id === currentColor);
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
return dotClass ? (
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
) : null;
})()}
{/* Color tag dots */}
{currentColors.length > 0 && (
<span className="flex items-center gap-0.5">
{currentColors.map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId);
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
return dotClass ? (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
) : null;
})}
</span>
)}
{isImportant && (
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
{t('important')}
+17 -12
View File
@@ -9,7 +9,7 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item";
@@ -67,9 +67,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color and keyword definition from keyword definitions if not passed directly
const tagId = getEmailColorTag(email.keywords);
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null;
// Resolve color tags using keyword definitions
const tagIds = getEmailColorTags(email.keywords);
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = (() => {
if (colorTag) return colorTag;
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
@@ -212,7 +213,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</>
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />}
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -255,15 +258,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDef && (
<span className={cn(
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label}
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
)}
))}
<span className={cn(
"text-xs tabular-nums",
isUnread
@@ -584,7 +587,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</>
)}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
{keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)}
<span className={cn(
'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
+62 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import React, { useState } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
@@ -41,16 +41,39 @@ function KeywordRow({
keyword,
onEdit,
onDelete,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
isDragOver,
isDragging,
}: {
keyword: KeywordDefinition;
onEdit: () => void;
onDelete: () => void;
onDragStart: () => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: () => void;
onDragEnd: () => void;
isDragOver: boolean;
isDragging: boolean;
}) {
const t = useTranslations("settings.keywords");
const palette = KEYWORD_PALETTE[keyword.color];
return (
<div className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group">
<div
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded-md border bg-background group transition-opacity",
isDragging ? "opacity-40" : "opacity-100",
isDragOver ? "border-primary" : "border-border"
)}
>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
@@ -166,9 +189,39 @@ export function KeywordSettings() {
const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false);
const [isMigrating, setIsMigrating] = useState(false);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const existingIds = emailKeywords.map((k) => k.id);
const handleDragStart = (index: number) => {
setDragIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (index !== dragOverIndex) setDragOverIndex(index);
};
const handleDrop = (index: number) => {
if (dragIndex === null || dragIndex === index) {
setDragIndex(null);
setDragOverIndex(null);
return;
}
const reordered = [...emailKeywords];
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(index, 0, moved);
reorderKeywords(reordered);
setDragIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = () => {
setDragIndex(null);
setDragOverIndex(null);
};
const handleAdd = (keyword: KeywordDefinition) => {
addKeyword(keyword);
setIsAdding(false);
@@ -220,7 +273,7 @@ export function KeywordSettings() {
{t("migrating")}
</div>
)}
{emailKeywords.map((keyword) =>
{emailKeywords.map((keyword, index) =>
editingId === keyword.id ? (
<KeywordEditForm
key={keyword.id}
@@ -238,6 +291,12 @@ export function KeywordSettings() {
setIsAdding(false);
}}
onDelete={() => handleDelete(keyword.id)}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
onDragEnd={handleDragEnd}
isDragOver={dragOverIndex === index && dragIndex !== index}
isDragging={dragIndex === index}
/>
)
)}
+1 -8
View File
@@ -78,14 +78,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
const email = currentEmails.find(em => em.id === emailId);
const keywords = { ...(email?.keywords || {}) };
// Remove old label/color keywords
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
}
});
// Add the new tag
// Add the tag without removing existing ones
keywords[`$label:${tagId}`] = true;
await client.updateEmailKeywords(emailId, keywords);
+19 -8
View File
@@ -152,21 +152,32 @@ export const KEYWORD_PREFIX = "$label:";
export const KEYWORD_PREFIX_LEGACY = "$color:";
/**
* Gets label/color tag from email keywords (if any).
* Gets all active label/color tag IDs from email keywords.
* Reads both the current $label: prefix and the legacy $color: prefix.
*/
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
if (!keywords) return null;
export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
return key.startsWith(KEYWORD_PREFIX)
? key.slice(KEYWORD_PREFIX.length)
: key.slice(KEYWORD_PREFIX_LEGACY.length);
tags.push(
key.startsWith(KEYWORD_PREFIX)
? key.slice(KEYWORD_PREFIX.length)
: key.slice(KEYWORD_PREFIX_LEGACY.length)
);
}
}
return tags;
}
return null;
/**
* Gets label/color tag from email keywords (if any).
* Reads both the current $label: prefix and the legacy $color: prefix.
* @deprecated Use getEmailColorTags for multi-tag support.
*/
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
const tags = getEmailColorTags(keywords);
return tags.length > 0 ? tags[0] : null;
}
/**