diff --git a/src/components/Composer.vue b/src/components/Composer.vue index 6bbf7cc0fe..e8c217aeb1 100644 --- a/src/components/Composer.vue +++ b/src/components/Composer.vue @@ -509,7 +509,6 @@ import { showError, showWarning } from '@nextcloud/dialogs' import { getCanonicalLocale, getFirstDay, getLocale, translate as t } from '@nextcloud/l10n' import moment from '@nextcloud/moment' import { NcActionButton as ActionButton, NcActionCheckbox as ActionCheckbox, NcActionInput as ActionInput, NcActionRadio as ActionRadio, NcActions as Actions, NcButton as ButtonVue, NcListItemIcon as ListItemIcon, NcIconSvgWrapper, NcSelect } from '@nextcloud/vue' -import addressParser from 'address-rfc2822' import debouncePromise from 'debounce-promise' import debounce from 'lodash/fp/debounce.js' import trimStart from 'lodash/fp/trimCharsStart.js' @@ -544,6 +543,7 @@ import { findRecipient } from '../service/AutocompleteService.js' import { savePreference } from '../service/PreferenceService.js' import { EDITOR_MODE_HTML, EDITOR_MODE_TEXT } from '../store/constants.js' import useMainStore from '../store/mainStore.js' +import { parseEmailList } from '../util/emailAddress.js' import { formatDateTime } from '../util/formatDateTime.js' import { detect, html, toHtml, toPlain } from '../util/text.js' import textBlockSvg from './../../img/text_snippet.svg' @@ -1130,8 +1130,9 @@ export default { /** * Called once a user leaves the recipient picker. * - * If the user is typing something that looks like a valid email address, we clear the input (return true) - * because the related code in onNewAddr will add the value as a recipient. + * If the user is typing something that looks like a valid email address (or a + * pasted list of addresses), we clear the input (return true) because the + * related code in onNewAddr will add the value as a recipient. * * Otherwise, the user is still typing and we don't clear the input. * @@ -1140,7 +1141,7 @@ export default { */ clearOnBlur(event) { if (this.recipientSearchTerms[event]) { - return this.seemsValidEmailAddress(this.recipientSearchTerms[event]) + return parseEmailList(this.recipientSearchTerms[event]).length > 0 } return false }, @@ -1470,27 +1471,71 @@ export default { }, onNewAddr(option, list, type) { + // Build a Set of already-selected emails for O(1) case-insensitive dedup. + const existing = new Set(list.map((r) => r.email.toLowerCase())) + if ( (option === null || option === undefined) && this.recipientSearchTerms[type] !== undefined && this.recipientSearchTerms[type] !== '' ) { - if (!this.seemsValidEmailAddress(this.recipientSearchTerms[type])) { + const parsedFromSearch = parseEmailList(this.recipientSearchTerms[type]) + if (parsedFromSearch.length === 0) { return } - option = {} - option.email = this.recipientSearchTerms[type] - option.label = this.recipientSearchTerms[type] this.recipientSearchTerms[type] = '' + + let changed = false + for (const addr of parsedFromSearch) { + if (!existing.has(addr.email.toLowerCase())) { + const recipient = { ...addr } + this.newRecipients.push(recipient) + list.push(recipient) + existing.add(addr.email.toLowerCase()) + changed = true + } + } + if (changed) { + this.saveDraftDebounced() + } + return } - if (list.some((recipient) => recipient.email === option?.email) || !option) { + if (!option) { return } - const recipient = { ...option } - this.newRecipients.push(recipient) - list.push(recipient) - this.saveDraftDebounced() + + let changed = false + if (option.id) { + if (!existing.has(option.email.toLowerCase())) { + const recipient = { ...option } + this.newRecipients.push(recipient) + list.push(recipient) + changed = true + } + } else { + const emailList = parseEmailList(option.email) + // When createRecipientOption normalised a named address like + // "Jane Doe " it stored the display name in + // option.label but reduced option.email to the bare address. + // Re-parsing that bare address loses the display name, so for + // single-address options we trust option.label directly. + const recipientsToAdd = emailList.length === 1 + ? [{ email: option.email, label: option.label ?? option.email }] + : emailList + for (const addr of recipientsToAdd) { + if (!existing.has(addr.email.toLowerCase())) { + const recipient = { ...addr } + this.newRecipients.push(recipient) + list.push(recipient) + existing.add(addr.email.toLowerCase()) + changed = true + } + } + } + if (changed) { + this.saveDraftDebounced() + } }, async onSend() { @@ -1659,10 +1704,18 @@ export default { * @return {{email: string, label: string}} The new option */ createRecipientOption(value) { - if (!this.seemsValidEmailAddress(value)) { - throw new Error('Skipping because it does not look like a valid email address') + const parsed = parseEmailList(value) + if (parsed.length === 0) { + throw new Error('Skipping because it does not look like valid email address(es)') + } + // For a single address, return the normalised email/label so the chip + // shows a clean address rather than the raw typed/pasted string. + if (parsed.length === 1) { + return { email: parsed[0].email, label: parsed[0].label } } - return { email: value, label: value } + // For multi-address pastes keep the raw value so onNewAddr can expand + // all addresses from option.email. + return { email: value.trim(), label: value.trim() } }, /** @@ -1686,20 +1739,6 @@ export default { return option.email }, - /** - * True when value looks like a valid email address - * - * @param {string} value to check if email address - * @return {boolean} - */ - seemsValidEmailAddress(value) { - try { - addressParser.parse(value) - return true - } catch (error) { - return false - } - }, }, } diff --git a/src/tests/unit/util/emailAddress.spec.js b/src/tests/unit/util/emailAddress.spec.js new file mode 100644 index 0000000000..92a442db27 --- /dev/null +++ b/src/tests/unit/util/emailAddress.spec.js @@ -0,0 +1,213 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getLabelAndAddress, parseEmailList } from '../../../util/emailAddress.js' + +describe('getLabelAndAddress', () => { + it('parses a plain email address', () => { + expect(getLabelAndAddress('alice@example.com')).toEqual({ + label: 'alice@example.com', + email: 'alice@example.com', + }) + }) + + it('parses an email with angle brackets', () => { + expect(getLabelAndAddress('')).toEqual({ + label: 'alice@example.com', + email: 'alice@example.com', + }) + }) + + it('parses a display name with angle bracket email', () => { + expect(getLabelAndAddress('Alice Smith ')).toEqual({ + label: 'Alice Smith', + email: 'alice@example.com', + }) + }) + + it('preserves uppercase email addresses', () => { + expect(getLabelAndAddress('User@Example.COM')).toEqual({ + label: 'User@Example.COM', + email: 'User@Example.COM', + }) + }) + + it('handles mixed case with display name', () => { + expect(getLabelAndAddress('Alice ')).toEqual({ + label: 'Alice', + email: 'Alice@Example.COM', + }) + }) + + it('returns null for invalid input', () => { + expect(getLabelAndAddress('not an email')).toBeNull() + }) + + it('returns null for empty string', () => { + expect(getLabelAndAddress('')).toBeNull() + }) + + it('returns null for null or undefined', () => { + expect(getLabelAndAddress(null)).toBeNull() + expect(getLabelAndAddress(undefined)).toBeNull() + }) + + it('handles trailing delimiter with trailing whitespace', () => { + expect(getLabelAndAddress('alice@example.com, ')).toEqual({ + label: 'alice@example.com', + email: 'alice@example.com', + }) + }) + + it('does not include trailing delimiters in the email', () => { + expect(getLabelAndAddress('alice@example.com,')).toEqual({ + label: 'alice@example.com', + email: 'alice@example.com', + }) + }) + + it('does not include trailing semicolons in the email', () => { + expect(getLabelAndAddress('alice@example.com;')).toEqual({ + label: 'alice@example.com', + email: 'alice@example.com', + }) + }) + + it('handles email with subdomains', () => { + expect(getLabelAndAddress('user@mail.example.co.uk')).toEqual({ + label: 'user@mail.example.co.uk', + email: 'user@mail.example.co.uk', + }) + }) + + it('handles email with special characters in local part', () => { + expect(getLabelAndAddress('user+tag@example.com')).toEqual({ + label: 'user+tag@example.com', + email: 'user+tag@example.com', + }) + }) +}) + +describe('parseEmailList', () => { + it('parses a single email', () => { + expect(parseEmailList('alice@example.com')).toEqual([ + { label: 'alice@example.com', email: 'alice@example.com' }, + ]) + }) + + it('parses comma-separated emails', () => { + expect(parseEmailList('alice@example.com, bob@example.com')).toEqual([ + { label: 'alice@example.com', email: 'alice@example.com' }, + { label: 'bob@example.com', email: 'bob@example.com' }, + ]) + }) + + it('parses semicolon-separated emails', () => { + expect(parseEmailList('alice@example.com; bob@example.com')).toEqual([ + { label: 'alice@example.com', email: 'alice@example.com' }, + { label: 'bob@example.com', email: 'bob@example.com' }, + ]) + }) + + it('parses space-separated emails', () => { + expect(parseEmailList('alice@example.com bob@example.com')).toEqual([ + { label: 'alice@example.com', email: 'alice@example.com' }, + { label: 'bob@example.com', email: 'bob@example.com' }, + ]) + }) + + it('parses emails with display names', () => { + expect(parseEmailList('Alice , Bob ')).toEqual([ + { label: 'Alice', email: 'alice@example.com' }, + { label: 'Bob', email: 'bob@example.com' }, + ]) + }) + + it('preserves uppercase email addresses', () => { + expect(parseEmailList('User@Example.COM, Another@TEST.org')).toEqual([ + { label: 'User@Example.COM', email: 'User@Example.COM' }, + { label: 'Another@TEST.org', email: 'Another@TEST.org' }, + ]) + }) + + it('handles mixed delimiters', () => { + expect(parseEmailList('a@example.com, b@example.com; c@example.com')).toEqual([ + { label: 'a@example.com', email: 'a@example.com' }, + { label: 'b@example.com', email: 'b@example.com' }, + { label: 'c@example.com', email: 'c@example.com' }, + ]) + }) + + it('ignores non-email entries in a list', () => { + expect(parseEmailList('not-an-email, alice@example.com')).toEqual([ + { label: 'alice@example.com', email: 'alice@example.com' }, + ]) + }) + + it('skips entries without any email address', () => { + expect(parseEmailList('just-text')).toEqual([]) + }) + + it('returns empty array for empty string', () => { + expect(parseEmailList('')).toEqual([]) + }) + + it('returns empty array for string without emails', () => { + expect(parseEmailList('just some text without emails')).toEqual([]) + }) + + it('handles trailing delimiters', () => { + expect(parseEmailList('alice@example.com, bob@example.com,')).toEqual([ + { label: 'alice@example.com', email: 'alice@example.com' }, + { label: 'bob@example.com', email: 'bob@example.com' }, + ]) + }) + + it('handles multiple addresses with angle brackets and display names', () => { + expect(parseEmailList('Alice Smith ; Bob Jones ')).toEqual([ + { label: 'Alice Smith', email: 'alice@example.com' }, + { label: 'Bob Jones', email: 'bob@example.com' }, + ]) + }) + + it('handles quoted display names containing commas', () => { + // address-rfc2822 normalizes "Last, First" to "First Last" per RFC 2822 + expect(parseEmailList('"Smith, Alice" , "Jones, Bob" ')).toEqual([ + { label: 'Alice Smith', email: 'alice@example.com' }, + { label: 'Bob Jones', email: 'bob@example.com' }, + ]) + }) + + it('extracts valid addresses from mixed input with invalid tokens', () => { + expect(parseEmailList('invalid-entry, "Smith, Alice" , not-an-email, bob@example.com')).toEqual([ + { label: 'Alice Smith', email: 'alice@example.com' }, + { label: 'bob@example.com', email: 'bob@example.com' }, + ]) + }) + + it('returns empty array for null or undefined', () => { + expect(parseEmailList(null)).toEqual([]) + expect(parseEmailList(undefined)).toEqual([]) + }) + + // Regression tests from PR description / issue #6013 + it('handles real-world paste: plain addresses with display name (issue #6013 case 1)', () => { + const result = parseEmailList('test@test.com, Jane Doe, MSc jane@doe.tld') + expect(result).toEqual([ + { label: 'test@test.com', email: 'test@test.com' }, + { label: 'jane@doe.tld', email: 'jane@doe.tld' }, + ]) + }) + + it('handles real-world paste: messy mixed input (issue #6013 case 2)', () => { + const input = 'ian eiloart iane@example.ac.uk>;shuf6@example.ac.uk,, test+user@company.c, "ian,eiloart", <@example.com:foo@example.ac.uk>, foo@#,ian@-example.com, ian@one@two;asdas< test@test.com> test@test.com, Newasd Na@,me >; testaaaa@aasd.com' + const result = parseEmailList(input) + const emails = result.map((r) => r.email) + expect(emails).toContain('shuf6@example.ac.uk') + expect(emails).toContain('ian@example.ac.uk') + expect(emails).toContain('test@test.com') + expect(emails).toContain('testaaaa@aasd.com') + }) +}) diff --git a/src/util/emailAddress.js b/src/util/emailAddress.js new file mode 100644 index 0000000000..8a6ed6e0b1 --- /dev/null +++ b/src/util/emailAddress.js @@ -0,0 +1,132 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import addressParser from 'address-rfc2822' + +/** + * Try to parse a string with address-rfc2822. + * Returns an array of {label, email} objects, or an empty array on failure. + * + * @param {string} str The input string + * @return {Array<{ label: string, email: string }>} + */ +function tryParse(str) { + if (!str) { + return [] + } + + try { + return addressParser.parse(str).map((addr) => ({ + label: addr.name() || addr.address, + email: addr.address, + })) + } catch { + return [] + } +} + +/** + * Split a string on delimiter characters (commas and semicolons) that are + * not inside quotes or angle brackets. + * + * @param {string} str The input string + * @return {string[]} The parts + */ +function splitOnDelimiters(str) { + const parts = [] + let current = '' + let inQuotes = false + let inAngle = false + + for (let i = 0; i < str.length; i++) { + const ch = str[i] + + if (ch === '"' && (i === 0 || str[i - 1] !== '\\')) { + inQuotes = !inQuotes + } else if (!inQuotes && ch === '<') { + inAngle = true + } else if (!inQuotes && ch === '>') { + inAngle = false + } + + if ((ch === ',' || ch === ';') && !inQuotes && !inAngle) { + parts.push(current) + current = '' + } else { + current += ch + } + } + parts.push(current) + return parts +} + +/** + * Extract a label and email address from a string like "John Doe " + * or just "john@example.com". + * + * @param {string|null|undefined} str The input string + * @return {{ label: string, email: string } | null} Parsed result or null if no email found + */ +export function getLabelAndAddress(str) { + if (!str) { + return null + } + + // Trim first so trailing delimiters followed by whitespace are still removed + const cleaned = str.trim().replace(/[,;]+$/, '').trim() + const results = tryParse(cleaned) + return results.length > 0 ? results[0] : null +} + +/** + * Parse a string containing one or more email addresses separated by + * commas or semicolons, with limited support for spaces between bare + * email addresses. + * + * Supports formats like: + * - "alice@example.com, bob@example.com" + * - "Alice ; Bob " + * - "alice@example.com bob@example.com" (bare email addresses only) + * + * @param {string|null|undefined} str The input string containing email addresses + * @return {Array<{ label: string, email: string }>} List of parsed addresses + */ +export function parseEmailList(str) { + if (!str) { + return [] + } + + // Split on commas and semicolons (respecting quotes/angle brackets), + // then normalize to a comma-separated string for address-rfc2822. + const parts = splitOnDelimiters(str) + const normalized = parts.map((p) => p.trim()).filter(Boolean).join(', ') + + // First try: parse the whole normalized string (handles clean lists) + const results = tryParse(normalized) + if (results.length > 0) { + return results + } + + // Second try: parse each part individually. This handles cases like + // "not-an-email, alice@example.com" where the library rejects the whole string. + const list = [] + for (const part of parts) { + const trimmed = part.trim() + if (!trimmed) { + continue + } + + const parsed = tryParse(trimmed) + if (parsed.length > 0) { + list.push(...parsed) + } else if (trimmed.includes(' ') && trimmed.includes('@')) { + // Try splitting on spaces for space-separated bare emails + for (const word of trimmed.split(/\s+/)) { + list.push(...tryParse(word)) + } + } + } + return list +}