EmailValidator.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.service.auth;

import com.google.common.net.InetAddresses;
import jakarta.validation.Validator;
import jakarta.validation.constraints.Email;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.net.Inet6Address;

/**
 * Applies Jakarta email syntax validation and EntryStore's domain policy: a dotted domain with a
 * suffix of at least two characters, or a bracketed IP literal. No TLD allowlist or DNS lookup is used.
 * IPv6 literals must carry the RFC 5321 {@code IPv6:} tag (matched case-insensitively); a bare bracketed
 * IPv6 address is rejected, as is a zone ID such as {@code %eth0}.
 *
 * <p>Reached declaratively through
 * {@link org.entrystore.rest.springboot.model.validation.ValidEmail} on request bodies, and directly
 * by {@code AuthService} on the password-reset path.
 */
@Service
@RequiredArgsConstructor
public class EmailValidator {

	private final Validator validator;

	/**
	 * Rejects absent values for direct callers. The declarative {@code @ValidEmail} constraint leaves
	 * null and empty values to the request body's presence constraint.
	 */
	public boolean isValid(String email) {
		if (email == null || email.isEmpty()) {
			return false;
		}
		if (!validator.validate(new Address(email)).isEmpty()) {
			return false;
		}

		String domain = email.substring(email.lastIndexOf('@') + 1);
		if (domain.startsWith("[") && domain.endsWith("]")) {
			String literal = domain.substring(1, domain.length() - 1);
			// A zone ID names a host-local scope, which the policy rejects just like "localhost".
			if (literal.indexOf('%') >= 0) {
				return false;
			}
			if (literal.regionMatches(true, 0, "IPv6:", 0, 5)) {
				literal = literal.substring(5);
				return InetAddresses.isInetAddress(literal)
						&& InetAddresses.forString(literal) instanceof Inet6Address;
			}
			// Hibernate's @Email checks IPv4 shape but does not enforce the octet range.
			return InetAddresses.isInetAddress(literal);
		}

		int lastDot = domain.lastIndexOf('.');
		int suffixLength = domain.length() - lastDot - 1;
		return lastDot > 0 && suffixLength >= 2;
	}

	private record Address(@Email String value) {
	}
}