comparison libervia/backend/plugins/plugin_misc_email_invitation.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/plugins/plugin_misc_email_invitation.py@524856bd7b19
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3 # SàT plugin for sending invitations by email
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 import shortuuid
20 from typing import Optional
21 from twisted.internet import defer
22 from twisted.words.protocols.jabber import jid
23 from twisted.words.protocols.jabber import error
24 from twisted.words.protocols.jabber import sasl
25 from libervia.backend.core.i18n import _, D_
26 from libervia.backend.core.constants import Const as C
27 from libervia.backend.core import exceptions
28 from libervia.backend.core.log import getLogger
29 from libervia.backend.tools import utils
30 from libervia.backend.tools.common import data_format
31 from libervia.backend.memory import persistent
32 from libervia.backend.tools.common import email as sat_email
33
34 log = getLogger(__name__)
35
36
37 PLUGIN_INFO = {
38 C.PI_NAME: "Email Invitations",
39 C.PI_IMPORT_NAME: "EMAIL_INVITATION",
40 C.PI_TYPE: C.PLUG_TYPE_MISC,
41 C.PI_DEPENDENCIES: ['XEP-0077'],
42 C.PI_RECOMMENDATIONS: ["IDENTITY"],
43 C.PI_MAIN: "InvitationsPlugin",
44 C.PI_HANDLER: "no",
45 C.PI_DESCRIPTION: _("""invitation of people without XMPP account""")
46 }
47
48
49 SUFFIX_MAX = 5
50 INVITEE_PROFILE_TPL = "guest@@{uuid}"
51 KEY_ID = 'id'
52 KEY_JID = 'jid'
53 KEY_CREATED = 'created'
54 KEY_LAST_CONNECTION = 'last_connection'
55 KEY_GUEST_PROFILE = 'guest_profile'
56 KEY_PASSWORD = 'password'
57 KEY_EMAILS_EXTRA = 'emails_extra'
58 EXTRA_RESERVED = {KEY_ID, KEY_JID, KEY_CREATED, 'jid_', 'jid', KEY_LAST_CONNECTION,
59 KEY_GUEST_PROFILE, KEY_PASSWORD, KEY_EMAILS_EXTRA}
60 DEFAULT_SUBJECT = D_("You have been invited by {host_name} to {app_name}")
61 DEFAULT_BODY = D_("""Hello {name}!
62
63 You have received an invitation from {host_name} to participate to "{app_name}".
64 To join, you just have to click on the following URL:
65 {url}
66
67 Please note that this URL should not be shared with anybody!
68 If you want more details on {app_name}, you can check {app_url}.
69
70 Welcome!
71 """)
72
73
74 class InvitationsPlugin(object):
75
76 def __init__(self, host):
77 log.info(_("plugin Invitations initialization"))
78 self.host = host
79 self.invitations = persistent.LazyPersistentBinaryDict('invitations')
80 host.bridge.add_method("invitation_create", ".plugin", in_sign='sasssssssssa{ss}s',
81 out_sign='a{ss}',
82 method=self._create,
83 async_=True)
84 host.bridge.add_method("invitation_get", ".plugin", in_sign='s', out_sign='a{ss}',
85 method=self.get,
86 async_=True)
87 host.bridge.add_method("invitation_delete", ".plugin", in_sign='s', out_sign='',
88 method=self._delete,
89 async_=True)
90 host.bridge.add_method("invitation_modify", ".plugin", in_sign='sa{ss}b',
91 out_sign='',
92 method=self._modify,
93 async_=True)
94 host.bridge.add_method("invitation_list", ".plugin", in_sign='s',
95 out_sign='a{sa{ss}}',
96 method=self._list,
97 async_=True)
98 host.bridge.add_method("invitation_simple_create", ".plugin", in_sign='sssss',
99 out_sign='a{ss}',
100 method=self._simple_create,
101 async_=True)
102
103 def check_extra(self, extra):
104 if EXTRA_RESERVED.intersection(extra):
105 raise ValueError(
106 _("You can't use following key(s) in extra, they are reserved: {}")
107 .format(', '.join(EXTRA_RESERVED.intersection(extra))))
108
109 def _create(self, email='', emails_extra=None, jid_='', password='', name='',
110 host_name='', language='', url_template='', message_subject='',
111 message_body='', extra=None, profile=''):
112 # XXX: we don't use **kwargs here to keep arguments name for introspection with
113 # D-Bus bridge
114 if emails_extra is None:
115 emails_extra = []
116
117 if extra is None:
118 extra = {}
119 else:
120 extra = {str(k): str(v) for k,v in extra.items()}
121
122 kwargs = {"extra": extra,
123 KEY_EMAILS_EXTRA: [str(e) for e in emails_extra]
124 }
125
126 # we need to be sure that values are unicode, else they won't be pickled correctly
127 # with D-Bus
128 for key in ("jid_", "password", "name", "host_name", "email", "language",
129 "url_template", "message_subject", "message_body", "profile"):
130 value = locals()[key]
131 if value:
132 kwargs[key] = str(value)
133 return defer.ensureDeferred(self.create(**kwargs))
134
135 async def get_existing_invitation(self, email: Optional[str]) -> Optional[dict]:
136 """Retrieve existing invitation with given email
137
138 @param email: check if any invitation exist with this email
139 @return: first found invitation, or None if nothing found
140 """
141 # FIXME: This method is highly inefficient, it get all invitations and check them
142 # one by one, this is just a temporary way to avoid creating creating new accounts
143 # for an existing email. A better way will be available with Libervia 0.9.
144 # TODO: use a better way to check existing invitations
145
146 if email is None:
147 return None
148 all_invitations = await self.invitations.all()
149 for id_, invitation in all_invitations.items():
150 if invitation.get("email") == email:
151 invitation[KEY_ID] = id_
152 return invitation
153
154 async def _create_account_and_profile(
155 self,
156 id_: str,
157 kwargs: dict,
158 extra: dict
159 ) -> None:
160 """Create XMPP account and Libervia profile for guest"""
161 ## XMPP account creation
162 password = kwargs.pop('password', None)
163 if password is None:
164 password = utils.generate_password()
165 assert password
166 # XXX: password is here saved in clear in database
167 # it is needed for invitation as the same password is used for profile
168 # and SàT need to be able to automatically open the profile with the uuid
169 # FIXME: we could add an extra encryption key which would be used with the
170 # uuid when the invitee is connecting (e.g. with URL). This key would
171 # not be saved and could be used to encrypt profile password.
172 extra[KEY_PASSWORD] = password
173
174 jid_ = kwargs.pop('jid_', None)
175 if not jid_:
176 domain = self.host.memory.config_get(None, 'xmpp_domain')
177 if not domain:
178 # TODO: fallback to profile's domain
179 raise ValueError(_("You need to specify xmpp_domain in sat.conf"))
180 jid_ = "invitation-{uuid}@{domain}".format(uuid=shortuuid.uuid(),
181 domain=domain)
182 jid_ = jid.JID(jid_)
183 extra[KEY_JID] = jid_.full()
184
185 if jid_.user:
186 # we don't register account if there is no user as anonymous login is then
187 # used
188 try:
189 await self.host.plugins['XEP-0077'].register_new_account(jid_, password)
190 except error.StanzaError as e:
191 prefix = jid_.user
192 idx = 0
193 while e.condition == 'conflict':
194 if idx >= SUFFIX_MAX:
195 raise exceptions.ConflictError(_("Can't create XMPP account"))
196 jid_.user = prefix + '_' + str(idx)
197 log.info(_("requested jid already exists, trying with {}".format(
198 jid_.full())))
199 try:
200 await self.host.plugins['XEP-0077'].register_new_account(
201 jid_,
202 password
203 )
204 except error.StanzaError:
205 idx += 1
206 else:
207 break
208 if e.condition != 'conflict':
209 raise e
210
211 log.info(_("account {jid_} created").format(jid_=jid_.full()))
212
213 ## profile creation
214
215 extra[KEY_GUEST_PROFILE] = guest_profile = INVITEE_PROFILE_TPL.format(
216 uuid=id_
217 )
218 # profile creation should not fail as we generate unique name ourselves
219 await self.host.memory.create_profile(guest_profile, password)
220 await self.host.memory.start_session(password, guest_profile)
221 await self.host.memory.param_set("JabberID", jid_.full(), "Connection",
222 profile_key=guest_profile)
223 await self.host.memory.param_set("Password", password, "Connection",
224 profile_key=guest_profile)
225
226 async def create(self, **kwargs):
227 r"""Create an invitation
228
229 This will create an XMPP account and a profile, and use a UUID to retrieve them.
230 The profile is automatically generated in the form guest@@[UUID], this way they
231 can be retrieved easily
232 **kwargs: keywords arguments which can have the following keys, unset values are
233 equivalent to None:
234 jid_(jid.JID, None): jid to use for invitation, the jid will be created using
235 XEP-0077
236 if the jid has no user part, an anonymous account will be used (no XMPP
237 account created in this case)
238 if None, automatically generate an account name (in the form
239 "invitation-[random UUID]@domain.tld") (note that this UUID is not the
240 same as the invitation one, as jid can be used publicly (leaking the
241 UUID), and invitation UUID give access to account.
242 in case of conflict, a suffix number is added to the account until a free
243 one if found (with a failure if SUFFIX_MAX is reached)
244 password(unicode, None): password to use (will be used for XMPP account and
245 profile)
246 None to automatically generate one
247 name(unicode, None): name of the invitee
248 will be set as profile identity if present
249 host_name(unicode, None): name of the host
250 email(unicode, None): email to send the invitation to
251 if None, no invitation email is sent, you can still associate email using
252 extra
253 if email is used, extra can't have "email" key
254 language(unicode): language of the invitee (used notabily to translate the
255 invitation)
256 TODO: not used yet
257 url_template(unicode, None): template to use to construct the invitation URL
258 use {uuid} as a placeholder for identifier
259 use None if you don't want to include URL (or if it is already specified
260 in custom message)
261 /!\ you must put full URL, don't forget https://
262 /!\ the URL will give access to the invitee account, you should warn in
263 message to not publish it publicly
264 message_subject(unicode, None): customised message body for the invitation
265 email
266 None to use default subject
267 uses the same substitution as for message_body
268 message_body(unicode, None): customised message body for the invitation email
269 None to use default body
270 use {name} as a place holder for invitee name
271 use {url} as a placeholder for the invitation url
272 use {uuid} as a placeholder for the identifier
273 use {app_name} as a placeholder for this software name
274 use {app_url} as a placeholder for this software official website
275 use {profile} as a placeholder for host's profile
276 use {host_name} as a placeholder for host's name
277 extra(dict, None): extra data to associate with the invitee
278 some keys are reserved:
279 - created (creation date)
280 if email argument is used, "email" key can't be used
281 profile(unicode, None): profile of the host (person who is inviting)
282 @return (dict[unicode, unicode]): dictionary with:
283 - UUID associated with the invitee (key: id)
284 - filled extra dictionary, as saved in the databae
285 """
286 ## initial checks
287 extra = kwargs.pop('extra', {})
288 if set(kwargs).intersection(extra):
289 raise ValueError(
290 _("You can't use following key(s) in both args and extra: {}").format(
291 ', '.join(set(kwargs).intersection(extra))))
292
293 self.check_extra(extra)
294
295 email = kwargs.pop('email', None)
296
297 existing = await self.get_existing_invitation(email)
298 if existing is not None:
299 log.info(f"There is already an invitation for {email!r}")
300 extra.update(existing)
301 del extra[KEY_ID]
302
303 emails_extra = kwargs.pop('emails_extra', [])
304 if not email and emails_extra:
305 raise ValueError(
306 _('You need to provide a main email address before using emails_extra'))
307
308 if (email is not None
309 and not 'url_template' in kwargs
310 and not 'message_body' in kwargs):
311 raise ValueError(
312 _("You need to provide url_template if you use default message body"))
313
314 ## uuid
315 log.info(_("creating an invitation"))
316 id_ = existing[KEY_ID] if existing else str(shortuuid.uuid())
317
318 if existing is None:
319 await self._create_account_and_profile(id_, kwargs, extra)
320
321 profile = kwargs.pop('profile', None)
322 guest_profile = extra[KEY_GUEST_PROFILE]
323 jid_ = jid.JID(extra[KEY_JID])
324
325 ## identity
326 name = kwargs.pop('name', None)
327 password = extra[KEY_PASSWORD]
328 if name is not None:
329 extra['name'] = name
330 try:
331 id_plugin = self.host.plugins['IDENTITY']
332 except KeyError:
333 pass
334 else:
335 await self.host.connect(guest_profile, password)
336 guest_client = self.host.get_client(guest_profile)
337 await id_plugin.set_identity(guest_client, {'nicknames': [name]})
338 await self.host.disconnect(guest_profile)
339
340 ## email
341 language = kwargs.pop('language', None)
342 if language is not None:
343 extra['language'] = language.strip()
344
345 if email is not None:
346 extra['email'] = email
347 data_format.iter2dict(KEY_EMAILS_EXTRA, extra)
348 url_template = kwargs.pop('url_template', '')
349 format_args = {
350 'uuid': id_,
351 'app_name': C.APP_NAME,
352 'app_url': C.APP_URL}
353
354 if name is None:
355 format_args['name'] = email
356 else:
357 format_args['name'] = name
358
359 if profile is None:
360 format_args['profile'] = ''
361 else:
362 format_args['profile'] = extra['profile'] = profile
363
364 host_name = kwargs.pop('host_name', None)
365 if host_name is None:
366 format_args['host_name'] = profile or _("somebody")
367 else:
368 format_args['host_name'] = extra['host_name'] = host_name
369
370 invite_url = url_template.format(**format_args)
371 format_args['url'] = invite_url
372
373 await sat_email.send_email(
374 self.host.memory.config,
375 [email] + emails_extra,
376 (kwargs.pop('message_subject', None) or DEFAULT_SUBJECT).format(
377 **format_args),
378 (kwargs.pop('message_body', None) or DEFAULT_BODY).format(**format_args),
379 )
380
381 ## roster
382
383 # we automatically add guest to host roster (if host is specified)
384 # FIXME: a parameter to disable auto roster adding would be nice
385 if profile is not None:
386 try:
387 client = self.host.get_client(profile)
388 except Exception as e:
389 log.error(f"Can't get host profile: {profile}: {e}")
390 else:
391 await self.host.contact_update(client, jid_, name, ['guests'])
392
393 if kwargs:
394 log.warning(_("Not all arguments have been consumed: {}").format(kwargs))
395
396 ## extra data saving
397 self.invitations[id_] = extra
398
399 extra[KEY_ID] = id_
400
401 return extra
402
403 def _simple_create(self, invitee_email, invitee_name, url_template, extra_s, profile):
404 client = self.host.get_client(profile)
405 # FIXME: needed because python-dbus use a specific string class
406 invitee_email = str(invitee_email)
407 invitee_name = str(invitee_name)
408 url_template = str(url_template)
409 extra = data_format.deserialise(extra_s)
410 d = defer.ensureDeferred(
411 self.simple_create(client, invitee_email, invitee_name, url_template, extra)
412 )
413 d.addCallback(lambda data: {k: str(v) for k,v in data.items()})
414 return d
415
416 async def simple_create(
417 self, client, invitee_email, invitee_name, url_template, extra):
418 """Simplified method to invite somebody by email"""
419 return await self.create(
420 name=invitee_name,
421 email=invitee_email,
422 url_template=url_template,
423 profile=client.profile,
424 )
425
426 def get(self, id_):
427 """Retrieve invitation linked to uuid if it exists
428
429 @param id_(unicode): UUID linked to an invitation
430 @return (dict[unicode, unicode]): data associated to the invitation
431 @raise KeyError: there is not invitation with this id_
432 """
433 return self.invitations[id_]
434
435 def _delete(self, id_):
436 return defer.ensureDeferred(self.delete(id_))
437
438 async def delete(self, id_):
439 """Delete an invitation data and associated XMPP account"""
440 log.info(f"deleting invitation {id_}")
441 data = await self.get(id_)
442 guest_profile = data['guest_profile']
443 password = data['password']
444 try:
445 await self.host.connect(guest_profile, password)
446 guest_client = self.host.get_client(guest_profile)
447 # XXX: be extra careful to use guest_client and not client below, as this will
448 # delete the associated XMPP account
449 log.debug("deleting XMPP account")
450 await self.host.plugins['XEP-0077'].unregister(guest_client, None)
451 except (error.StanzaError, sasl.SASLAuthError) as e:
452 log.warning(
453 f"Can't delete {guest_profile}'s XMPP account, maybe it as already been "
454 f"deleted: {e}")
455 try:
456 await self.host.memory.profile_delete_async(guest_profile, True)
457 except Exception as e:
458 log.warning(f"Can't delete guest profile {guest_profile}: {e}")
459 log.debug("removing guest data")
460 await self.invitations.adel(id_)
461 log.info(f"{id_} invitation has been deleted")
462
463 def _modify(self, id_, new_extra, replace):
464 return self.modify(id_, {str(k): str(v) for k,v in new_extra.items()},
465 replace)
466
467 def modify(self, id_, new_extra, replace=False):
468 """Modify invitation data
469
470 @param id_(unicode): UUID linked to an invitation
471 @param new_extra(dict[unicode, unicode]): data to update
472 empty values will be deleted if replace is True
473 @param replace(bool): if True replace the data
474 else update them
475 @raise KeyError: there is not invitation with this id_
476 """
477 self.check_extra(new_extra)
478 def got_current_data(current_data):
479 if replace:
480 new_data = new_extra
481 for k in EXTRA_RESERVED:
482 try:
483 new_data[k] = current_data[k]
484 except KeyError:
485 continue
486 else:
487 new_data = current_data
488 for k,v in new_extra.items():
489 if k in EXTRA_RESERVED:
490 log.warning(_("Skipping reserved key {key}").format(key=k))
491 continue
492 if v:
493 new_data[k] = v
494 else:
495 try:
496 del new_data[k]
497 except KeyError:
498 pass
499
500 self.invitations[id_] = new_data
501
502 d = self.invitations[id_]
503 d.addCallback(got_current_data)
504 return d
505
506 def _list(self, profile=C.PROF_KEY_NONE):
507 return defer.ensureDeferred(self.list(profile))
508
509 async def list(self, profile=C.PROF_KEY_NONE):
510 """List invitations
511
512 @param profile(unicode): return invitation linked to this profile only
513 C.PROF_KEY_NONE: don't filter invitations
514 @return list(unicode): invitations uids
515 """
516 invitations = await self.invitations.all()
517 if profile != C.PROF_KEY_NONE:
518 invitations = {id_:data for id_, data in invitations.items()
519 if data.get('profile') == profile}
520
521 return invitations