|
|
@@ -0,0 +1,471 @@
|
|
|
+// Package authd — tenants.go: Tenant (a.k.a. company) CRUD.
|
|
|
+//
|
|
|
+// The M13a schema (009_auth.up.sql) introduced the auth.tenants
|
|
|
+// table. M13b W1 turns that into a fully-managed resource in the
|
|
|
+// admin UI: super-admins can create / list / edit / suspend /
|
|
|
+// activate tenants; tenant-admins get a read-only view of their
|
|
|
+// own tenant. All state transitions write an audit_log row so the
|
|
|
+// Audit log UI (M13c) can show "who suspended tenant X, when".
|
|
|
+//
|
|
|
+// Threading: safe for concurrent use (pgx pool is goroutine-safe).
|
|
|
+
|
|
|
+package authd
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+ "strings"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "github.com/jackc/pgx/v5"
|
|
|
+ "github.com/jackc/pgx/v5/pgconn"
|
|
|
+
|
|
|
+)
|
|
|
+
|
|
|
+// Tenant is the wire shape returned to handlers / JSON callers.
|
|
|
+// The wire shape is kept flat and snake_case to match the rest
|
|
|
+// of the M13a / M13b admin API.
|
|
|
+type Tenant struct {
|
|
|
+ ID string `json:"id"`
|
|
|
+ Slug string `json:"slug"`
|
|
|
+ DisplayName string `json:"display_name"`
|
|
|
+ Status string `json:"status"`
|
|
|
+ ContactEmail string `json:"contact_email"`
|
|
|
+ RateLimitPerSec int `json:"rate_limit_per_sec"`
|
|
|
+ FCMShared bool `json:"fcm_shared"`
|
|
|
+ CreatedAt time.Time `json:"created_at"`
|
|
|
+ UpdatedAt time.Time `json:"updated_at"`
|
|
|
+ ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
|
|
+}
|
|
|
+
|
|
|
+// ErrTenantNotFound is returned when a tenant id or slug does not
|
|
|
+// exist. Distinct from ErrUserNotFound so callers can disambiguate.
|
|
|
+var ErrTenantNotFound = errors.New("authd: tenant not found")
|
|
|
+
|
|
|
+// ErrTenantSlugTaken is returned when CreateTenant sees a slug
|
|
|
+// collision. UI surfaces this as a 409.
|
|
|
+var ErrTenantSlugTaken = errors.New("authd: tenant slug already taken")
|
|
|
+
|
|
|
+// ErrTenantInvalid is returned when input validation fails (e.g.
|
|
|
+// slug doesn't match the regex, or rate_limit_per_sec is out of
|
|
|
+// range). The wrapped error string is safe to surface to the UI.
|
|
|
+var ErrTenantInvalid = errors.New("authd: tenant input invalid")
|
|
|
+
|
|
|
+// TenantFilter controls ListTenants. Empty fields mean "no filter".
|
|
|
+// Limit caps the result count; 0 → default of 100. Max 500.
|
|
|
+type TenantFilter struct {
|
|
|
+ Q string // matches slug OR display_name (ILIKE)
|
|
|
+ Status string // exact match: "active" | "suspended" | "archived" | ""
|
|
|
+ Limit int
|
|
|
+ Offset int
|
|
|
+ // Scope controls what's visible.
|
|
|
+ // "all" — super_admin only: every tenant
|
|
|
+ // "self" — returns the single tenant matching CallerTenantID
|
|
|
+ CallerRole string
|
|
|
+ CallerTenantID string
|
|
|
+}
|
|
|
+
|
|
|
+// ListTenants returns the tenants visible to the caller under the
|
|
|
+// given filter, plus the total count (for pagination in the UI).
|
|
|
+func (s *Store) ListTenants(ctx context.Context, f TenantFilter) ([]Tenant, int, error) {
|
|
|
+ if s.pool == nil {
|
|
|
+ return nil, 0, errors.New("authd: no DB pool (test mode)")
|
|
|
+ }
|
|
|
+ if f.Limit <= 0 {
|
|
|
+ f.Limit = 100
|
|
|
+ }
|
|
|
+ if f.Limit > 500 {
|
|
|
+ f.Limit = 500
|
|
|
+ }
|
|
|
+
|
|
|
+ // Build the WHERE clause. We use $N-style placeholders that
|
|
|
+ // we count as we go so it's safe to extend.
|
|
|
+ args := []any{}
|
|
|
+ conds := []string{}
|
|
|
+
|
|
|
+ if strings.TrimSpace(f.Status) != "" {
|
|
|
+ args = append(args, f.Status)
|
|
|
+ conds = append(conds, fmt.Sprintf("status = $%d", len(args)))
|
|
|
+ }
|
|
|
+ if strings.TrimSpace(f.Q) != "" {
|
|
|
+ args = append(args, "%"+strings.TrimSpace(f.Q)+"%")
|
|
|
+ conds = append(conds, fmt.Sprintf("(slug ILIKE $%d OR display_name ILIKE $%d)", len(args), len(args)))
|
|
|
+ }
|
|
|
+ // Scope: tenant_admin only sees their own tenant.
|
|
|
+ if f.CallerRole != "super_admin" {
|
|
|
+ if f.CallerTenantID == "" {
|
|
|
+ // A non-super_admin without a tenant_id has no business
|
|
|
+ // listing tenants. Return an empty page so the UI
|
|
|
+ // shows "0 results" rather than leaking the existence
|
|
|
+ // of other tenants.
|
|
|
+ return []Tenant{}, 0, nil
|
|
|
+ }
|
|
|
+ args = append(args, f.CallerTenantID)
|
|
|
+ conds = append(conds, fmt.Sprintf("id = $%d", len(args)))
|
|
|
+ }
|
|
|
+ where := ""
|
|
|
+ if len(conds) > 0 {
|
|
|
+ where = "WHERE " + strings.Join(conds, " AND ")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Count first (cheap, uses the same WHERE).
|
|
|
+ var total int
|
|
|
+ countQ := "SELECT COUNT(*) FROM auth.tenants " + where
|
|
|
+ if err := s.pool.QueryRow(ctx, countQ, args...).Scan(&total); err != nil {
|
|
|
+ return nil, 0, fmt.Errorf("count tenants: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Then the page.
|
|
|
+ args = append(args, f.Limit, f.Offset)
|
|
|
+ pageQ := fmt.Sprintf(`
|
|
|
+ SELECT id::text, slug, display_name, status, contact_email,
|
|
|
+ rate_limit_per_sec, fcm_shared, created_at, updated_at, archived_at
|
|
|
+ FROM auth.tenants
|
|
|
+ %s
|
|
|
+ ORDER BY created_at DESC
|
|
|
+ LIMIT $%d OFFSET $%d
|
|
|
+ `, where, len(args)-1, len(args))
|
|
|
+ rows, err := s.pool.Query(ctx, pageQ, args...)
|
|
|
+ if err != nil {
|
|
|
+ return nil, 0, fmt.Errorf("list tenants: %w", err)
|
|
|
+ }
|
|
|
+ defer rows.Close()
|
|
|
+ out := make([]Tenant, 0, f.Limit)
|
|
|
+ for rows.Next() {
|
|
|
+ var t Tenant
|
|
|
+ if err := rows.Scan(
|
|
|
+ &t.ID, &t.Slug, &t.DisplayName, &t.Status, &t.ContactEmail,
|
|
|
+ &t.RateLimitPerSec, &t.FCMShared, &t.CreatedAt, &t.UpdatedAt, &t.ArchivedAt,
|
|
|
+ ); err != nil {
|
|
|
+ return nil, 0, fmt.Errorf("scan tenant: %w", err)
|
|
|
+ }
|
|
|
+ out = append(out, t)
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ return nil, 0, fmt.Errorf("rows: %w", err)
|
|
|
+ }
|
|
|
+ return out, total, nil
|
|
|
+}
|
|
|
+
|
|
|
+// GetTenant fetches a single tenant by id.
|
|
|
+func (s *Store) GetTenant(ctx context.Context, id string) (*Tenant, error) {
|
|
|
+ if s.pool == nil {
|
|
|
+ return nil, errors.New("authd: no DB pool (test mode)")
|
|
|
+ }
|
|
|
+ const q = `
|
|
|
+ SELECT id::text, slug, display_name, status, contact_email,
|
|
|
+ rate_limit_per_sec, fcm_shared, created_at, updated_at, archived_at
|
|
|
+ FROM auth.tenants
|
|
|
+ WHERE id = $1
|
|
|
+ `
|
|
|
+ t := &Tenant{}
|
|
|
+ err := s.pool.QueryRow(ctx, q, id).Scan(
|
|
|
+ &t.ID, &t.Slug, &t.DisplayName, &t.Status, &t.ContactEmail,
|
|
|
+ &t.RateLimitPerSec, &t.FCMShared, &t.CreatedAt, &t.UpdatedAt, &t.ArchivedAt,
|
|
|
+ )
|
|
|
+ if err != nil {
|
|
|
+ if errors.Is(err, pgx.ErrNoRows) {
|
|
|
+ return nil, ErrTenantNotFound
|
|
|
+ }
|
|
|
+ return nil, fmt.Errorf("get tenant: %w", err)
|
|
|
+ }
|
|
|
+ return t, nil
|
|
|
+}
|
|
|
+
|
|
|
+// CreateTenantInput is the validated create payload.
|
|
|
+type CreateTenantInput struct {
|
|
|
+ Slug string
|
|
|
+ DisplayName string
|
|
|
+ ContactEmail string
|
|
|
+ RateLimitPerSec int
|
|
|
+ FCMShared *bool // nil → use default true
|
|
|
+}
|
|
|
+
|
|
|
+// Validate runs the constraints the DB enforces, but earlier and
|
|
|
+// with friendlier error messages for the UI.
|
|
|
+func (in *CreateTenantInput) Validate() error {
|
|
|
+ if !validSlug(in.Slug) {
|
|
|
+ return fmt.Errorf("%w: slug must match ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$", ErrTenantInvalid)
|
|
|
+ }
|
|
|
+ if strings.TrimSpace(in.DisplayName) == "" {
|
|
|
+ return fmt.Errorf("%w: display_name is required", ErrTenantInvalid)
|
|
|
+ }
|
|
|
+ if !looksLikeEmail(in.ContactEmail) {
|
|
|
+ return fmt.Errorf("%w: contact_email is not a valid email", ErrTenantInvalid)
|
|
|
+ }
|
|
|
+ if in.RateLimitPerSec < 1 || in.RateLimitPerSec > 1_000_000 {
|
|
|
+ return fmt.Errorf("%w: rate_limit_per_sec must be 1..1000000", ErrTenantInvalid)
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// CreateTenant inserts a new tenant in 'active' status and writes
|
|
|
+// an audit_log row. Returns the new id. Duplicate slug →
|
|
|
+// ErrTenantSlugTaken (so the UI can show a 409).
|
|
|
+func (s *Store) CreateTenant(ctx context.Context, in CreateTenantInput, actorUserID, actorIP, actorUA string) (*Tenant, error) {
|
|
|
+ if s.pool == nil {
|
|
|
+ return nil, errors.New("authd: no DB pool (test mode)")
|
|
|
+ }
|
|
|
+ if err := in.Validate(); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ fcmShared := true
|
|
|
+ if in.FCMShared != nil {
|
|
|
+ fcmShared = *in.FCMShared
|
|
|
+ }
|
|
|
+ const q = `
|
|
|
+ INSERT INTO auth.tenants
|
|
|
+ (slug, display_name, contact_email, rate_limit_per_sec, fcm_shared, status)
|
|
|
+ VALUES
|
|
|
+ ($1, $2, $3, $4, $5, 'active')
|
|
|
+ RETURNING id::text
|
|
|
+ `
|
|
|
+ var id string
|
|
|
+ err := s.pool.QueryRow(ctx, q,
|
|
|
+ in.Slug, in.DisplayName, in.ContactEmail, in.RateLimitPerSec, fcmShared,
|
|
|
+ ).Scan(&id)
|
|
|
+ if err != nil {
|
|
|
+ var pgErr *pgconn.PgError
|
|
|
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
|
+ return nil, ErrTenantSlugTaken
|
|
|
+ }
|
|
|
+ return nil, fmt.Errorf("create tenant: %w", err)
|
|
|
+ }
|
|
|
+ if err := s.WriteAudit(ctx, "tenant.create", actorUserID, actorIP, actorUA, id, id, map[string]any{
|
|
|
+ "slug": in.Slug,
|
|
|
+ "display_name": in.DisplayName,
|
|
|
+ "contact_email": in.ContactEmail,
|
|
|
+ "rate_limit_per_sec": in.RateLimitPerSec,
|
|
|
+ "fcm_shared": fcmShared,
|
|
|
+ }); err != nil {
|
|
|
+ // Audit failure is non-fatal: log and continue. The tenant
|
|
|
+ // was created; the audit row is observability, not authz.
|
|
|
+ // Errors are returned via fmt.Errorf wrapping; callers may
|
|
|
+ // log them. We don't return the error to the caller.
|
|
|
+ _ = err
|
|
|
+ }
|
|
|
+ return s.GetTenant(ctx, id)
|
|
|
+}
|
|
|
+
|
|
|
+// UpdateTenantInput is the validated update payload. Pointer
|
|
|
+// fields mean "leave unchanged" when nil — this is the standard
|
|
|
+// PATCH semantics.
|
|
|
+type UpdateTenantInput struct {
|
|
|
+ DisplayName *string
|
|
|
+ ContactEmail *string
|
|
|
+ RateLimitPerSec *int
|
|
|
+ FCMShared *bool
|
|
|
+}
|
|
|
+
|
|
|
+// Validate runs the constraints the DB enforces, but earlier.
|
|
|
+func (in *UpdateTenantInput) Validate() error {
|
|
|
+ if in.DisplayName != nil && strings.TrimSpace(*in.DisplayName) == "" {
|
|
|
+ return fmt.Errorf("%w: display_name cannot be empty", ErrTenantInvalid)
|
|
|
+ }
|
|
|
+ if in.ContactEmail != nil && !looksLikeEmail(*in.ContactEmail) {
|
|
|
+ return fmt.Errorf("%w: contact_email is not a valid email", ErrTenantInvalid)
|
|
|
+ }
|
|
|
+ if in.RateLimitPerSec != nil && (*in.RateLimitPerSec < 1 || *in.RateLimitPerSec > 1_000_000) {
|
|
|
+ return fmt.Errorf("%w: rate_limit_per_sec must be 1..1000000", ErrTenantInvalid)
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// UpdateTenant applies a partial update and writes an audit_log
|
|
|
+// row with the changed fields. Returns the new state.
|
|
|
+//
|
|
|
+// "actorScopeAll" controls whether the caller can edit every
|
|
|
+// field (super_admin) or only display_name + contact_email
|
|
|
+// (tenant_admin on their own tenant). If false and the patch
|
|
|
+// includes a restricted field, returns ErrTenantInvalid.
|
|
|
+func (s *Store) UpdateTenant(
|
|
|
+ ctx context.Context,
|
|
|
+ id string,
|
|
|
+ in UpdateTenantInput,
|
|
|
+ actorScopeAll bool,
|
|
|
+ actorUserID, actorIP, actorUA string,
|
|
|
+) (*Tenant, error) {
|
|
|
+ if s.pool == nil {
|
|
|
+ return nil, errors.New("authd: no DB pool (test mode)")
|
|
|
+ }
|
|
|
+ if err := in.Validate(); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ // Tenant_admin is restricted to display_name + contact_email.
|
|
|
+ if !actorScopeAll {
|
|
|
+ if in.RateLimitPerSec != nil || in.FCMShared != nil {
|
|
|
+ return nil, fmt.Errorf("%w: only super_admin can change rate_limit_per_sec or fcm_shared", ErrTenantInvalid)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Build the SET clause incrementally so unset fields don't
|
|
|
+ // touch the row.
|
|
|
+ sets := []string{}
|
|
|
+ args := []any{pgid(id)}
|
|
|
+ if in.DisplayName != nil {
|
|
|
+ args = append(args, strings.TrimSpace(*in.DisplayName))
|
|
|
+ sets = append(sets, fmt.Sprintf("display_name = $%d", len(args)))
|
|
|
+ }
|
|
|
+ if in.ContactEmail != nil {
|
|
|
+ args = append(args, *in.ContactEmail)
|
|
|
+ sets = append(sets, fmt.Sprintf("contact_email = $%d", len(args)))
|
|
|
+ }
|
|
|
+ if in.RateLimitPerSec != nil {
|
|
|
+ args = append(args, *in.RateLimitPerSec)
|
|
|
+ sets = append(sets, fmt.Sprintf("rate_limit_per_sec = $%d", len(args)))
|
|
|
+ }
|
|
|
+ if in.FCMShared != nil {
|
|
|
+ args = append(args, *in.FCMShared)
|
|
|
+ sets = append(sets, fmt.Sprintf("fcm_shared = $%d", len(args)))
|
|
|
+ }
|
|
|
+ if len(sets) == 0 {
|
|
|
+ // Nothing to change. Return the current state.
|
|
|
+ return s.GetTenant(ctx, id)
|
|
|
+ }
|
|
|
+ q := fmt.Sprintf(`UPDATE auth.tenants SET %s WHERE id = $1`, strings.Join(sets, ", "))
|
|
|
+ tag, err := s.pool.Exec(ctx, q, args...)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("update tenant: %w", err)
|
|
|
+ }
|
|
|
+ if tag.RowsAffected() == 0 {
|
|
|
+ return nil, ErrTenantNotFound
|
|
|
+ }
|
|
|
+
|
|
|
+ // Build audit payload (only the fields the caller sent).
|
|
|
+ payload := map[string]any{}
|
|
|
+ if in.DisplayName != nil {
|
|
|
+ payload["display_name"] = *in.DisplayName
|
|
|
+ }
|
|
|
+ if in.ContactEmail != nil {
|
|
|
+ payload["contact_email"] = *in.ContactEmail
|
|
|
+ }
|
|
|
+ if in.RateLimitPerSec != nil {
|
|
|
+ payload["rate_limit_per_sec"] = *in.RateLimitPerSec
|
|
|
+ }
|
|
|
+ if in.FCMShared != nil {
|
|
|
+ payload["fcm_shared"] = *in.FCMShared
|
|
|
+ }
|
|
|
+ if err := s.WriteAudit(ctx, "tenant.update", actorUserID, actorIP, actorUA, id, id, payload); err != nil {
|
|
|
+ _ = err
|
|
|
+ }
|
|
|
+ return s.GetTenant(ctx, id)
|
|
|
+}
|
|
|
+
|
|
|
+// SetTenantStatus changes the status. Allowed transitions:
|
|
|
+// active -> suspended, archived
|
|
|
+// suspended -> active, archived
|
|
|
+// archived -> (terminal — no transitions out of archived)
|
|
|
+//
|
|
|
+// archived is terminal. Setting status=archived also stamps
|
|
|
+// archived_at = NOW(). Writes an audit_log row with the
|
|
|
+// {from, to} transition.
|
|
|
+func (s *Store) SetTenantStatus(
|
|
|
+ ctx context.Context,
|
|
|
+ id, newStatus string,
|
|
|
+ actorUserID, actorIP, actorUA string,
|
|
|
+) (*Tenant, error) {
|
|
|
+ if s.pool == nil {
|
|
|
+ return nil, errors.New("authd: no DB pool (test mode)")
|
|
|
+ }
|
|
|
+ switch newStatus {
|
|
|
+ case "active", "suspended", "archived":
|
|
|
+ default:
|
|
|
+ return nil, fmt.Errorf("%w: status must be active|suspended|archived", ErrTenantInvalid)
|
|
|
+ }
|
|
|
+ cur, err := s.GetTenant(ctx, id)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ if cur.Status == "archived" {
|
|
|
+ return nil, fmt.Errorf("%w: tenant is archived (terminal)", ErrTenantInvalid)
|
|
|
+ }
|
|
|
+ if cur.Status == newStatus {
|
|
|
+ // No-op transition. Return the current state.
|
|
|
+ return cur, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ var q string
|
|
|
+ var args []any
|
|
|
+ if newStatus == "archived" {
|
|
|
+ q = `UPDATE auth.tenants SET status = $2, archived_at = NOW() WHERE id = $1`
|
|
|
+ args = []any{pgid(id), newStatus}
|
|
|
+ } else {
|
|
|
+ q = `UPDATE auth.tenants SET status = $2, archived_at = NULL WHERE id = $1`
|
|
|
+ args = []any{pgid(id), newStatus}
|
|
|
+ }
|
|
|
+ tag, err := s.pool.Exec(ctx, q, args...)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("set tenant status: %w", err)
|
|
|
+ }
|
|
|
+ if tag.RowsAffected() == 0 {
|
|
|
+ return nil, ErrTenantNotFound
|
|
|
+ }
|
|
|
+ if err := s.WriteAudit(ctx, "tenant.status", actorUserID, actorIP, actorUA, id, id, map[string]any{
|
|
|
+ "from": cur.Status,
|
|
|
+ "to": newStatus,
|
|
|
+ }); err != nil {
|
|
|
+ _ = err
|
|
|
+ }
|
|
|
+ return s.GetTenant(ctx, id)
|
|
|
+}
|
|
|
+
|
|
|
+// pgid is a tiny helper that keeps the call sites readable: we
|
|
|
+// only ever pass a single id as the first arg, and we want it to
|
|
|
+// be parsed as a UUID by Postgres.
|
|
|
+func pgid(id string) any { return id }
|
|
|
+
|
|
|
+// -------------------------------------------------------------------
|
|
|
+// input validation
|
|
|
+// -------------------------------------------------------------------
|
|
|
+
|
|
|
+// validSlug matches the regex on the slug column:
|
|
|
+//
|
|
|
+// ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$
|
|
|
+//
|
|
|
+// Inlined (not via regexp package) because the pattern is fixed
|
|
|
+// and the package would add 50KB of binary.
|
|
|
+func validSlug(s string) bool {
|
|
|
+ if len(s) < 2 || len(s) > 64 {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if !isAlnumOrDash(s[0]) || s[0] == '-' {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if !isAlnumOrDash(s[len(s)-1]) || s[len(s)-1] == '-' {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ for i := 1; i < len(s)-1; i++ {
|
|
|
+ if !isAlnumOrDash(s[i]) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+func isAlnumOrDash(c byte) bool {
|
|
|
+ return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'
|
|
|
+}
|
|
|
+
|
|
|
+// looksLikeEmail is intentionally permissive: we just enforce
|
|
|
+// the local-part, the '@', and a non-empty domain with at least
|
|
|
+// one dot that isn't at the edge. Bounces are caught by the
|
|
|
+// actual mail server, not by the admin UI.
|
|
|
+func looksLikeEmail(s string) bool {
|
|
|
+ s = strings.TrimSpace(s)
|
|
|
+ if s == "" || len(s) > 254 {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ at := strings.IndexByte(s, '@')
|
|
|
+ if at < 1 || at == len(s)-1 {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ domain := s[at+1:]
|
|
|
+ if len(domain) < 3 {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if domain[0] == '.' || domain[len(domain)-1] == '.' {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return strings.Contains(domain, ".")
|
|
|
+}
|