Skip to content

Commit e2e32c6

Browse files
authored
Merge pull request #214 from berkmancenter/cj/topic-resolution
When creating a conversation from a calendar invite, determine owner and which topic it belongs to
2 parents ba05d63 + 2c7e9b1 commit e2e32c6

14 files changed

Lines changed: 516 additions & 25 deletions

File tree

.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ JWT_RESET_PASSWORD_EXPIRATION_MINUTES=120
6565
# Short by design: the token is meant to be clicked from Slack within an hour.
6666
# HANDOFF_TOKEN_EXPIRATION_MINUTES=60
6767

68-
# SMTP configuration options for the email service
68+
# SMTP configuration options for the email service.
69+
# On real deployments, point these at interfaces like Postmark's SMTP interface
6970
# For testing, you can use a fake SMTP service like Ethereal: https://ethereal.email/create
7071
SMTP_HOST=email-server
7172
SMTP_PORT=587
@@ -79,6 +80,11 @@ EMAIL_FROM=support@yourapp.com
7980
#POSTMARK_WEBHOOK_AUTH_USER=
8081
#POSTMARK_WEBHOOK_AUTH_SECRET=
8182

83+
# Comma-separated email domains whose senders, if they have no account yet, get a "please sign up"
84+
# reply to an inbound calendar invite. Invites from any other domain are rejected (no event, no reply).
85+
# Unset means no domains are allowlisted, so no signup invites are sent.
86+
#ALLOWED_ORGANIZER_EMAIL_DOMAINS=example.edu
87+
8288
# Public URL of the Nextspace frontend. Used to build links for password
8389
# reset emails, channel archive emails, and the Slack event-setup handoff
8490
# link the bot posts when an organizer asks to create an event.

src/config/config.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,10 @@ const envVarsSchema = Joi.object()
135135
.description('Minutes after scheduledEndTime to auto-stop a conversation'),
136136
SYSTEM_USERS: Joi.string()
137137
.default('event-setup-bot:serviceAccount')
138-
.description('Comma-separated list of system accounts to create on startup, in username:role format')
138+
.description('Comma-separated list of system accounts to create on startup, in username:role format'),
139+
ALLOWED_ORGANIZER_EMAIL_DOMAINS: Joi.string().description(
140+
'Comma-separated email domains whose senders, if they have no account yet, get a "please sign up" reply to an inbound invite. Invites from any other domain are rejected: no event, no reply. Unset means none, so no signup invites are ever sent.'
141+
)
139142
})
140143
.unknown()
141144

@@ -287,6 +290,10 @@ const config = {
287290
systemUsers: envVars.SYSTEM_USERS.split(',').map((entry: string) => {
288291
const [username, role] = entry.trim().split(':')
289292
return { username, role }
290-
})
293+
}),
294+
allowedOrganizerEmailDomains: (envVars.ALLOWED_ORGANIZER_EMAIL_DOMAINS ?? '')
295+
.split(',')
296+
.map((domain: string) => domain.trim().toLowerCase())
297+
.filter((domain: string) => domain.length > 0)
291298
}
292299
export default config

src/docs/components.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,6 @@ components:
759759
- enableDMs
760760
- experiments
761761
- owner
762-
- topic
763762
properties:
764763
id:
765764
type: string

src/models/conversation.model.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ const conversationSchema = new mongoose.Schema<IConversation, ConversationModel>
119119
topic: {
120120
type: mongoose.SchemaTypes.ObjectId,
121121
ref: 'Topic',
122-
required: true,
122+
// Optional so a draft created from an inbound invite with no matching Topic can be saved with a
123+
// blank topic for the organizer to fill in. Non-draft conversations still get one at creation.
123124
index: true
124125
},
125126
scheduledTime: {

src/services/conversation.service/index.ts

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import config from '../../config/config.js'
1212
import adapterService from '../adapter.service.js'
1313
import channelService from '../channel.service.js'
1414
import { ConversationDocument } from '../../models/conversation.model.js'
15+
import { TopicDocument } from '../../models/topic.model.js'
1516
import { getConversationType } from '../../conversations/index.js'
1617
import adapterTypes from '../../adapters/index.js'
1718
import resolveConversationType from '../../conversations/resolver.js'
@@ -60,7 +61,7 @@ const startConversation = async (conversationOrId, user) => {
6061
await conversation.populate(['topic', 'agents', 'adapters'])
6162
if (
6263
user._id.toString() !== conversation.owner._id.toString() &&
63-
user._id.toString() !== conversation.topic.owner._id.toString()
64+
user._id.toString() !== conversation.topic?.owner?._id.toString()
6465
) {
6566
throw new ApiError(httpStatus.FORBIDDEN, 'Only conversation or topic owner can start conversation')
6667
}
@@ -76,7 +77,10 @@ const stopConversation = async (conversationOrId, user) => {
7677
}
7778
}
7879
await conversation.populate(['topic', 'agents', 'adapters'])
79-
if (user._id.toString() !== conversation.owner.toString() && user._id.toString() !== conversation.topic.owner.toString()) {
80+
if (
81+
user._id.toString() !== conversation.owner.toString() &&
82+
user._id.toString() !== conversation.topic?.owner?.toString()
83+
) {
8084
throw new ApiError(httpStatus.FORBIDDEN, 'Only conversation or topic owner can stop conversation')
8185
}
8286
return doStopConversation(conversation)
@@ -141,17 +145,25 @@ async function scheduleConversationEndingSoon(conversation) {
141145
/**
142146
* Create a conversation
143147
* @param {Object} conversationBody
148+
* @param {Object} user
149+
* @param {Object} [options]
150+
* @param {boolean} [options.allowDraft] trusted internal callers only; lets a conversation be created
151+
* with no topic, saved as a draft for the owner to complete (see createConversationFromType)
144152
* @returns {Promise<Conversation>}
145153
*/
146-
const createConversation = async (conversationBody, user) => {
147-
if (!conversationBody.topicId) throw new ApiError(httpStatus.BAD_REQUEST, 'topic id must be passed in request body')
148-
const topicId = new mongoose.Types.ObjectId(conversationBody.topicId)
149-
const topic = await Topic.findById(topicId)
150-
if (!topic) {
151-
throw new ApiError(httpStatus.BAD_REQUEST, 'No such topic')
152-
}
153-
if (!topic?.conversationCreationAllowed && user._id.toString() !== topic?.owner.toString()) {
154-
throw new ApiError(httpStatus.FORBIDDEN, 'Conversation creation not allowed.')
154+
const createConversation = async (conversationBody, user, { allowDraft = false } = {}) => {
155+
let topic: TopicDocument | null = null
156+
if (conversationBody.topicId) {
157+
const topicId = new mongoose.Types.ObjectId(conversationBody.topicId)
158+
topic = await Topic.findById(topicId)
159+
if (!topic) {
160+
throw new ApiError(httpStatus.BAD_REQUEST, 'No such topic')
161+
}
162+
if (!topic.conversationCreationAllowed && user._id.toString() !== topic.owner.toString()) {
163+
throw new ApiError(httpStatus.FORBIDDEN, 'Conversation creation not allowed.')
164+
}
165+
} else if (!allowDraft) {
166+
throw new ApiError(httpStatus.BAD_REQUEST, 'topic id must be passed in request body')
155167
}
156168

157169
if (conversationBody.scheduledTime && new Date(conversationBody.scheduledTime) <= new Date()) {
@@ -167,7 +179,7 @@ const createConversation = async (conversationBody, user) => {
167179
const conversation = new Conversation({
168180
name: conversationBody.name,
169181
owner: user,
170-
topic,
182+
...(topic && { topic }),
171183
enableAgents: !!conversationBody.agentTypes?.length,
172184
...(conversationBody.enableDMs !== undefined && { enableDMs: conversationBody.enableDMs }),
173185
...(conversationBody.conversationType !== undefined && { conversationType: conversationBody.conversationType }),
@@ -212,8 +224,13 @@ const createConversation = async (conversationBody, user) => {
212224
await channelService.createChannel(conversation, channelProps)
213225
}
214226

215-
topic.conversations.push(conversation.toObject())
216-
await Promise.all([conversation.save(), topic.save()])
227+
// A topicless draft has nothing to link back to, so only touch the topic when one resolved.
228+
if (topic) {
229+
topic.conversations.push(conversation.toObject())
230+
await Promise.all([conversation.save(), topic.save()])
231+
} else {
232+
await conversation.save()
233+
}
217234
await transcript.loadEventMetadataIntoVectorStore(conversation)
218235

219236
websocketGateway.broadcastNewConversation(conversation)
@@ -253,7 +270,7 @@ const createConversationFromType = async (params, user, { allowDraft = false } =
253270
}
254271

255272
const resolved = resolveConversationType(params, conversationType, allowDraft)
256-
return createConversation({ ...params, conversationType: type, ...resolved }, user)
273+
return createConversation({ ...params, conversationType: type, ...resolved }, user, { allowDraft })
257274
}
258275

259276
/**
@@ -269,7 +286,7 @@ const updateConversation = async (conversationBody, user) => {
269286
}
270287
if (
271288
user._id.toString() !== conversationDoc.owner.toString() &&
272-
user._id.toString() !== conversationDoc.topic.owner.toString()
289+
user._id.toString() !== conversationDoc.topic?.owner?.toString()
273290
) {
274291
throw new ApiError(httpStatus.FORBIDDEN, 'Only conversation or topic owner can update.')
275292
}
@@ -702,7 +719,10 @@ const deleteConversation = async (id, user) => {
702719
if (!conversation) {
703720
throw new ApiError(httpStatus.NOT_FOUND, `Conversation with id ${id} not found`)
704721
}
705-
if (user._id.toString() !== conversation.owner.toString() && user._id.toString() !== conversation.topic.owner.toString()) {
722+
if (
723+
user._id.toString() !== conversation.owner.toString() &&
724+
user._id.toString() !== conversation.topic?.owner?.toString()
725+
) {
706726
throw new ApiError(httpStatus.FORBIDDEN, 'Only conversation or topic owner can delete.')
707727
}
708728
if (conversation.active) {
@@ -737,7 +757,10 @@ const patchConversationAgent = async (id, agentId, body, user) => {
737757
throw new ApiError(httpStatus.NOT_FOUND, `Conversation with id ${id} not found`)
738758
}
739759
const agentIdStr = agentId.toString() ? agentId.toString() : agentId
740-
if (user._id.toString() !== conversation.owner.toString() && user._id.toString() !== conversation.topic.owner.toString()) {
760+
if (
761+
user._id.toString() !== conversation.owner.toString() &&
762+
user._id.toString() !== conversation.topic?.owner?.toString()
763+
) {
741764
throw new ApiError(httpStatus.FORBIDDEN, 'Only conversation or topic owner can patch agents')
742765
}
743766
const agent = conversation.agents.find((a) => a._id!.toString() === agentIdStr)

src/services/email.service.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,32 @@ To prevent archival and keep your channel on Conversations, please copy and past
101101
await sendEmailAsync(to, subject, text, html)
102102
}
103103

104+
/**
105+
* Invite an inbound-invite sender who has no account yet to sign up.
106+
* Only sent to senders inside an allowlisted domain (see emailSetup.service); an event is created
107+
* only once they have an account, so this is the reply that unblocks them.
108+
* @param {string} to
109+
* @returns {Promise}
110+
*/
111+
const sendSignupInviteEmail = async (to) => {
112+
const subject = 'Set up your account to create your event'
113+
const signupUrl = `${config.appHost}/signup`
114+
const text = `Hello,
115+
We received your calendar invite, but there's no account for this email address yet.
116+
To finish setting up your event, sign up here and then resend the invite: ${signupUrl}`
117+
const html = `<p>Hello,</p>
118+
<p>We received your calendar invite, but there's no account for this email address yet.</p>
119+
<p>To finish setting up your event, <a href="${signupUrl}">sign up here</a> and then resend the invite.</p>`
120+
await sendEmailAsync(to, subject, text, html)
121+
}
122+
104123
const emailService = {
105124
transport,
106125
sendEmail,
107126
sendEmailAsync,
108127
sendPasswordResetEmail,
109128
sendPasswordResetEmailAsync,
110-
sendArchiveTopicEmail
129+
sendArchiveTopicEmail,
130+
sendSignupInviteEmail
111131
}
112132
export default emailService
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* Resolves the two things an inbound calendar invite doesn't state directly: who owns the event
3+
* (the organizer) and which Topic it belongs to. Both are decided deterministically here, with no
4+
* LLM involved (see the plan's "Topic matching never uses the LLM"). The fuzzy fields (Zoom link,
5+
* speakers, etc.) are extracted separately.
6+
*/
7+
import config from '../../config/config.js'
8+
import logger from '../../config/logger.js'
9+
import { Topic } from '../../models/index.js'
10+
import { TopicDocument } from '../../models/topic.model.js'
11+
import userService from '../user.service.js'
12+
import topicService from '../topic.service.js'
13+
import emailService from '../email.service.js'
14+
import { InboundInvite } from '../../types/index.types.js'
15+
16+
/**
17+
* The Topic prefix an invite's SUMMARY points at: the substring before the first colon, trimmed.
18+
* Conversations in a series are named "<topic>: <additional info>", so "Team Sync: Jane Presents"
19+
* points at the "Team Sync" topic. Returns null when there's no colon or the prefix is empty, which
20+
* the caller reads as "no prefix to match, fall back to a new Topic."
21+
*/
22+
export const topicPrefixFromSummary = (summary?: string): string | null => {
23+
if (!summary) return null
24+
const colonIndex = summary.indexOf(':')
25+
if (colonIndex === -1) return null
26+
const prefix = summary.slice(0, colonIndex).trim()
27+
return prefix.length > 0 ? prefix : null
28+
}
29+
30+
/**
31+
* Pick the candidate Topic whose name exactly equals the invite SUMMARY's prefix, comparing
32+
* case-insensitively and ignoring surrounding whitespace. Exact match only: "Team Sync" must not
33+
* match a "Team Syncs" Topic. Returns null when there's no prefix or nothing matches. The candidate
34+
* list is the permission boundary; the caller decides which Topics go in it.
35+
*/
36+
export const matchTopicByPrefix = <T extends { name?: string }>(summary: string | undefined, candidates: T[]): T | null => {
37+
const prefix = topicPrefixFromSummary(summary)
38+
if (!prefix) return null
39+
const target = prefix.toLowerCase()
40+
return candidates.find((candidate) => (candidate.name ?? '').trim().toLowerCase() === target) ?? null
41+
}
42+
43+
/** The lowercased domain of an email address, or null if it isn't shaped like `local@domain`. */
44+
const emailDomain = (address: string): string | null => {
45+
const atIndex = address.lastIndexOf('@')
46+
if (atIndex === -1) return null
47+
const domain = address
48+
.slice(atIndex + 1)
49+
.trim()
50+
.toLowerCase()
51+
return domain.length > 0 ? domain : null
52+
}
53+
54+
/** True when the sender is inside a domain we invite to sign up (see ALLOWED_ORGANIZER_EMAIL_DOMAINS). */
55+
const isAllowedOrganizerDomain = (address: string): boolean => {
56+
const domain = emailDomain(address)
57+
return domain !== null && config.allowedOrganizerEmailDomains.includes(domain)
58+
}
59+
60+
/**
61+
* Find the account that owns this invite, keying off the envelope From that Postmark
62+
* received (never the spoofable .ics ORGANIZER). Returns the organizer, or null when no event should
63+
* be created. The allowlisted domain is a hard gate checked first: any sender outside it is rejected
64+
* outright, account or not, with no reply (confirming the address "just needs to sign up" would be a
65+
* small information leak to arbitrary outsiders). Inside the allowlist, a known sender is the
66+
* organizer and an unknown one gets a "please sign up" reply.
67+
*/
68+
export const resolveOrganizer = async (inboundInvite: InboundInvite) => {
69+
const { fromAddress, invite } = inboundInvite
70+
71+
if (!isAllowedOrganizerDomain(fromAddress)) {
72+
logger.warn(`Email webhook: sender ${fromAddress} is outside the allowlisted domains; rejecting, no event created`)
73+
return null
74+
}
75+
76+
// Match the organizer case-insensitively: the domain gate above already lowercases, and a
77+
// lowercase-stored account would otherwise be missed by a mixed-case From and wrongly bounced.
78+
const organizer = await userService.getUserByEmail(fromAddress.toLowerCase())
79+
if (organizer) {
80+
// A mismatch is worth watching (a spoof attempt, or a relay rewriting headers) but not worth
81+
// blocking a real organizer's event over, so log and proceed on the trusted From.
82+
if (invite.organizer && invite.organizer.toLowerCase() !== fromAddress.toLowerCase()) {
83+
logger.warn(
84+
`Email webhook: .ics ORGANIZER ${invite.organizer} differs from envelope From ${fromAddress}; trusting From`
85+
)
86+
}
87+
return organizer
88+
}
89+
90+
await emailService.sendSignupInviteEmail(fromAddress)
91+
logger.info(`Email webhook: no account for ${fromAddress} (allowlisted domain); sent signup invite`)
92+
return null
93+
}
94+
95+
/**
96+
* Decide which existing Topic this invite belongs to, deterministically. Match the SUMMARY's
97+
* "<topic>:" prefix against the sender's candidate set (public Topics plus their own private ones),
98+
* case-insensitively; the candidate set is the permission boundary. Returns the matched Topic, or
99+
* null when nothing matches. On null the draft event is created with a blank topic for the organizer
100+
* to fill in later, rather than inventing a Topic from a one-off invite.
101+
*/
102+
export const resolveTopic = async (inboundInvite: InboundInvite, organizer): Promise<TopicDocument | null> => {
103+
const { invite } = inboundInvite
104+
105+
const candidates = await topicService.allTopicsByUser(organizer)
106+
const matched = matchTopicByPrefix(invite.summary, candidates)
107+
if (!matched?.id) return null
108+
109+
return Topic.findById(matched.id)
110+
}

src/services/resource.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ const savePdf = async (conversationId: string, resourceId: string, fileBuffer: B
100100

101101
const userId = user._id.toString()
102102
const isConvOwner = conv.owner.toString() === userId
103-
const isTopicOwner = conv.topic.owner.toString() === userId
103+
const isTopicOwner = conv.topic?.owner?.toString() === userId
104104
if (!isConvOwner && !isTopicOwner) {
105105
throw new ApiError(httpStatus.FORBIDDEN, 'Only conversation or topic owner can upload resources')
106106
}

src/types/index.types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ export interface ParsedInvite {
1515
organizer?: string // organizer email from the .ics ORGANIZER field, mailto: stripped
1616
}
1717

18+
/* The trust boundary in one shape: `invite` is attacker-supplied .ics file content (anyone can put
19+
any UID or ORGANIZER in a raw .ics), while `fromAddress` is the envelope From that Postmark actually
20+
received. Identity resolution keys off fromAddress; ORGANIZER is only ever compared against it. */
21+
export interface InboundInvite {
22+
fromAddress: string
23+
invite: ParsedInvite
24+
}
25+
1826
export interface PaginateResults<T> {
1927
results: Array<T>
2028
page: number

src/websockets/websocketGateway.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,13 @@ class WebsocketGateway {
7474
}
7575

7676
async broadcastNewConversation(conversation) {
77+
// A topicless draft (e.g. from an unmatched inbound invite) has no topic room to broadcast into.
78+
if (!conversation.topic) return
7779
await this.broadcast(conversation.topic._id.toString(), 'conversation:new', conversation)
7880
}
7981

8082
async broadcastConversationUpdate(conversation) {
83+
if (!conversation.topic) return
8184
await this.broadcast(conversation.topic._id.toString(), 'conversation:update', conversation)
8285
}
8386

0 commit comments

Comments
 (0)