##// END OF EJS Templates
email: added help block on where to change email settings
marcink -
r2725:c0939830 default
parent child Browse files
Show More
@@ -1,542 +1,543 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 RhodeCode authentication plugin for LDAP
23 23 """
24 24
25 25 import socket
26 26 import re
27 27 import colander
28 28 import logging
29 29 import traceback
30 30 import string
31 31
32 32 from rhodecode.translation import _
33 33 from rhodecode.authentication.base import (
34 34 RhodeCodeExternalAuthPlugin, chop_at, hybrid_property)
35 35 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
36 36 from rhodecode.authentication.routes import AuthnPluginResourceBase
37 37 from rhodecode.lib.colander_utils import strip_whitespace
38 38 from rhodecode.lib.exceptions import (
39 39 LdapConnectionError, LdapUsernameError, LdapPasswordError, LdapImportError
40 40 )
41 41 from rhodecode.lib.utils2 import safe_unicode, safe_str
42 42 from rhodecode.model.db import User
43 43 from rhodecode.model.validators import Missing
44 44
45 45 log = logging.getLogger(__name__)
46 46
47 47 try:
48 48 import ldap
49 49 except ImportError:
50 50 # means that python-ldap is not installed, we use Missing object to mark
51 51 # ldap lib is Missing
52 52 ldap = Missing
53 53
54 54
55 55 class LdapError(Exception):
56 56 pass
57 57
58
58 59 def plugin_factory(plugin_id, *args, **kwds):
59 60 """
60 61 Factory function that is called during plugin discovery.
61 62 It returns the plugin instance.
62 63 """
63 64 plugin = RhodeCodeAuthPlugin(plugin_id)
64 65 return plugin
65 66
66 67
67 68 class LdapAuthnResource(AuthnPluginResourceBase):
68 69 pass
69 70
70 71
71 72 class LdapSettingsSchema(AuthnPluginSettingsSchemaBase):
72 73 tls_kind_choices = ['PLAIN', 'LDAPS', 'START_TLS']
73 74 tls_reqcert_choices = ['NEVER', 'ALLOW', 'TRY', 'DEMAND', 'HARD']
74 75 search_scope_choices = ['BASE', 'ONELEVEL', 'SUBTREE']
75 76
76 77 host = colander.SchemaNode(
77 78 colander.String(),
78 79 default='',
79 80 description=_('Host[s] of the LDAP Server \n'
80 81 '(e.g., 192.168.2.154, or ldap-server.domain.com.\n '
81 82 'Multiple servers can be specified using commas'),
82 83 preparer=strip_whitespace,
83 84 title=_('LDAP Host'),
84 85 widget='string')
85 86 port = colander.SchemaNode(
86 87 colander.Int(),
87 88 default=389,
88 89 description=_('Custom port that the LDAP server is listening on. '
89 90 'Default value is: 389'),
90 91 preparer=strip_whitespace,
91 92 title=_('Port'),
92 93 validator=colander.Range(min=0, max=65536),
93 94 widget='int')
94 95
95 96 timeout = colander.SchemaNode(
96 97 colander.Int(),
97 98 default=60 * 5,
98 99 description=_('Timeout for LDAP connection'),
99 100 preparer=strip_whitespace,
100 101 title=_('Connection timeout'),
101 102 validator=colander.Range(min=1),
102 103 widget='int')
103 104
104 105 dn_user = colander.SchemaNode(
105 106 colander.String(),
106 107 default='',
107 108 description=_('Optional user DN/account to connect to LDAP if authentication is required. \n'
108 109 'e.g., cn=admin,dc=mydomain,dc=com, or '
109 110 'uid=root,cn=users,dc=mydomain,dc=com, or admin@mydomain.com'),
110 111 missing='',
111 112 preparer=strip_whitespace,
112 113 title=_('Account'),
113 114 widget='string')
114 115 dn_pass = colander.SchemaNode(
115 116 colander.String(),
116 117 default='',
117 118 description=_('Password to authenticate for given user DN.'),
118 119 missing='',
119 120 preparer=strip_whitespace,
120 121 title=_('Password'),
121 122 widget='password')
122 123 tls_kind = colander.SchemaNode(
123 124 colander.String(),
124 125 default=tls_kind_choices[0],
125 126 description=_('TLS Type'),
126 127 title=_('Connection Security'),
127 128 validator=colander.OneOf(tls_kind_choices),
128 129 widget='select')
129 130 tls_reqcert = colander.SchemaNode(
130 131 colander.String(),
131 132 default=tls_reqcert_choices[0],
132 133 description=_('Require Cert over TLS?. Self-signed and custom '
133 134 'certificates can be used when\n `RhodeCode Certificate` '
134 135 'found in admin > settings > system info page is extended.'),
135 136 title=_('Certificate Checks'),
136 137 validator=colander.OneOf(tls_reqcert_choices),
137 138 widget='select')
138 139 base_dn = colander.SchemaNode(
139 140 colander.String(),
140 141 default='',
141 142 description=_('Base DN to search. Dynamic bind is supported. Add `$login` marker '
142 143 'in it to be replaced with current user credentials \n'
143 144 '(e.g., dc=mydomain,dc=com, or ou=Users,dc=mydomain,dc=com)'),
144 145 missing='',
145 146 preparer=strip_whitespace,
146 147 title=_('Base DN'),
147 148 widget='string')
148 149 filter = colander.SchemaNode(
149 150 colander.String(),
150 151 default='',
151 152 description=_('Filter to narrow results \n'
152 153 '(e.g., (&(objectCategory=Person)(objectClass=user)), or \n'
153 154 '(memberof=cn=rc-login,ou=groups,ou=company,dc=mydomain,dc=com)))'),
154 155 missing='',
155 156 preparer=strip_whitespace,
156 157 title=_('LDAP Search Filter'),
157 158 widget='string')
158 159
159 160 search_scope = colander.SchemaNode(
160 161 colander.String(),
161 162 default=search_scope_choices[2],
162 163 description=_('How deep to search LDAP. If unsure set to SUBTREE'),
163 164 title=_('LDAP Search Scope'),
164 165 validator=colander.OneOf(search_scope_choices),
165 166 widget='select')
166 167 attr_login = colander.SchemaNode(
167 168 colander.String(),
168 169 default='uid',
169 170 description=_('LDAP Attribute to map to user name (e.g., uid, or sAMAccountName)'),
170 171 preparer=strip_whitespace,
171 172 title=_('Login Attribute'),
172 173 missing_msg=_('The LDAP Login attribute of the CN must be specified'),
173 174 widget='string')
174 175 attr_firstname = colander.SchemaNode(
175 176 colander.String(),
176 177 default='',
177 178 description=_('LDAP Attribute to map to first name (e.g., givenName)'),
178 179 missing='',
179 180 preparer=strip_whitespace,
180 181 title=_('First Name Attribute'),
181 182 widget='string')
182 183 attr_lastname = colander.SchemaNode(
183 184 colander.String(),
184 185 default='',
185 186 description=_('LDAP Attribute to map to last name (e.g., sn)'),
186 187 missing='',
187 188 preparer=strip_whitespace,
188 189 title=_('Last Name Attribute'),
189 190 widget='string')
190 191 attr_email = colander.SchemaNode(
191 192 colander.String(),
192 193 default='',
193 194 description=_('LDAP Attribute to map to email address (e.g., mail).\n'
194 195 'Emails are a crucial part of RhodeCode. \n'
195 196 'If possible add a valid email attribute to ldap users.'),
196 197 missing='',
197 198 preparer=strip_whitespace,
198 199 title=_('Email Attribute'),
199 200 widget='string')
200 201
201 202
202 203 class AuthLdap(object):
203 204
204 205 def _build_servers(self):
205 206 def host_resolver(host, port):
206 207 """
207 208 Main work for this function is to prevent ldap connection issues,
208 209 and detect them early using a "greenified" sockets
209 210 """
210 211 host = host.strip()
211 212
212 213 log.info('Resolving LDAP host %s', host)
213 214 try:
214 215 ip = socket.gethostbyname(host)
215 216 log.info('Got LDAP server %s ip %s', host, ip)
216 217 except Exception:
217 218 raise LdapConnectionError(
218 219 'Failed to resolve host: `{}`'.format(host))
219 220
220 221 log.info('Checking LDAP IP access %s', ip)
221 222 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
222 223 try:
223 224 s.connect((ip, int(port)))
224 225 s.shutdown(socket.SHUT_RD)
225 226 except Exception:
226 227 raise LdapConnectionError(
227 228 'Failed to connect to host: `{}:{}`'.format(host, port))
228 229
229 230 return '{}:{}'.format(host, port)
230 231
231 232 port = self.LDAP_SERVER_PORT
232 233 return ', '.join(
233 234 ["{}://{}".format(
234 235 self.ldap_server_type, host_resolver(host, port))
235 236 for host in self.SERVER_ADDRESSES])
236 237
237 238 def __init__(self, server, base_dn, port=389, bind_dn='', bind_pass='',
238 239 tls_kind='PLAIN', tls_reqcert='DEMAND', ldap_version=3,
239 240 search_scope='SUBTREE', attr_login='uid',
240 241 ldap_filter='', timeout=None):
241 242 if ldap == Missing:
242 243 raise LdapImportError("Missing or incompatible ldap library")
243 244
244 245 self.debug = False
245 246 self.timeout = timeout or 60 * 5
246 247 self.ldap_version = ldap_version
247 248 self.ldap_server_type = 'ldap'
248 249
249 250 self.TLS_KIND = tls_kind
250 251
251 252 if self.TLS_KIND == 'LDAPS':
252 253 port = port or 689
253 254 self.ldap_server_type += 's'
254 255
255 256 OPT_X_TLS_DEMAND = 2
256 257 self.TLS_REQCERT = getattr(ldap, 'OPT_X_TLS_%s' % tls_reqcert,
257 258 OPT_X_TLS_DEMAND)
258 259 # split server into list
259 260 self.SERVER_ADDRESSES = server.split(',')
260 261 self.LDAP_SERVER_PORT = port
261 262
262 263 # USE FOR READ ONLY BIND TO LDAP SERVER
263 264 self.attr_login = attr_login
264 265
265 266 self.LDAP_BIND_DN = safe_str(bind_dn)
266 267 self.LDAP_BIND_PASS = safe_str(bind_pass)
267 268 self.LDAP_SERVER = self._build_servers()
268 269 self.SEARCH_SCOPE = getattr(ldap, 'SCOPE_%s' % search_scope)
269 270 self.BASE_DN = safe_str(base_dn)
270 271 self.LDAP_FILTER = safe_str(ldap_filter)
271 272
272 273 def _get_ldap_conn(self):
273 274 log.debug('initializing LDAP connection to:%s', self.LDAP_SERVER)
274 275
275 276 if self.debug:
276 277 ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255)
277 278
278 279 if hasattr(ldap, 'OPT_X_TLS_CACERTDIR'):
279 280 ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, '/etc/openldap/cacerts')
280 281 if self.TLS_KIND != 'PLAIN':
281 282 ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT)
282 283
283 284 ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
284 285 ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON)
285 286
286 287 # init connection now
287 288 ldap_conn = ldap.initialize(self.LDAP_SERVER)
288 289 ldap_conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout)
289 290 ldap_conn.set_option(ldap.OPT_TIMEOUT, self.timeout)
290 291 ldap_conn.timeout = self.timeout
291 292
292 293 if self.ldap_version == 2:
293 294 ldap_conn.protocol = ldap.VERSION2
294 295 else:
295 296 ldap_conn.protocol = ldap.VERSION3
296 297
297 298 if self.TLS_KIND == 'START_TLS':
298 299 ldap_conn.start_tls_s()
299 300
300 301 if self.LDAP_BIND_DN and self.LDAP_BIND_PASS:
301 302 log.debug('Trying simple_bind with password and given login DN: %s',
302 303 self.LDAP_BIND_DN)
303 304 ldap_conn.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS)
304 305
305 306 return ldap_conn
306 307
307 308 def get_uid(self, username):
308 309 uid = username
309 310 for server_addr in self.SERVER_ADDRESSES:
310 311 uid = chop_at(username, "@%s" % server_addr)
311 312 return uid
312 313
313 314 def fetch_attrs_from_simple_bind(self, server, dn, username, password):
314 315 try:
315 316 log.debug('Trying simple bind with %s', dn)
316 317 server.simple_bind_s(dn, safe_str(password))
317 318 user = server.search_ext_s(
318 319 dn, ldap.SCOPE_BASE, '(objectClass=*)', )[0]
319 320 _, attrs = user
320 321 return attrs
321 322
322 323 except ldap.INVALID_CREDENTIALS:
323 324 log.debug(
324 325 "LDAP rejected password for user '%s': %s, org_exc:",
325 326 username, dn, exc_info=True)
326 327
327 328 def authenticate_ldap(self, username, password):
328 329 """
329 330 Authenticate a user via LDAP and return his/her LDAP properties.
330 331
331 332 Raises AuthenticationError if the credentials are rejected, or
332 333 EnvironmentError if the LDAP server can't be reached.
333 334
334 335 :param username: username
335 336 :param password: password
336 337 """
337 338
338 339 uid = self.get_uid(username)
339 340
340 341 if not password:
341 342 msg = "Authenticating user %s with blank password not allowed"
342 343 log.warning(msg, username)
343 344 raise LdapPasswordError(msg)
344 345 if "," in username:
345 346 raise LdapUsernameError(
346 347 "invalid character `,` in username: `{}`".format(username))
347 348 ldap_conn = None
348 349 try:
349 350 ldap_conn = self._get_ldap_conn()
350 351 filter_ = '(&%s(%s=%s))' % (
351 352 self.LDAP_FILTER, self.attr_login, username)
352 353 log.debug("Authenticating %r filter %s at %s", self.BASE_DN,
353 354 filter_, self.LDAP_SERVER)
354 355 lobjects = ldap_conn.search_ext_s(
355 356 self.BASE_DN, self.SEARCH_SCOPE, filter_)
356 357
357 358 if not lobjects:
358 359 log.debug("No matching LDAP objects for authentication "
359 360 "of UID:'%s' username:(%s)", uid, username)
360 361 raise ldap.NO_SUCH_OBJECT()
361 362
362 363 log.debug('Found matching ldap object, trying to authenticate')
363 364 for (dn, _attrs) in lobjects:
364 365 if dn is None:
365 366 continue
366 367
367 368 user_attrs = self.fetch_attrs_from_simple_bind(
368 369 ldap_conn, dn, username, password)
369 370 if user_attrs:
370 371 break
371 372
372 373 else:
373 374 raise LdapPasswordError(
374 375 'Failed to authenticate user `{}`'
375 376 'with given password'.format(username))
376 377
377 378 except ldap.NO_SUCH_OBJECT:
378 379 log.debug("LDAP says no such user '%s' (%s), org_exc:",
379 380 uid, username, exc_info=True)
380 381 raise LdapUsernameError('Unable to find user')
381 382 except ldap.SERVER_DOWN:
382 383 org_exc = traceback.format_exc()
383 384 raise LdapConnectionError(
384 385 "LDAP can't access authentication "
385 386 "server, org_exc:%s" % org_exc)
386 387 finally:
387 388 if ldap_conn:
388 389 log.debug('ldap: connection release')
389 390 try:
390 391 ldap_conn.unbind_s()
391 392 except Exception:
392 393 # for any reason this can raise exception we must catch it
393 394 # to not crush the server
394 395 pass
395 396
396 397 return dn, user_attrs
397 398
398 399
399 400 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
400 401 # used to define dynamic binding in the
401 402 DYNAMIC_BIND_VAR = '$login'
402 403 _settings_unsafe_keys = ['dn_pass']
403 404
404 405 def includeme(self, config):
405 406 config.add_authn_plugin(self)
406 407 config.add_authn_resource(self.get_id(), LdapAuthnResource(self))
407 408 config.add_view(
408 409 'rhodecode.authentication.views.AuthnPluginViewBase',
409 410 attr='settings_get',
410 411 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
411 412 request_method='GET',
412 413 route_name='auth_home',
413 414 context=LdapAuthnResource)
414 415 config.add_view(
415 416 'rhodecode.authentication.views.AuthnPluginViewBase',
416 417 attr='settings_post',
417 418 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
418 419 request_method='POST',
419 420 route_name='auth_home',
420 421 context=LdapAuthnResource)
421 422
422 423 def get_settings_schema(self):
423 424 return LdapSettingsSchema()
424 425
425 426 def get_display_name(self):
426 427 return _('LDAP')
427 428
428 429 @hybrid_property
429 430 def name(self):
430 431 return "ldap"
431 432
432 433 def use_fake_password(self):
433 434 return True
434 435
435 436 def user_activation_state(self):
436 437 def_user_perms = User.get_default_user().AuthUser().permissions['global']
437 438 return 'hg.extern_activate.auto' in def_user_perms
438 439
439 440 def try_dynamic_binding(self, username, password, current_args):
440 441 """
441 442 Detects marker inside our original bind, and uses dynamic auth if
442 443 present
443 444 """
444 445
445 446 org_bind = current_args['bind_dn']
446 447 passwd = current_args['bind_pass']
447 448
448 449 def has_bind_marker(username):
449 450 if self.DYNAMIC_BIND_VAR in username:
450 451 return True
451 452
452 453 # we only passed in user with "special" variable
453 454 if org_bind and has_bind_marker(org_bind) and not passwd:
454 455 log.debug('Using dynamic user/password binding for ldap '
455 456 'authentication. Replacing `%s` with username',
456 457 self.DYNAMIC_BIND_VAR)
457 458 current_args['bind_dn'] = org_bind.replace(
458 459 self.DYNAMIC_BIND_VAR, username)
459 460 current_args['bind_pass'] = password
460 461
461 462 return current_args
462 463
463 464 def auth(self, userobj, username, password, settings, **kwargs):
464 465 """
465 466 Given a user object (which may be null), username, a plaintext password,
466 467 and a settings object (containing all the keys needed as listed in
467 468 settings()), authenticate this user's login attempt.
468 469
469 470 Return None on failure. On success, return a dictionary of the form:
470 471
471 472 see: RhodeCodeAuthPluginBase.auth_func_attrs
472 473 This is later validated for correctness
473 474 """
474 475
475 476 if not username or not password:
476 477 log.debug('Empty username or password skipping...')
477 478 return None
478 479
479 480 ldap_args = {
480 481 'server': settings.get('host', ''),
481 482 'base_dn': settings.get('base_dn', ''),
482 483 'port': settings.get('port'),
483 484 'bind_dn': settings.get('dn_user'),
484 485 'bind_pass': settings.get('dn_pass'),
485 486 'tls_kind': settings.get('tls_kind'),
486 487 'tls_reqcert': settings.get('tls_reqcert'),
487 488 'search_scope': settings.get('search_scope'),
488 489 'attr_login': settings.get('attr_login'),
489 490 'ldap_version': 3,
490 491 'ldap_filter': settings.get('filter'),
491 492 'timeout': settings.get('timeout')
492 493 }
493 494
494 495 ldap_attrs = self.try_dynamic_binding(username, password, ldap_args)
495 496
496 497 log.debug('Checking for ldap authentication.')
497 498
498 499 try:
499 500 aldap = AuthLdap(**ldap_args)
500 501 (user_dn, ldap_attrs) = aldap.authenticate_ldap(username, password)
501 502 log.debug('Got ldap DN response %s', user_dn)
502 503
503 504 def get_ldap_attr(k):
504 505 return ldap_attrs.get(settings.get(k), [''])[0]
505 506
506 507 # old attrs fetched from RhodeCode database
507 508 admin = getattr(userobj, 'admin', False)
508 509 active = getattr(userobj, 'active', True)
509 510 email = getattr(userobj, 'email', '')
510 511 username = getattr(userobj, 'username', username)
511 512 firstname = getattr(userobj, 'firstname', '')
512 513 lastname = getattr(userobj, 'lastname', '')
513 514 extern_type = getattr(userobj, 'extern_type', '')
514 515
515 516 groups = []
516 517 user_attrs = {
517 518 'username': username,
518 519 'firstname': safe_unicode(
519 520 get_ldap_attr('attr_firstname') or firstname),
520 521 'lastname': safe_unicode(
521 522 get_ldap_attr('attr_lastname') or lastname),
522 523 'groups': groups,
523 524 'user_group_sync': False,
524 525 'email': get_ldap_attr('attr_email') or email,
525 526 'admin': admin,
526 527 'active': active,
527 528 'active_from_extern': None,
528 529 'extern_name': user_dn,
529 530 'extern_type': extern_type,
530 531 }
531 532 log.debug('ldap user: %s', user_attrs)
532 533 log.info('user `%s` authenticated correctly', user_attrs['username'])
533 534
534 535 return user_attrs
535 536
536 537 except (LdapUsernameError, LdapPasswordError, LdapImportError):
537 538 log.exception("LDAP related exception")
538 539 return None
539 540 except (Exception,):
540 541 log.exception("Other exception")
541 542 return None
542 543
@@ -1,56 +1,60 b''
1 1 <div class="panel panel-default">
2 2 <div class="panel-heading">
3 3 <h3 class="panel-title">${_('Email Configuration')}</h3>
4 4 </div>
5 5 <div class="panel-body">
6 6 <%
7 7 elems = [
8 8 (_('Email prefix'), c.rhodecode_ini.get('email_prefix'), ''),
9 9 (_('RhodeCode email from'), c.rhodecode_ini.get('app_email_from'), ''),
10 10 (_('Error email from'), c.rhodecode_ini.get('error_email_from'), ''),
11 11 (_('Error email recipients'), c.rhodecode_ini.get('email_to'), ''),
12 12
13 13 (_('SMTP server'), c.rhodecode_ini.get('smtp_server'), ''),
14 14 (_('SMTP username'), c.rhodecode_ini.get('smtp_username'), ''),
15 15 (_('SMTP password'), '%s chars' % len(c.rhodecode_ini.get('smtp_password', '')), ''),
16 16 (_('SMTP port'), c.rhodecode_ini.get('smtp_port'), ''),
17 17
18 18 (_('SMTP use TLS'), c.rhodecode_ini.get('smtp_use_tls'), ''),
19 19 (_('SMTP use SSL'), c.rhodecode_ini.get('smtp_use_ssl'), ''),
20 20 (_('SMTP auth'), c.rhodecode_ini.get('smtp_auth'), ''),
21 21 ]
22 22 %>
23 23 <dl class="dl-horizontal settings">
24 24 %for dt, dd, tt in elems:
25 25 <dt >${dt}:</dt>
26 26 <dd title="${h.tooltip(tt)}">${dd}</dd>
27 27 %endfor
28 28 </dl>
29 <span class="help-block">
30 ${_('You can adjust those settings in [DEFAULT] section of .ini file located at')}: <br/>
31 ${c.rhodecode_ini.get('__file__')}
32 </span>
29 33 </div>
30 34 </div>
31 35
32 36 <div class="panel panel-default">
33 37 <div class="panel-heading">
34 38 <h3 class="panel-title">${_('Test Email')}</h3>
35 39 </div>
36 40 <div class="panel-body">
37 41 ${h.secure_form(h.route_path('admin_settings_email_update'), request=request)}
38 42
39 43 <div class="field input">
40 44 ${h.text('test_email', size=60, placeholder=_('enter valid email'))}
41 45 </div>
42 46 <div class="field">
43 47 <span class="help-block">
44 48 ${_('Send an auto-generated email from this server to above email...')}
45 49 </span>
46 50 </div>
47 51 <div class="buttons">
48 52 ${h.submit('send',_('Send'),class_="btn")}
49 53 </div>
50 54 ${h.end_form()}
51 55 </div>
52 56 </div>
53 57
54 58
55 59
56 60
General Comments 0
You need to be logged in to leave comments. Login now