-
Notifications
You must be signed in to change notification settings - Fork 318
feat(ui): paste or copy several e-mail addresses into address fields at once #9347
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ChristophWurst
merged 1 commit into
nextcloud:main
from
arublov:feature/add-several-emails-at-once
May 19, 2026
+414
−30
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| } | ||
| } | ||
|
Comment on lines
+1489
to
+1497
|
||
| 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) | ||
|
ChristophWurst marked this conversation as resolved.
|
||
| list.push(recipient) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| existing.add(addr.email.toLowerCase()) | ||
| changed = true | ||
| } | ||
| } | ||
|
ChristophWurst marked this conversation as resolved.
|
||
| } | ||
| 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 | ||
| } | ||
| }, | ||
| }, | ||
| } | ||
| </script> | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]' }, | ||
| ]) | ||
| }) | ||
|
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]' }, | ||
| ]) | ||
|
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]') | ||
| }) | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.