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
99 changes: 69 additions & 30 deletions src/components/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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.
*
Expand All @@ -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
},
Expand Down Expand Up @@ -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
}
}
Comment on lines +1489 to +1497
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

De-duplication compares recipient.email === addr.email (case-sensitive). This can allow duplicates for the same address pasted with different casing (pretty common when copying from different sources). Consider comparing on a canonical form (e.g., email.toLowerCase() at least for the domain part, or entirely lowercased if that matches existing app behavior) to avoid duplicate recipients.

Copilot uses AI. Check for mistakes.
Comment on lines +1489 to +1497
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list.some(...) is called for each parsed address, making this O(n*m) as recipients grow. Using a Set of existing emails (and updating it as you add) would make de-dup checks O(1) and keep recipient addition snappy for large pastes.

Copilot uses AI. Check for mistakes.
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 <[email protected]>" 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)
Comment thread
ChristophWurst marked this conversation as resolved.
list.push(recipient)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
existing.add(addr.email.toLowerCase())
changed = true
}
}
Comment thread
ChristophWurst marked this conversation as resolved.
}
if (changed) {
this.saveDraftDebounced()
}
},

async onSend() {
Expand Down Expand Up @@ -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() }
},

/**
Expand All @@ -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
}
},
},
}
</script>
Expand Down
213 changes: 213 additions & 0 deletions src/tests/unit/util/emailAddress.spec.js
Original file line number Diff line number Diff line change
@@ -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('[email protected]')).toEqual({
label: '[email protected]',
email: '[email protected]',
})
})

it('parses an email with angle brackets', () => {
expect(getLabelAndAddress('<[email protected]>')).toEqual({
label: '[email protected]',
email: '[email protected]',
})
})

it('parses a display name with angle bracket email', () => {
expect(getLabelAndAddress('Alice Smith <[email protected]>')).toEqual({
label: 'Alice Smith',
email: '[email protected]',
})
})

it('preserves uppercase email addresses', () => {
expect(getLabelAndAddress('[email protected]')).toEqual({
label: '[email protected]',
email: '[email protected]',
})
})

it('handles mixed case with display name', () => {
expect(getLabelAndAddress('Alice <[email protected]>')).toEqual({
label: 'Alice',
email: '[email protected]',
})
})

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('[email protected], ')).toEqual({
label: '[email protected]',
email: '[email protected]',
})
})

it('does not include trailing delimiters in the email', () => {
expect(getLabelAndAddress('[email protected],')).toEqual({
label: '[email protected]',
email: '[email protected]',
})
})

it('does not include trailing semicolons in the email', () => {
expect(getLabelAndAddress('[email protected];')).toEqual({
label: '[email protected]',
email: '[email protected]',
})
})

it('handles email with subdomains', () => {
expect(getLabelAndAddress('[email protected]')).toEqual({
label: '[email protected]',
email: '[email protected]',
})
})

it('handles email with special characters in local part', () => {
expect(getLabelAndAddress('[email protected]')).toEqual({
label: '[email protected]',
email: '[email protected]',
})
})
})

describe('parseEmailList', () => {
it('parses a single email', () => {
expect(parseEmailList('[email protected]')).toEqual([
{ label: '[email protected]', email: '[email protected]' },
])
})

it('parses comma-separated emails', () => {
expect(parseEmailList('[email protected], [email protected]')).toEqual([
{ label: '[email protected]', email: '[email protected]' },
{ label: '[email protected]', email: '[email protected]' },
])
})

it('parses semicolon-separated emails', () => {
expect(parseEmailList('[email protected]; [email protected]')).toEqual([
{ label: '[email protected]', email: '[email protected]' },
{ label: '[email protected]', email: '[email protected]' },
])
})

it('parses space-separated emails', () => {
expect(parseEmailList('[email protected] [email protected]')).toEqual([
{ label: '[email protected]', email: '[email protected]' },
{ label: '[email protected]', email: '[email protected]' },
])
})

it('parses emails with display names', () => {
expect(parseEmailList('Alice <[email protected]>, Bob <[email protected]>')).toEqual([
{ label: 'Alice', email: '[email protected]' },
{ label: 'Bob', email: '[email protected]' },
])
})

it('preserves uppercase email addresses', () => {
expect(parseEmailList('[email protected], [email protected]')).toEqual([
{ label: '[email protected]', email: '[email protected]' },
{ label: '[email protected]', email: '[email protected]' },
])
})

it('handles mixed delimiters', () => {
expect(parseEmailList('[email protected], [email protected]; [email protected]')).toEqual([
{ label: '[email protected]', email: '[email protected]' },
{ label: '[email protected]', email: '[email protected]' },
{ label: '[email protected]', email: '[email protected]' },
])
})

it('ignores non-email entries in a list', () => {
expect(parseEmailList('not-an-email, [email protected]')).toEqual([
{ label: '[email protected]', email: '[email protected]' },
])
})

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('[email protected], [email protected],')).toEqual([
{ label: '[email protected]', email: '[email protected]' },
{ label: '[email protected]', email: '[email protected]' },
])
})

it('handles multiple addresses with angle brackets and display names', () => {
expect(parseEmailList('Alice Smith <[email protected]>; Bob Jones <[email protected]>')).toEqual([
{ label: 'Alice Smith', email: '[email protected]' },
{ label: 'Bob Jones', email: '[email protected]' },
])
})
Comment thread
ChristophWurst marked this conversation as resolved.

it('handles quoted display names containing commas', () => {
// address-rfc2822 normalizes "Last, First" to "First Last" per RFC 2822
expect(parseEmailList('"Smith, Alice" <[email protected]>, "Jones, Bob" <[email protected]>')).toEqual([
{ label: 'Alice Smith', email: '[email protected]' },
{ label: 'Bob Jones', email: '[email protected]' },
])
})

it('extracts valid addresses from mixed input with invalid tokens', () => {
expect(parseEmailList('invalid-entry, "Smith, Alice" <[email protected]>, not-an-email, [email protected]')).toEqual([
{ label: 'Alice Smith', email: '[email protected]' },
{ label: '[email protected]', email: '[email protected]' },
])
Comment thread
ChristophWurst marked this conversation as resolved.
})

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('[email protected], Jane Doe, MSc [email protected]')
expect(result).toEqual([
{ label: '[email protected]', email: '[email protected]' },
{ label: '[email protected]', email: '[email protected]' },
])
})

it('handles real-world paste: messy mixed input (issue #6013 case 2)', () => {
const input = 'ian eiloart [email protected]>;[email protected],, [email protected], "ian,eiloart"<[email protected]>, <@example.com:[email protected]>, foo@#,[email protected], ian@one@two;asdas< [email protected]> [email protected], Newasd Na@,me >; [email protected]'
const result = parseEmailList(input)
const emails = result.map((r) => r.email)
expect(emails).toContain('[email protected]')
expect(emails).toContain('[email protected]')
expect(emails).toContain('[email protected]')
expect(emails).toContain('[email protected]')
})
})
Loading
Loading