From ae6b8a2905bfc7905030479e06f3490f2c901099 Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Sat, 26 Jan 2013 23:59:24 +0100 Subject: Factorized the code to list localparts. --- lib/Fripost/Panel/Interface.pm | 148 ++++++++++--------- lib/Fripost/Schema/Domain.pm | 22 ++- lib/Fripost/Schema/Local.pm | 312 ++++++++++++++++++++++++++++++++++++++++- lib/Fripost/Schema/Util.pm | 2 +- templates/add-alias.html | 2 +- templates/add-domain-1.html | 2 +- templates/add-domain-2.html | 2 +- templates/add-list.html | 2 +- templates/add-user.html | 2 +- templates/edit-alias.html | 2 +- templates/edit-domain.html | 2 +- templates/edit-list.html | 2 +- templates/edit-user.html | 2 +- templates/list-domains.html | 4 +- templates/list-locals.html | 46 +++--- 15 files changed, 425 insertions(+), 127 deletions(-) diff --git a/lib/Fripost/Panel/Interface.pm b/lib/Fripost/Panel/Interface.pm index 228233e..eb9d69a 100644 --- a/lib/Fripost/Panel/Interface.pm +++ b/lib/Fripost/Panel/Interface.pm @@ -51,7 +51,7 @@ sub ListDomains : StartRunmode { $template->param( $self->userInfo ); $template->param( canIAddDomain => $canIAdd ); $template->param( domains => [ map { { &fill_HTML_template_from_entry($_) - , URI => &mkURL('.', $_->{name}) + , URL => &mkURL('.', $_->{name}) , isPending => $_->{isPending} // 0 } } @domains ] ); @@ -59,6 +59,7 @@ sub ListDomains : StartRunmode { } + # Add a new (locked) domain. sub AddDomain : Runmode { my $self = shift; @@ -131,6 +132,7 @@ sub AddDomain : Runmode { } + # On this page, authenticated users can edit the domain description and # catch-alls, and toggle activation (if they have the permission). sub EditDomain : Runmode { @@ -177,106 +179,89 @@ sub EditDomain : Runmode { - - -# This Run Mode lists the known users, aliases and lists under the current -# domain. +# On this page, authenticated users can list the virtual users, aliases +# and lists under the current domain. sub ListLocals : Runmode { my $self = shift; my %CFG = $self->cfg; - my $d = ($self->split_path)[1]; + my $domainname = ($self->split_path)[1]; my $fp = Fripost::Schema::->SASLauth( $self->authen->username, %CFG ); my $q = $self->query; if (defined $q->param('unlock')) { - my $error; # TODO - $fp->domain->unlock( $d, $q->param('unlock'), -error => \$error ) + # Unlock the domain, and come back to the home page. + # Errors are thrown away. + $fp->domain->unlock( $domainname, $q->param('unlock'), -error => undef ) if $q->param('unlock') ne ''; $fp->done; return $self->redirect('../'); } - # Query *the* matching domain - my %domain = $fp->domain->get( $d, -die => 404 ); - - # Query the users, aliases and lists under the given domain. We don't - # die with a HTTP error code here, as it is not supposed to crash. - my @users = $fp->user->search( $d ); - my @aliases = $fp->alias->search( $d ); - my @lists = $fp->list->search( $d ); + # Query *the* matching domain, or 404. + my $domain = $fp->domain->search( $domainname, -filter => 'unlocked' ) + // die "404\n"; + # Query the users, aliases and lists under the given domain. + my @locals = $fp->local->search ( $domainname, sort => 1); $fp->done; + map { $_->{name} = (split_addr $_->{name})[0]; # Remove the domainpart + $_->{URL} = &mkURL('.', $_->{name}) } # Add a URL + @locals; + + my @users = grep { $_->{type} eq 'user' } @locals; + my @aliases = grep { $_->{type} eq 'alias'} @locals; + my @lists = grep { $_->{type} eq 'list' } @locals; + + # Add a link to the list (external) homepage. + map { $_->{listURL} = $CFG{'listurl_'.$_->{transport}}. + email_to_ascii($_->{name}.'@'.$domainname) } + @lists; my $template = $self->load_tmpl( 'list-locals.html', cache => 1, , loop_context_vars => 1 ); $template->param( $self->userInfo ); - $template->param( domain => encode_entities($domain{domain}) - , isactive => $domain{isactive} - , description => join ("\n", @{$domain{description}}) ); + $template->param( &fill_HTML_template_from_entry ($domain, + -loop => ['catchAll'] ) + , CAodd => not $#aliases % 2 ); + # Can the user edit the domain (change description, toggle - # activation, modify catchalls?) - $template->param( canEditDomain => $domain{permissions} =~ /[op]/ ); + # activation, modify catch-alls?) + $template->param( canEditDomain => $domain->{permissions} =~ /[op]/ ); # Can the user add users? - $template->param( canAddUser => $domain{permissions} =~ /p/ ); + $template->param( canAddUser => $domain->{permissions} =~ /p/ ); # Should we list users? $template->param( listUsers => $#users >= 0 || - $domain{permissions} =~ /p/ ); + $domain->{permissions} =~ /p/ ); $template->param( users => [ - map { { &mkLink(user => $_->{user}) - , description => join ("\n", @{$_->{description}}) - , isactive => $_->{isactive} - , forwards => [ map { {forward => encode_entities($_)} } - @{$_->{forwards}} ] - , quota => $_->{quota} - }; - } - @users - ]); + map { {&fill_HTML_template_from_entry ($_, -loop => ['forward'])} } + @users ]); # Can the user add aliases? - $template->param( canAddalias => $domain{permissions} =~ /[aop]/ ); - $template->param( listCanAddAlias => [ map { {user => encode_entities($_)} } - @{$domain{canAddAlias}} ] ) - if $domain{permissions} =~ /[op]/; + $template->param( canAddalias => $domain->{permissions} =~ /[aop]/ ); + $template->param( listCanAddAlias => [ map { {item => encode_entities($_)} } + @{$domain->{canAddAlias}} ] ) + if $domain->{permissions} =~ /[op]/; # Should we list aliases? $template->param( listAliases => $#aliases >= 0 || - $domain{permissions} =~ /[aop]/ ); + $domain->{permissions} =~ /[aop]/ ); $template->param( aliases => [ - map { { &mkLink(alias => $_->{alias}) - , description => join ("\n", @{$_->{description}}) - , isactive => $_->{isactive} - , destinations => [ map { {destination => encode_entities($_)} } - @{$_->{maildrop}} ] - }; - } - @aliases - ]); - $template->param( catchalls => [ map { {catchall => encode_entities($_)} } - @{$domain{catchalls}} ] - , CAodd => not $#aliases % 2); + map { {&fill_HTML_template_from_entry ($_, -loop => ['destination'])} } + @aliases ]); # Can the user add lists? - $template->param( canAddList => $domain{permissions} =~ /[lop]/ ); - $template->param( listCanAddList => [ map { {user => encode_entities($_)} } - @{$domain{canAddList}} ] ) - if $domain{permissions} =~ /[op]/; + $template->param( canAddList => $domain->{permissions} =~ /[lop]/ ); + $template->param( listCanAddList => [ map { {item => encode_entities($_)} } + @{$domain->{canAddList}} ] ) + if $domain->{permissions} =~ /[op]/; # Should we list lists? - $template->param( listLists => $#lists >= 0 || $domain{permissions} =~ /[lop]/ ); + $template->param( listLists => $#lists >= 0 || + $domain->{permissions} =~ /[lop]/ ); $template->param( lists => [ - map { { &mkLink(list => $_->{list}) - , description => join ("\n", @{$_->{description}}) - , isactive => $_->{isactive} - , ispending => $_->{ispending} - , transport => $_->{transport} - , listURL => $CFG{'listurl_'.$_->{transport}}. - email_to_ascii($_->{list}.'@'.$d) - }; - } - @lists - ]); + map { {&fill_HTML_template_from_entry ($_, -loop => ['destination'])} } + @lists ]); return $template->output; } @@ -531,7 +516,7 @@ sub mkLink { my $k = shift; my $d = shift; ( $k => encode_entities($d), - $k.'URI' => &mkURL('.', $d) ) + $k.'URL' => &mkURL('.', $d) ) } sub userInfo { @@ -542,41 +527,52 @@ sub userInfo { ( user_localpart => encode_entities($l) , user_domainpart => encode_entities($d) - , userURI => &mkURL ($root, $d, $l) + , userURL => &mkURL ($root, $d, $l) ) } -sub mkFormContentE { +sub mkFormContentE { # TODO delete &mkFormContent (map { encode_entities ($_) } @_); } -sub mkFormContent { +sub mkFormContent { # TODO delete join ("\x{0D}\x{0A}", @_); } -sub mkDesc { +sub mkDesc { # TODO delete my $desc = shift // return ''; join '
', map {encode_entities($_)} @$desc; } -my @single_valued_keys = qw/isActive/; +my @single_valued_keys = qw/isActive quota/; my @multi_valued_keys = qw/description catchAll - canAddAlias canAddList/; + canAddAlias canAddList + forward destination/; sub fill_HTML_template_from_entry { my $entry = shift; + my %options = @_; my %vars; foreach my $key (keys %$entry) { + next if $options{'-hide'} and + grep { $key eq $_ } @{$options{'-hide'}}; + if ($key eq 'name') { $vars{$key} = encode_entities($entry->{$key}); } - elsif (grep {$key eq $_} @single_valued_keys) { + elsif (grep {$key eq $_} ('URL', 'listURL', @single_valued_keys)) { $vars{$key} = $entry->{$key}; } elsif (grep {$key eq $_} @multi_valued_keys) { - $vars{$key} = join "\x{0D}\x{0A}", map { encode_entities ($_) } - @{$entry->{$key}}; + my @array = map { encode_entities ($_) } @{$entry->{$key}}; + if ($options{'-loop'} and + grep { $key eq $_ } @{$options{'-loop'}}) { + $vars{$key} = [ map {{item => $_}} @array ]; + } + else { + $vars{$key} = join "\x{0D}\x{0A}", @array; + } } } return %vars; diff --git a/lib/Fripost/Schema/Domain.pm b/lib/Fripost/Schema/Domain.pm index 6ed22b6..3caffb5 100644 --- a/lib/Fripost/Schema/Domain.pm +++ b/lib/Fripost/Schema/Domain.pm @@ -2,7 +2,7 @@ package Fripost::Schema::Domain; =head1 NAME -Domain.pm - Domain related methods in the Fripost Schema +Domain.pm - Domainpart related methods for the Fripost Schema =head1 DESCRIPTION @@ -31,7 +31,7 @@ use Template; =head1 REPRESENTATION -Domains are imported and exported as hash references, having the +Domains are imported and exported as hash references, with the following keys: =over 4 @@ -44,6 +44,10 @@ A UTF-8 string representing the (internationalized) domain name. Whether or not the domain is active. +=item B + +An array reference containing UTF-8 strings describing the domain. + =item B => 0|1 Whether or not the domain is pending. This is key is ignored when adding @@ -79,10 +83,6 @@ An optional array reference containing the (internationalized) catch-alls for that domain. Localparts may be left empty for domain aliases. -=item B - -An array reference containing UTF-8 string representing that domain. - =item B An optional string representing the permission of the current user @@ -154,10 +154,10 @@ current user to read or even search anything, though. The default is to retrieve every visible attribute, unless in void context where B<-keys> is set to [] that is, no attribute is sent back to the client. -=item B<-count> +=item B<-count> => 0|1 -Returns the number of entries in the result set. The B<-keys> option is -bypassed not to ask any attribute from the server. +Return the number of entries in the result set. When set, the B<-keys> +option is bypassed not to ask any attribute from the server. =item B<-sort> => 0|1 @@ -285,9 +285,7 @@ sub _entries_to_domains { } # Add a 'permissions' key if wanted. - if ((not @$keys or grep { $_ eq 'permissions' } @$keys) and - grep { $entry->exists($_) } qw/fripostCanAddAlias fripostCanAddList - fripostOwner fripostPostmaster/) { + if ((not @$keys or grep { $_ eq 'permissions' } @$keys)) { my $perms = ''; $perms .= 'a' if $entry->exists('fripostCanAddAlias') and grep { $user eq lc $_ or $parent eq lc $_ } diff --git a/lib/Fripost/Schema/Local.pm b/lib/Fripost/Schema/Local.pm index 7a1ae22..1f09f66 100644 --- a/lib/Fripost/Schema/Local.pm +++ b/lib/Fripost/Schema/Local.pm @@ -2,12 +2,12 @@ package Fripost::Schema::Local; =head1 NAME -Local.pm - +Local.pm - Localpart related method for the Fripost Schema =head1 DESCRIPTION -Local.pm abstracts the LDAP schema definition and provides methods to -search for virtual users, aliases or lists alltogether. +This module abstracts the LDAP schema definition and provides methods to +add, list or delete virtual users, aliases or lists. =cut @@ -17,11 +17,315 @@ use warnings; use utf8; use parent 'Fripost::Schema'; -use Fripost::Schema::Util qw/concat split_addr canonical_dn/; +use Fripost::Schema::Util qw/concat split_addr canonical_dn + ldap_error dn2mail/; use Net::IDN::Encode qw/email_to_ascii email_to_unicode/; use Net::LDAP::Util 'escape_filter_value'; +=head1 REPRESENTATION + +Virtual users, aliases and lists are imported and exported as hash +references, with the following keys: + +=over 4 + +=item B + +A UTF-8 string representing the (internationalized) e-mail address for +the user, alias or list. + +=item B => user|alias|list + +The type of the entry. + +=item B => 0|1 + +Whether or not the entry is active. + +=item B + +An array reference containing UTF-8 strings describing the entry. + +=item B => 0|1 + +(List only) Whether or not the entry is pending. New lists are always +marked as pending, and it is up to the list manager's side to unlock +them. + +=item B + +(User only) A string e.g., C<100 MB> representing the current quota on +the user's mailboxes. + +=item B + +(Alias and list only) An optional array reference containing the +(internationalized) e-mails addresses of the entry owners. + +=item B + +(User only) An optional array reference containing a (internationalized) +e-mails addresses that will also receive every single message sent to +that user. + +=item B + +(Alias only) An array reference containing a (internationalized) e-mails +addresses that will receive messages sent to that alias. + +=item B mailman|schleuder + +(List only) The list manager associated with list entries. + + +=head1 METHODS + +=over 4 + +=item B (I, I) + +Search for I, or list all the known (and visible) users, aliases +and lists when I is not defined. If I does not contain a '@' +symbol, it is interpreted as a domain name, which limits the scope of +that search. In list context, return a list of entries represented as +hash references, as explained above. In scalar context, only the first +entry found is returned. In void context, no attributes are returned +from the LDAP server, but it may nevertheless be useful, to ensure that +the result set is not empty for instance. + +The following options are considered: + +=over 4 + +=item B<-no-escape> => 0|1 + +By default, the local and domain parts of I - when defined - are +safely escaped before insertion into the LDAP DN and filter. This flag +disables escaping. It is useful if I contains wildcards for +instance. Note that in case the domain part contains wildcard, this +method will query the LDAP server for every single matching domain. + +=item B<-filter> => locked|unlocked + +Limit the search scope to pending / non-pending entries only. + +=item B<-type> => user|alias|list + +Limit the search scope to the given entry type. + +=item B<-keys> + +An array reference containing the attributes that are to be retrived +from the LDAP server. Note that Access Control List may prevent the +current user to read or even search anything, though. The default is to +retrieve every visible attribute, unless in void context where B<-keys> +is set to [] that is, no attribute is sent back to the client. + +=item B<-count> => 0|1 + +Return the number of entries in the result set. When set, the B<-keys> +option is bypassed not to ask any attribute from the server. + +=item B<-sort> => 0|1 + +In list context, sort the results per localpart. + +=back + +Errors can be caught with options B<-die> and B<-error>, see +B for details. + +=cut + +sub search { + my $self = shift; + my $in = shift; + my %options = @_; + + # Nothing to do after an error. + return if $options{'-error'} && ${$options{'-error'}}; + + # If there is not '@', we interpret $in as a domain name. + $in =~ s/^([^\@]+)$/\@$1/; + my ($localname, $domainname) = split_addr($in); + + my @filters; + if (defined $options{'-type'}) { + # Limit the scope to the given type. + if ($options{'-type'} eq 'user') { + push @filters, 'objectClass=FripostVirtualUser'; + } + elsif ($options{'-type'} eq 'alias') { + push @filters, 'objectClass=FripostVirtualAlias'; + } + elsif ($options{'-type'} eq 'list') { + push @filters, 'objectClass=FripostVirtualList'; + } + } + else { + push @filters, '(|(objectClass=FripostVirtualUser)'. + '(objectClass=FripostVirtualAlias)'. + '(objectClass=FripostVirtualList))'; + } + + my @domainnames; + if ($domainname) { + if ($options{'-no-escape'} and $domainname =~ /\*/) { + # If the domain part contains a wildcard, we have to query + # the LDAP server to list the matching domains. + my %opts = ( '-no-escape' => 1, -keys => [ 'name' ]) ; + $opts{'-filter'} = 'unlocked'; + foreach (qw/-filter -error -die/) { + $opts{$_} = $options{$_} if $options{$_}; + } + push @domainnames, map {$_->{name}} + $self->domain->search($domainname, %opts); + } + else { + # Otherwise, a single query is enough. + $domainname = Net::LDAP::Util::escape_dn_value($domainname) + unless $options{'-no-escape'}; + push @domainnames, $domainname; + } + } + + if ($localname) { + $localname = Net::LDAP::Util::escape_filter_value($localname) + unless $options{'-no-escape'}; + push @filters, 'fvl='.$localname; + } + + if (defined $options{'-filter'}) { + push @filters, 'objectClass=FripostPendingEntry' + if $options{'-filter'} eq 'locked'; + push @filters, '!(objectClass=FripostPendingEntry)' + if $options{'-filter'} eq 'unlocked'; + } + + my $attrs = []; + if (not (defined wantarray)) { + # In void context, we are only interested in whether or not the + # result set is empty. + $attrs = [ '1.1' ]; + } + elsif (defined $options{'-keys'}) { + $attrs = @{$options{'-keys'}} ? [ &_keys_to_attrs(@{$options{'-keys'}}) ] + : [ '1.1' ]; + } + + my $filter = Fripost::Schema::Util::ldap_and_filter(@filters); + my $count = 0; + my @resultset; + foreach my $domainname (@domainnames) { + # For query the server for each matching domain. + my $locals = $self->ldap->search( base => $self->mail2dn('@'.$domainname) + , scope => 'one' + , deref => 'never' + , filter => $filter + , attrs => $attrs + ); + ldap_error($locals, %options) // return; + next unless defined wantarray; # We'll drop the result anyway + + if ($options{'-count'}) { + $count += $locals->count; + } + elsif (wantarray) { + push @resultset, + &_entries_to_locals( $domainname, $options{'-keys'}, + $locals->entries ); + } + else { + # In scalar context, we stop here if we got a match. + return &_entries_to_locals( $domainname, $options{'-keys'}, + $locals->pop_entry ) + if $locals->count; + } + } + + return $count if $options{'-count'}; + # In list context, we return the whole result set, maybe sorted. + $options{'-sort'} ? sort { $a->{name} cmp $b->{name} } @resultset + : @resultset; +} + + +# Map a list of LDAP::Entry object into our public representation of +# users, aliases and lists. +sub _entries_to_locals { + my $domainname = shift; + my $keys = shift // []; + + my @locals; + foreach my $entry (@_) { + + # Ignore bogus entries. + return unless defined $entry; + my %local; + + foreach my $attr ($entry->attributes) { + my $val = $entry->get_value($attr, asref => 1); + if ($attr eq 'fvl') { + $local{name} = email_to_unicode($val->[0].'@'.$domainname) + if not @$keys or grep { $_ eq 'name' } @$keys; + } + elsif ($attr eq 'fripostIsStatusActive') { + $local{isActive} = $val->[0] eq 'TRUE' + if not @$keys or grep { $_ eq 'isActive' } @$keys; + } + elsif ($attr eq 'objectClass') { + if (grep { lc $_ eq lc 'FripostVirtualUser' } @$val) { + $local{type} = 'user'; + } + elsif (grep { lc $_ eq lc 'FripostVirtualAlias' } @$val) { + $local{type} = 'alias'; + } + elsif (grep { lc $_ eq lc 'FripostVirtualList' } @$val) { + $local{type} = 'list'; + } + $local{isPending} = scalar (grep { lc $_ eq lc 'FripostPendingEntry' } + @$val ) + if not @$keys or grep { $_ eq 'isPending' } @$keys; + } + elsif ($attr eq 'description') { + $local{description} = [ map { Encode::_utf8_on($_); $_ } @$val ] + if not @$keys or grep { $_ eq 'description' } @$keys; + } + elsif ($attr eq 'fripostUserQuota') { + $local{quota} = $val->[0] + if not @$keys or grep { $_ eq 'quota' } @$keys; + } + elsif ($attr eq 'fripostOwner') { + $local{owner} = [ map { dn2mail($_) } @$val ] + if not @$keys or grep { $_ eq 'owner' } @$keys; + } + elsif ($attr eq 'fripostOptionalMaildrop') { + $local{forward} = [ map { email_to_unicode($_) } @$val ] + if not @$keys or grep { $_ eq 'forward' } @$keys; + } + elsif ($attr eq 'fripostMaildrop') { + $local{destination} = [ map { email_to_unicode($_) } @$val ] + if not @$keys or grep { $_ eq 'destination' } @$keys; + } + elsif ($attr eq 'fripostListManager') { + $local{transport} = $val->[0] + if not @$keys or grep { $_ eq 'transport' } @$keys; + } + else { + die "Missing translation for local attribute ‘".$attr."’."; + } + } + + # Stop after the first processed domain in scalar mode. + return \%local unless wantarray; + push @locals, \%local; + } + return @locals; +} + + + =head1 METHODS =over 4 diff --git a/lib/Fripost/Schema/Util.pm b/lib/Fripost/Schema/Util.pm index d5e122e..59d724f 100644 --- a/lib/Fripost/Schema/Util.pm +++ b/lib/Fripost/Schema/Util.pm @@ -154,7 +154,7 @@ sub ldap_explode_dn { } sub split_addr { - my $addr = shift; + my $addr = shift // return; my %options = @_; $addr =~ /^(.*)\@([^\@]+)$/s; diff --git a/templates/add-alias.html b/templates/add-alias.html index ab2d8b7..2844dad 100644 --- a/templates/add-alias.html +++ b/templates/add-alias.html @@ -12,7 +12,7 @@ /
- Logged as @ | Log out
diff --git a/templates/add-domain-1.html b/templates/add-domain-1.html index a7afc6f..ef7f493 100644 --- a/templates/add-domain-1.html +++ b/templates/add-domain-1.html @@ -11,7 +11,7 @@ Root /
- Logged as @ | Log out
diff --git a/templates/add-domain-2.html b/templates/add-domain-2.html index 9565d8a..c3a728b 100644 --- a/templates/add-domain-2.html +++ b/templates/add-domain-2.html @@ -11,7 +11,7 @@ Root /
- Logged as @ | Log out
diff --git a/templates/add-list.html b/templates/add-list.html index d4053bd..136899d 100644 --- a/templates/add-list.html +++ b/templates/add-list.html @@ -12,7 +12,7 @@ /
- Logged as @ | Log out
diff --git a/templates/add-user.html b/templates/add-user.html index ba54e56..8964c22 100644 --- a/templates/add-user.html +++ b/templates/add-user.html @@ -12,7 +12,7 @@ /
- Logged as @ | Log out
diff --git a/templates/edit-alias.html b/templates/edit-alias.html index 7e42e4b..34425a4 100644 --- a/templates/edit-alias.html +++ b/templates/edit-alias.html @@ -13,7 +13,7 @@ /
- Logged as @ | Log out
diff --git a/templates/edit-domain.html b/templates/edit-domain.html index d95c8fb..de765e4 100644 --- a/templates/edit-domain.html +++ b/templates/edit-domain.html @@ -12,7 +12,7 @@ /
- Logged as @ | Log out
diff --git a/templates/edit-list.html b/templates/edit-list.html index ad5311a..bb26eef 100644 --- a/templates/edit-list.html +++ b/templates/edit-list.html @@ -13,7 +13,7 @@ /
- Logged as @ | Log out
diff --git a/templates/edit-user.html b/templates/edit-user.html index 53a2871..38d3836 100644 --- a/templates/edit-user.html +++ b/templates/edit-user.html @@ -13,7 +13,7 @@ /
- Logged as @ | Log out
diff --git a/templates/list-domains.html b/templates/list-domains.html index e0fe52e..f07c4ef 100644 --- a/templates/list-domains.html +++ b/templates/list-domains.html @@ -11,7 +11,7 @@ Root /
- Logged as @ | Log out
@@ -34,7 +34,7 @@ + href="/">
- Manage domain <TMPL_VAR NAME=domain> + Manage domain <TMPL_VAR NAME=name>