diff --git a/src/components/Editor/Invitees/InviteesListSearch.vue b/src/components/Editor/Invitees/InviteesListSearch.vue index 2b9645689d..27b3fe461c 100644 --- a/src/components/Editor/Invitees/InviteesListSearch.vue +++ b/src/components/Editor/Invitees/InviteesListSearch.vue @@ -47,6 +47,10 @@
{{ option.subtitle }}
+
+ + {{ $t('calendar', 'This might be a mailing list. Invitations will not work.') }} +
@@ -62,13 +66,14 @@ import { NcSelect, } from '@nextcloud/vue' import debounce from 'debounce' +import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' import GoogleCirclesCommunitiesIcon from 'vue-material-design-icons/GoogleCirclesCommunities.vue' import { circleGetMembers, circleSearchByName, } from '@/services/circleService.js' import isCirclesEnabled from '@/services/isCirclesEnabled.js' -import { removeMailtoPrefix } from '@/utils/attendee.js' +import { looksLikeMailingList, removeMailtoPrefix } from '@/utils/attendee.js' import logger from '@/utils/logger.js' import { randomId } from '@/utils/randomId.js' @@ -78,6 +83,7 @@ export default { Avatar, NcSelect, GoogleCirclesCommunitiesIcon, + AlertCircleOutline, }, props: { @@ -154,6 +160,7 @@ export default { timezoneId: null, hasMultipleEMails: false, dropdownName: query, + looksLikeMailingList: looksLikeMailingList(query), }) } } @@ -290,6 +297,7 @@ export default { timezoneId: result.tzid, hasMultipleEMails, dropdownName: name + ' ' + email, + looksLikeMailingList: looksLikeMailingList(email), }) }) @@ -330,5 +338,13 @@ export default { :deep(.vs__search::placeholder) { text-overflow: ellipsis; + +} + +.invitees-search-list-item__warning { + display: flex; + align-items: center; + gap: 4px; + color: var(--color-warning); } diff --git a/src/utils/attendee.js b/src/utils/attendee.js index 069fa0c0dd..127050fb19 100644 --- a/src/utils/attendee.js +++ b/src/utils/attendee.js @@ -84,6 +84,94 @@ export function getRoomAttendees(attendees) { return attendees.filter((attendee) => attendee?.attendeeProperty?.userType === 'ROOM') } +/* + * Heuristically check if an email address looks like a mailing list address + * + * @param {string} email Email address to check (with or without mailto: prefix) + * @return {boolean} True if the address looks like a mailing list + */ +export function looksLikeMailingList(email) { + if (typeof email !== 'string') { + return false + } + + const address = removeMailtoPrefix(email).toLowerCase() + const atIndex = address.indexOf('@') + if (atIndex === -1) { + return false + } + + const local = address.slice(0, atIndex) + const domain = address.slice(atIndex + 1) + + const exactMatches = new Set([ + 'list', + 'lists', + 'ml', + 'announce', + 'announcements', + 'noreply', + 'no-reply', + 'newsletter', + 'newsletters', + 'mailer-daemon', + 'postmaster', + 'sympa', + 'majordomo', + 'listserv', + 'mailman', + 'dmarc', + 'bounce', + 'bounces', + 'subscribe', + 'unsubscribe', + ]) + if (exactMatches.has(local)) { + return true + } + + const suffixes = [ + '-bounces', + '-request', + '-subscribe', + '-unsubscribe', + '-owner', + '-help', + '-announce', + '-devel', + '-discuss', + '-commits', + '-bugs', + '-patches', + '-users', + '-list', + '+bounces', + '+subscribe', + ] + if (suffixes.some((s) => local.endsWith(s))) { + return true + } + + const knownDomains = new Set([ + 'googlegroups.com', + 'groups.io', + 'freelists.org', + 'yahoogroups.com', + 'listserv.com', + 'topica.com', + ]) + if (knownDomains.has(domain)) { + return true + } + + const knownSubdomainPrefixes = ['lists.', 'ml.', 'listserv.', 'mailman.', 'sympa.'] + if (knownSubdomainPrefixes.some((p) => domain.startsWith(p))) { + return true + } + + return false +} + /** * Check if the current user is an attendee * diff --git a/tests/javascript/unit/utils/attendee.test.js b/tests/javascript/unit/utils/attendee.test.js index 3d5c250451..5323d147d8 100644 --- a/tests/javascript/unit/utils/attendee.test.js +++ b/tests/javascript/unit/utils/attendee.test.js @@ -7,6 +7,7 @@ import { addMailtoPrefix, getRoomAttendees, isPendingResourceBooking, + looksLikeMailingList, organizerDisplayName, removeMailtoPrefix, } from '@/utils/attendee.js' @@ -37,6 +38,51 @@ describe('utils/attendee test suite', () => { expect(addMailtoPrefix(undefined)).toEqual('mailto:') }) + describe('looksLikeMailingList', () => { + it('detects exact local-part matches', () => { + expect(looksLikeMailingList('announce@example.com')).toBe(true) + expect(looksLikeMailingList('mailman@example.org')).toBe(true) + expect(looksLikeMailingList('noreply@example.com')).toBe(true) + expect(looksLikeMailingList('lists@example.com')).toBe(true) + }) + + it('detects local-part suffix matches', () => { + expect(looksLikeMailingList('dev-bounces@example.org')).toBe(true) + expect(looksLikeMailingList('project-request@example.com')).toBe(true) + expect(looksLikeMailingList('calendar-users@example.com')).toBe(true) + expect(looksLikeMailingList('app+bounces@example.com')).toBe(true) + }) + + it('detects known mailing list domains', () => { + expect(looksLikeMailingList('group@googlegroups.com')).toBe(true) + expect(looksLikeMailingList('list@groups.io')).toBe(true) + expect(looksLikeMailingList('user@freelists.org')).toBe(true) + }) + + it('detects known mailing list subdomains', () => { + expect(looksLikeMailingList('user@lists.nextcloud.com')).toBe(true) + expect(looksLikeMailingList('someone@mailman.apache.org')).toBe(true) + expect(looksLikeMailingList('foo@sympa.example.com')).toBe(true) + }) + + it('handles mailto: prefix', () => { + expect(looksLikeMailingList('mailto:announce@example.com')).toBe(true) + expect(looksLikeMailingList('mailto:john@example.com')).toBe(false) + }) + + it('returns false for regular email addresses', () => { + expect(looksLikeMailingList('john@example.com')).toBe(false) + expect(looksLikeMailingList('dev@example.com')).toBe(false) + expect(looksLikeMailingList('alice@company.com')).toBe(false) + }) + + it('returns false for invalid input', () => { + expect(looksLikeMailingList('notanemail')).toBe(false) + expect(looksLikeMailingList(null)).toBe(false) + expect(looksLikeMailingList(undefined)).toBe(false) + }) + }) + it('should extract a display name of an organizer', () => { const commonName = 'My Name' const uri = 'uri@test.com'