diff --git a/frappe-ui b/frappe-ui index 29c3c3a80..8feaedcef 160000 --- a/frappe-ui +++ b/frappe-ui @@ -1 +1 @@ -Subproject commit 29c3c3a808bdf583408ba13e2484206f8a21e0c0 +Subproject commit 8feaedcefced2ac341ec2390757fed84bfca6ffa diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 460b4f652..81a844a2f 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -31,6 +31,8 @@ declare module 'vue' { AttachmentCapsule: typeof import('./src/components/AttachmentCapsule.vue')['default'] AttachmentViewer: typeof import('./src/components/AttachmentViewer.vue')['default'] AudioIcon: typeof import('./src/components/Icons/AudioIcon.vue')['default'] + CalendarLogo: typeof import('./src/components/Icons/CalendarLogo.vue')['default'] + CalendarSidebar: typeof import('./src/components/CalendarSidebar.vue')['default'] ChangePasswordModal: typeof import('./src/components/Modals/ChangePasswordModal.vue')['default'] ComposeMailEditor: typeof import('./src/components/ComposeMailEditor.vue')['default'] ComposeMailToolbar: typeof import('./src/components/ComposeMailToolbar.vue')['default'] @@ -50,6 +52,11 @@ declare module 'vue' { EditSignatureModal: typeof import('./src/components/Modals/EditSignatureModal.vue')['default'] EmailContent: typeof import('./src/components/EmailContent.vue')['default'] EmojiPicker: typeof import('./src/components/EmojiPicker.vue')['default'] + EventAlertList: typeof import('./src/components/EventAlertList.vue')['default'] + EventModal: typeof import('./src/components/Modals/EventModal.vue')['default'] + EventParticipantList: typeof import('./src/components/EventParticipantList.vue')['default'] + EventPopoverContent: typeof import('./src/components/EventPopoverContent.vue')['default'] + EventRepeatSettingsModal: typeof import('./src/components/Modals/EventRepeatSettingsModal.vue')['default'] ExportSettings: typeof import('./src/components/Settings/ExportSettings.vue')['default'] FrappeLogo: typeof import('./src/components/Icons/FrappeLogo.vue')['default'] HeaderActions: typeof import('./src/components/HeaderActions.vue')['default'] diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 23dbafa9f..ac5bc3ff7 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -75,8 +75,8 @@ import { useRoute, useRouter } from 'vue-router' import { useStorage } from '@vueuse/core' import { Button, Dropdown, Sidebar, SidebarItem, createResource } from 'frappe-ui' -import { toTitleCase } from '@/utils' import { useScreenSize, useSidebar } from '@/utils/composables' +import { toTitleCase } from '@/utils/format' import { sessionStore } from '@/stores/session' import { userStore } from '@/stores/user' import MailLogo from '@/components/Icons/MailLogo.vue' diff --git a/frontend/src/components/CalendarSidebar.vue b/frontend/src/components/CalendarSidebar.vue new file mode 100644 index 000000000..7c5cadd4c --- /dev/null +++ b/frontend/src/components/CalendarSidebar.vue @@ -0,0 +1,81 @@ + + + diff --git a/frontend/src/components/ComposeMailEditor.vue b/frontend/src/components/ComposeMailEditor.vue index 3d6040606..16b8bcb7a 100644 --- a/frontend/src/components/ComposeMailEditor.vue +++ b/frontend/src/components/ComposeMailEditor.vue @@ -268,7 +268,6 @@ import { import { getAttachmentUrl } from '@/resources' import { - formatBytes, isOverlayPresent, processInlineImages, raiseToast, @@ -276,6 +275,7 @@ import { validateEmail, } from '@/utils' import { useScreenSize, useVisualViewport } from '@/utils/composables' +import { formatBytes } from '@/utils/format' import { CustomParagraphExtension } from '@/utils/text-editor' import { userStore } from '@/stores/user' import ComposeMailToolbar from '@/components/ComposeMailToolbar.vue' diff --git a/frontend/src/components/Controls/MultiselectInputControl.vue b/frontend/src/components/Controls/MultiselectInputControl.vue index 8ee96ecab..e251130f0 100644 --- a/frontend/src/components/Controls/MultiselectInputControl.vue +++ b/frontend/src/components/Controls/MultiselectInputControl.vue @@ -153,7 +153,6 @@ const mailContacts = createResource({ }), transform: (data) => data.map((option) => ({ label: option.full_name || option.email, value: option.email })), - auto: false, }) const debouncedSearch = useDebounceFn((text: string) => mailContacts.reload(text), 300) diff --git a/frontend/src/components/EventAlertList.vue b/frontend/src/components/EventAlertList.vue new file mode 100644 index 000000000..bb177783e --- /dev/null +++ b/frontend/src/components/EventAlertList.vue @@ -0,0 +1,99 @@ + + + diff --git a/frontend/src/components/EventParticipantList.vue b/frontend/src/components/EventParticipantList.vue new file mode 100644 index 000000000..63d90a934 --- /dev/null +++ b/frontend/src/components/EventParticipantList.vue @@ -0,0 +1,72 @@ + + diff --git a/frontend/src/components/EventPopoverContent.vue b/frontend/src/components/EventPopoverContent.vue new file mode 100644 index 000000000..828d49200 --- /dev/null +++ b/frontend/src/components/EventPopoverContent.vue @@ -0,0 +1,357 @@ + + + diff --git a/frontend/src/components/Icons/CalendarLogo.vue b/frontend/src/components/Icons/CalendarLogo.vue new file mode 100644 index 000000000..1c22405c8 --- /dev/null +++ b/frontend/src/components/Icons/CalendarLogo.vue @@ -0,0 +1,11 @@ + diff --git a/frontend/src/components/MailThread.vue b/frontend/src/components/MailThread.vue index c7283165e..a90ba237f 100644 --- a/frontend/src/components/MailThread.vue +++ b/frontend/src/components/MailThread.vue @@ -98,6 +98,7 @@ () diff --git a/frontend/src/components/Modals/ContactsModal.vue b/frontend/src/components/Modals/ContactsModal.vue index a37b6e89f..54dfa581d 100644 --- a/frontend/src/components/Modals/ContactsModal.vue +++ b/frontend/src/components/Modals/ContactsModal.vue @@ -43,7 +43,7 @@ import { createResource, } from 'frappe-ui' -import { extractNameFromEmail } from '@/utils' +import { extractNameFromEmail } from '@/utils/format' import { userStore } from '@/stores/user' const show = defineModel() diff --git a/frontend/src/components/Modals/EventModal.vue b/frontend/src/components/Modals/EventModal.vue new file mode 100644 index 000000000..ddf098ff5 --- /dev/null +++ b/frontend/src/components/Modals/EventModal.vue @@ -0,0 +1,608 @@ + + + diff --git a/frontend/src/components/Modals/EventRepeatSettingsModal.vue b/frontend/src/components/Modals/EventRepeatSettingsModal.vue new file mode 100644 index 000000000..85f12ad76 --- /dev/null +++ b/frontend/src/components/Modals/EventRepeatSettingsModal.vue @@ -0,0 +1,255 @@ + + + diff --git a/frontend/src/components/Modals/SearchModal.vue b/frontend/src/components/Modals/SearchModal.vue index 8fd1ff7af..86cada9f2 100644 --- a/frontend/src/components/Modals/SearchModal.vue +++ b/frontend/src/components/Modals/SearchModal.vue @@ -161,8 +161,8 @@ import { ChevronLeft, Paperclip, Search, SlidersHorizontal } from 'lucide-vue-ne import { Button, Dialog, FormControl, createResource } from 'frappe-ui' import { getAttachmentOptions, getReadStatusOptions } from '@/constants' -import { getFormattedDate } from '@/utils' import { useScreenSize } from '@/utils/composables' +import { getFormattedDate } from '@/utils/format' import { userStore } from '@/stores/user' import SearchMobileLayout from '@/components/SearchMobileLayout.vue' diff --git a/frontend/src/components/QuotaBar.vue b/frontend/src/components/QuotaBar.vue index c3459e5c4..a7c2b51c9 100644 --- a/frontend/src/components/QuotaBar.vue +++ b/frontend/src/components/QuotaBar.vue @@ -26,7 +26,7 @@ import { computed, inject } from 'vue' import { Cloud } from 'lucide-vue-next' import { createResource } from 'frappe-ui' -import { formatBytes } from '@/utils' +import { formatBytes } from '@/utils/format' const { isCollapsed } = defineProps<{ isCollapsed: boolean }>() diff --git a/frontend/src/pages/AddressBookView.vue b/frontend/src/pages/AddressBookView.vue index 738f86356..28e812d88 100644 --- a/frontend/src/pages/AddressBookView.vue +++ b/frontend/src/pages/AddressBookView.vue @@ -109,7 +109,8 @@ import { createResource, } from 'frappe-ui' -import { extractNameFromEmail, raiseToast } from '@/utils' +import { raiseToast } from '@/utils' +import { extractNameFromEmail } from '@/utils/format' import { userStore } from '@/stores/user' import DashboardCard from '@/components/DashboardCard.vue' import DashboardLayout from '@/components/DashboardLayout.vue' diff --git a/frontend/src/pages/ContactsView.vue b/frontend/src/pages/ContactsView.vue index 470b4b816..c3b7edf5c 100644 --- a/frontend/src/pages/ContactsView.vue +++ b/frontend/src/pages/ContactsView.vue @@ -54,7 +54,8 @@ import { createResource, } from 'frappe-ui' -import { extractNameFromEmail, raiseToast } from '@/utils' +import { raiseToast } from '@/utils' +import { extractNameFromEmail } from '@/utils/format' import DashboardLayout from '@/components/DashboardLayout.vue' import AddContactModal from '@/components/Modals/AddContactModal.vue' diff --git a/frontend/src/pages/MailExchangeView.vue b/frontend/src/pages/MailExchangeView.vue index d3a2db6d1..864c81fd6 100644 --- a/frontend/src/pages/MailExchangeView.vue +++ b/frontend/src/pages/MailExchangeView.vue @@ -53,7 +53,8 @@ import { useRouter } from 'vue-router' import { Download } from 'lucide-vue-next' import { Badge, Breadcrumbs, Dropdown, createResource } from 'frappe-ui' -import { formatBytes, getTheme } from '@/utils' +import { getTheme } from '@/utils' +import { formatBytes } from '@/utils/format' import CopyCode from '@/components/CopyCode.vue' const { id } = defineProps<{ id: string }>() diff --git a/frontend/src/pages/MailboxView.vue b/frontend/src/pages/MailboxView.vue index c2d4ff887..65759037a 100644 --- a/frontend/src/pages/MailboxView.vue +++ b/frontend/src/pages/MailboxView.vue @@ -307,15 +307,9 @@ import { toast, } from 'frappe-ui' -import { - getFormattedDate, - isMac, - raisePromiseToast, - raiseToast, - shouldIgnoreKeypress, - startResizing, -} from '@/utils' +import { isMac, raisePromiseToast, raiseToast, shouldIgnoreKeypress, startResizing } from '@/utils' import { useLayout, useScreenSize, useSidebar, useUndo } from '@/utils/composables' +import { getFormattedDate } from '@/utils/format' import { type MailboxRole, userStore } from '@/stores/user' import HeaderActions from '@/components/HeaderActions.vue' import NoMails from '@/components/Icons/NoMails.vue' diff --git a/frontend/src/pages/calendar/CalendarView.vue b/frontend/src/pages/calendar/CalendarView.vue new file mode 100644 index 000000000..7e64468dc --- /dev/null +++ b/frontend/src/pages/calendar/CalendarView.vue @@ -0,0 +1,148 @@ + + + diff --git a/frontend/src/router.ts b/frontend/src/router.ts index a288cbc37..23ffea318 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -158,6 +158,12 @@ const routes = [ props: true, meta: { isDashboard: true }, }, + { + path: '/calendar', + name: 'Calendar', + component: () => import('@/pages/calendar/CalendarView.vue'), + meta: { noLayout: true }, + }, ] const router = createRouter({ history: createWebHistory('/mail'), routes }) diff --git a/frontend/src/types/globals.d.ts b/frontend/src/types/globals.d.ts index 050691231..2fda8de7e 100644 --- a/frontend/src/types/globals.d.ts +++ b/frontend/src/types/globals.d.ts @@ -1,6 +1,6 @@ export {} -type TranslateFunction = (message: string, variables?: string[]) => string +type TranslateFunction = (message: string, variables?: (string | number)[]) => string declare global { const __: TranslateFunction } diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 000000000..5503d5db0 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,114 @@ +import { isEmail } from '@/utils' +import dayjs from '@/utils/dayjs' + +const DAYS_MAP: Record = { + su: 'Sunday', + mo: 'Monday', + tu: 'Tuesday', + we: 'Wednesday', + th: 'Thursday', + fr: 'Friday', + sa: 'Saturday', +} + +const getByDayMessage = (byDay?: { day: string; nthOfPeriod?: number }[]) => { + if (!byDay?.length) return '' + const [first] = byDay + + if (first.nthOfPeriod === -1) return __(' on the last {0}', [DAYS_MAP[first.day]]) + + if (first.nthOfPeriod != null) + return __(' on the {0} {1}', [getNthLabel(first.nthOfPeriod), DAYS_MAP[first.day]]) + + return __(' on {0}', [byDay.map((d) => DAYS_MAP[d.day]).join(', ')]) +} + +const getByMonthDayMessage = (byMonthDay?: number[]) => { + if (!byMonthDay?.length) return '' + const [day] = byMonthDay + if (day === -1) return __(' on the last day') + return __(' on the {0}', [getNthLabel(day)]) +} + +const getNthLabel = (n: number): string => { + const suffixes: Record = { 1: 'st', 2: 'nd', 3: 'rd' } + const suffix = suffixes[n] ?? 'th' + return `${n}${suffix}` +} + +export const toTitleCase = (str: string) => + str + ?.toLowerCase() + .split(' ') + .map(function (word: string) { + return word.charAt(0).toUpperCase().concat(word.substr(1)) + }) + .join(' ') || '' + +export const getFormattedDate = (date: Date | string, omitDate = false) => { + const dateObj = dayjs(date) + const isCurrentYear = dateObj.year() === dayjs().year() + if (omitDate) return dateObj.format(isCurrentYear ? 'MMMM' : 'MMMM YYYY') + if (dateObj.isToday()) return __('Today') + if (dateObj.isYesterday()) return __('Yesterday') + return dateObj.format(isCurrentYear ? 'D MMMM' : 'D MMMM YYYY') +} + +export const formatBytes = (bytes: number) => { + if (!+bytes) return '0 Bytes' + + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` +} + +export const extractNameFromEmail = (email: string) => + isEmail(email) + ? email + .split('@')[0] + .replace(/[._-]/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) + : email + +export const getRepeatFrequencyOptions = (interval: number) => [ + { label: interval === 1 ? __('Year') : __('Years'), value: 'yearly' }, + { label: interval === 1 ? __('Month') : __('Months'), value: 'monthly' }, + { label: interval === 1 ? __('Week') : __('Weeks'), value: 'weekly' }, + { label: interval === 1 ? __('Day') : __('Days'), value: 'daily' }, +] + +export const getRepeatMessage = (recurrenceRule: RecurrenceRule) => { + const interval = recurrenceRule.interval || 1 + const message = __('Every {0} {1}', [ + interval === 1 ? '' : interval, + getRepeatFrequencyOptions(interval) + .find((option) => option.value === recurrenceRule.frequency)! + .label.toLowerCase(), + ]) + + const suffix = + getByDayMessage(recurrenceRule.byDay) || getByMonthDayMessage(recurrenceRule.byMonthDay) + + const fullMessage = `${message}${suffix}` + + if (recurrenceRule?.until) + return __('{0} until {1}', [ + fullMessage, + dayjs(recurrenceRule.until).format('MMM DD, YYYY'), + ]) + if (recurrenceRule?.count) return __('{0}, {1} times', [fullMessage, recurrenceRule.count]) + + return fullMessage +} + +interface RecurrenceRule { + frequency: 'daily' | 'weekly' | 'monthly' | 'yearly' + interval: number + byDay?: { day: string; nthOfPeriod?: number }[] + byMonthDay?: number[] + until?: string + count?: number +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index e64deea58..0b8bbd20e 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -2,7 +2,6 @@ import * as cheerio from 'cheerio' import { File, Paperclip } from 'lucide-vue-next' import { toast } from 'frappe-ui' -import dayjs from '@/utils/dayjs' import AudioIcon from '@/components/Icons/AudioIcon.vue' import ImageIcon from '@/components/Icons/ImageIcon.vue' import PDFIcon from '@/components/Icons/PDFIcon.vue' @@ -10,15 +9,6 @@ import VideoIcon from '@/components/Icons/VideoIcon.vue' import type { ComposeMailData, Recipient } from '@/types' -export const toTitleCase = (str: string) => - str - ?.toLowerCase() - .split(' ') - .map(function (word: string) { - return word.charAt(0).toUpperCase().concat(word.substr(1)) - }) - .join(' ') || '' - export function startResizing(event) { const startX = event.clientX const sidebar = document.getElementsByClassName('mailSidebar')[0] @@ -42,36 +32,12 @@ export function startResizing(event) { document.addEventListener('mouseup', onMouseUp) } -export const singularize = (word: string) => { - const endings = { - ves: 'fe', - ies: 'y', - i: 'us', - zes: 'ze', - ses: 's', - es: 'e', - s: '', - } - return word.replace(new RegExp(`(${Object.keys(endings).join('|')})$`), (r) => endings[r]) -} - export const validateEmail = (email: string) => { const regExp = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ return regExp.test(email) } -export const formatBytes = (bytes: number) => { - if (!+bytes) return '0 Bytes' - - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] - - const i = Math.floor(Math.log(bytes) / Math.log(k)) - - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` -} - export const raiseToast = (message: string, type = 'success') => { if (type === 'success') return toast.success(message) @@ -104,12 +70,6 @@ export const raisePromiseToast = ( toast.promise(action(), { loading, success, error }) } -export const kebabToTitleCase = (str: string) => - str - .split('-') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') - export const copyToClipBoard = async (text: string) => { try { await navigator.clipboard.writeText(text) @@ -160,15 +120,6 @@ export const getFormattedRecipients = (mailRecipients: Recipient[]) => { return formattedRecipients } -export const getFormattedDate = (date: Date | string, omitDate = false) => { - const dateObj = dayjs(date) - const isCurrentYear = dateObj.year() === dayjs().year() - if (omitDate) return dateObj.format(isCurrentYear ? 'MMMM' : 'MMMM YYYY') - if (dateObj.isToday()) return __('Today') - if (dateObj.isYesterday()) return __('Yesterday') - return dateObj.format(isCurrentYear ? 'D MMMM' : 'D MMMM YYYY') -} - export const getFirstAlphabet = (str?: string) => str?.match(/\p{L}/u)?.[0] export const getTheme = ( @@ -313,9 +264,30 @@ export const processInlineImages = (mail: ComposeMailData) => { return { html_body: $.html(), attachments: processedAttachments } } +export const isUrl = (str: string) => { + if (typeof str !== 'string' || !str.trim()) return false + str = str.trim() + try { + const url = new URL(/^https?:\/\//i.test(str) ? str : 'https://' + str) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false + const parts = url.hostname.split('.') + return parts.length >= 2 && parts.every((p) => p.length > 0) + } catch { + return false + } +} + +export const getReorderedParticipants = ( + participants, + organizerEmail, + originalParticipants?: any[], +) => { + const original = new Set(originalParticipants?.map((p) => p.email) || []) -export const extractNameFromEmail = (email: string) => - email - .split('@')[0] - .replace(/[._-]/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()) + const organizer = participants.find((p) => p.email === organizerEmail) + const rest = participants + .filter((p) => p.email !== organizerEmail) + .map((p) => ({ ...p, isOrganizer: false, isNew: !original.has(p.email) })) + + return organizer ? [{ ...organizer, isOrganizer: true }, ...rest] : rest +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9e170b0d3..f908ca8cc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -99,6 +99,7 @@ export default defineConfig(({ mode }) => ({ }), ], resolve: { + dedupe: ['vue'], alias: [ { find: '@', replacement: path.resolve(__dirname, 'src') }, ...(loadEnv(mode, process.cwd(), '').LOCAL_FRAPPE_UI === 'true' diff --git a/mail/api/calendar.py b/mail/api/calendar.py new file mode 100644 index 000000000..c645c273e --- /dev/null +++ b/mail/api/calendar.py @@ -0,0 +1,94 @@ +import json + +import frappe +from frappe import _ + +from mail.client.doctype.calendar.calendar import fetch_calendars +from mail.client.doctype.calendar_event.calendar_event import ( + fetch_calendar_events, + get_master_events_by_uids, + update_calendar_event, +) +from mail.client.doctype.calendar_event.calendar_event import ( + get_calendar_events as get_calendar_events_by_ids, +) + + +@frappe.whitelist() +def get_calendars() -> list[dict[str, str]]: + """Returns a list of the current user's calendars.""" + + calendars = fetch_calendars(frappe.session.user) + + return [{key: cal[key] for key in ["name", "_name"]} for cal in calendars] + + +@frappe.whitelist() +def get_calendar_events(from_date: str, to_date: str, time_zone: str) -> list[dict]: + """Fetches calendar events between from_date and to_date for the current user.""" + + user = frappe.session.user + events = fetch_calendar_events( + user, + {"after": from_date, "before": to_date}, + limit=999, + time_zone=time_zone, + expand_recurrences=True, + )[0] + + uids = {event["uid"] for event in events} + masters = get_master_events_by_uids(user, list(uids)) + master_map = { + uid: { + "recurrence_rule": json.loads(master["recurrence_rule"]), + "master_id": master["id"], + "master_start": master["start"], + "master_duration": master["duration"], + } + for uid, master in masters.items() + } + + for event in events: + event.update(master_map.get(event["uid"], {})) + + return events + + +@frappe.whitelist() +def edit_calendar_event(id: str, **kwargs) -> None: + user = frappe.session.user + event = get_calendar_events_by_ids(user, [id])[0] + + def resolve(key): + return kwargs[key] if key in kwargs else event[key] + + calendar_ids = ( + kwargs["calendar_ids"] + if "calendar_ids" in kwargs + else [calendar["calendar_id"] for calendar in event["calendars"]] + ) + + update_calendar_event( + user, + id, + event["uid"], + event["organizer"], + calendar_ids, + resolve("status"), + resolve("draft"), + resolve("title"), + resolve("start"), + resolve("duration"), + resolve("time_zone"), + json.loads(resolve("recurrence_rule")), + resolve("show_without_time"), + resolve("privacy"), + resolve("free_busy_status"), + resolve("description"), + resolve("locations"), + resolve("links"), + resolve("participants"), + resolve("alerts"), + resolve("use_default_alerts"), + kwargs.get("send_scheduling_messages", False), + ) diff --git a/mail/api/contacts.py b/mail/api/contacts.py index ef2b26186..5c326f8dc 100644 --- a/mail/api/contacts.py +++ b/mail/api/contacts.py @@ -1,5 +1,6 @@ import frappe +from mail.api.mail import get_user_images from mail.client.doctype.address_book.address_book import fetch_address_books from mail.client.doctype.contact_card.contact_card import fetch_contact_cards @@ -41,6 +42,12 @@ def get_contacts(filter: dict | None = None, limit: int = 50) -> list[dict]: for email in emails: contacts.append({"full_name": card.get("full_name"), "email": email.get("address")}) + images = get_user_images([c.get("email") for c in contacts]) + + for contact in contacts: + email = contact.get("email") + contact["user_image"] = images.get(email) if email else None + return contacts diff --git a/mail/api/mail.py b/mail/api/mail.py index 3653331de..c0e8ac77b 100644 --- a/mail/api/mail.py +++ b/mail/api/mail.py @@ -50,6 +50,17 @@ def get_user_mailboxes(user) -> list[dict]: return frappe.get_all("Mailbox", filters={"user": user}) +def get_user_images(emails: list[str]) -> dict[str, str]: + """Returns a mapping of user emails to their avatar URLs.""" + + user_image_map = {} + if emails: + user_data = frappe.get_all("User", filters={"name": ["in", emails]}, fields=["name", "user_image"]) + user_image_map = {u.name: u.user_image for u in user_data if u.user_image} + + return {email: user_image_map.get(email) or get_avatar_url(email) for email in emails} + + def get_avatar_url(email: str) -> str: """Returns the avatar URL for the given email.""" @@ -89,18 +100,9 @@ def add_user_images_to_emails(mails: list[dict], is_thread: bool = False) -> lis email_map[name] = selected_email - unique_emails = {e for e in email_map.values() if e} - - user_image_map = {} - if unique_emails: - user_data = frappe.db.get_all( - "User", - filters={"name": ["in", list(unique_emails)]}, - fields=["name", "user_image"], - ) - user_image_map = {u.name: u.user_image for u in user_data if u.user_image} + unique_emails = list({e for e in email_map.values() if e}) - images = {email: user_image_map.get(email) or get_avatar_url(email) for email in unique_emails} + images = get_user_images(unique_emails) for mail in mails: email = email_map.get(mail["name"])