diff --git a/Justfile b/Justfile index 1558692..17392af 100644 --- a/Justfile +++ b/Justfile @@ -60,8 +60,8 @@ frontend-test *args: # cd email && pnpm i && pnpm email dev --dir emails {{args}} # ## Generate HTML output of react-email and copy to backend static folder -#email-gen *args: -# cd email && bash email.sh {{args}} +email-gen *args: + cd email && bash email.sh {{args}} # Run the dev servers (backend & frontend) dev *args: diff --git a/js/package.json b/js/package.json index a26bb27..7b4b1c1 100644 --- a/js/package.json +++ b/js/package.json @@ -34,6 +34,7 @@ "immer": "^10.1.3", "mantine-form-zod-resolver": "^1.3.0", "motion": "^12.34.2", + "papaparse": "^5.5.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.4.0", @@ -53,6 +54,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/papaparse": "^5.5.2", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index fd06d81..cd3d07b 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: motion: specifier: ^12.34.2 version: 12.34.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + papaparse: + specifier: ^5.5.3 + version: 5.5.3 react: specifier: ^18.3.1 version: 18.3.1 @@ -111,6 +114,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 '@types/react': specifier: ^18.3.18 version: 18.3.18 @@ -1227,6 +1233,9 @@ packages: '@types/node@24.0.10': resolution: {integrity: sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/prop-types@15.7.14': resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==} @@ -2770,6 +2779,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4673,7 +4685,10 @@ snapshots: '@types/node@24.0.10': dependencies: undici-types: 7.8.0 - optional: true + + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 24.0.10 '@types/prop-types@15.7.14': {} @@ -6590,6 +6605,8 @@ snapshots: dependencies: p-limit: 3.1.0 + papaparse@5.5.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7375,8 +7392,7 @@ snapshots: undefsafe@2.0.5: {} - undici-types@7.8.0: - optional: true + undici-types@7.8.0: {} unist-util-is@6.0.1: dependencies: diff --git a/js/src/app/user/admin/emails/CsvUpload.tsx b/js/src/app/user/admin/emails/CsvUpload.tsx new file mode 100644 index 0000000..6975018 --- /dev/null +++ b/js/src/app/user/admin/emails/CsvUpload.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Button, FileInput, Stack, Text, Title } from "@mantine/core"; +import { useState } from "react"; +import { parsePairingFile, parseUserFile } from "src/app/user/admin/emails/parseCSV"; + +export default function CsvUpload() { + const [userFile, setUserFile] = useState(null); + const [pairFile, setPairFile] = useState(null); + const [loading, setLoading] = useState(false); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + async function handleSendUsers() { + if (!userFile) return; + setLoading(true); + setStatus(null); + setError(null); + try { + const users = await parseUserFile(userFile); + setStatus(`Sent user emails to ${users.size} recipient(s).`); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to send user emails."); + } finally { + setLoading(false); + } + } + + async function handleSendPairings() { + if (!pairFile) return; + setLoading(true); + setStatus(null); + setError(null); + try { + const pairs = await parsePairingFile(pairFile); + setStatus(`Sent pairing emails for ${pairs.length} pairing(s).`); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to send pairing emails."); + } finally { + setLoading(false); + } + } + + return ( + + Send Emails + + + + + + + + + + + + {status && {status}} + {error && {error}} + + ); +} diff --git a/src/main/java/org/patinanetwork/patchats/api/ApiController.java b/src/main/java/org/patinanetwork/patchats/api/ApiController.java index 2508f30..8915af8 100644 --- a/src/main/java/org/patinanetwork/patchats/api/ApiController.java +++ b/src/main/java/org/patinanetwork/patchats/api/ApiController.java @@ -1,5 +1,6 @@ package org.patinanetwork.patchats.api; + import io.micrometer.core.annotation.Timed; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; diff --git a/src/main/java/org/patinanetwork/patchats/api/emails/EmailController.java b/src/main/java/org/patinanetwork/patchats/api/emails/EmailController.java new file mode 100644 index 0000000..a72ee9d --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/emails/EmailController.java @@ -0,0 +1,127 @@ +package org.patinanetwork.patchats.api.emails; + +import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.patinanetwork.patchats.common.dto.ApiResponder; +import org.patinanetwork.patchats.common.email.EmailClient; +import org.patinanetwork.patchats.common.email.options.SendEmailOptions; +import org.patinanetwork.patchats.common.email.template.ReactEmailTemplater; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Receives CSV-parsed payloads from the admin frontend (see {@code js/src/app/user/admin/emails/parseCSV.ts}) and sends + * the corresponding emails by rendering a React Email template then dispatching it through the {@link EmailClient}. + */ +@RestController +@RequestMapping("/api/emails") +@Tag(name = "Email sending") +@Timed(value = "controller.execution") +public class EmailController { + + // TODO - These are placeholders. The example template is a "verify email" template; user/pairing emails likely + // need their own templates and a real verify URL / support address sourced from configuration. + private static final String SUPPORT_EMAIL = "patchats@patinanetwork.org"; + private static final String VERIFY_URL = "https://patchats.org/verify"; + + private final ReactEmailTemplater templater; + private final EmailClient emailClient; + + public EmailController(final ReactEmailTemplater templater, final EmailClient emailClient) { + this.templater = templater; + this.emailClient = emailClient; + } + + @Operation(summary = "Send an email to each user parsed from the uploaded users CSV") + @PostMapping("/send-users") + public ResponseEntity> sendUsers(@RequestBody final Map users) { + final SendResult result = new SendResult(); + for (final UserDto user : users.values()) { + try { + final String html = templater.createExampleTemplate(user.name(), VERIFY_URL, SUPPORT_EMAIL); + send(user.email(), "Welcome to PatChats", html, result); + } catch (final Exception e) { + result.fail(user.email(), e); + } + } + return ResponseEntity.ok().body(ApiResponder.success("Processed user emails", result)); + } + + @Operation(summary = "Send an email to both members of each pairing parsed from the uploaded pairings CSV") + @PostMapping("/send-pairings") + public ResponseEntity> sendPairings(@RequestBody final List pairs) { + final SendResult result = new SendResult(); + for (final PairDto pair : pairs) { + sendPairingMember(pair.fullNameA(), pair.emailA(), result); + sendPairingMember(pair.fullNameB(), pair.emailB(), result); + } + return ResponseEntity.ok().body(ApiResponder.success("Processed pairing emails", result)); + } + + private void sendPairingMember(final String name, final String email, final SendResult result) { + try { + // TODO - Reusing the example template; swap for a dedicated pairing-notification template. + final String html = templater.createExampleTemplate(name, VERIFY_URL, SUPPORT_EMAIL); + send(email, "Your PatChats pairing", html, result); + } catch (final Exception e) { + result.fail(email, e); + } + } + + private void send(final String recipientEmail, final String subject, final String html, final SendResult result) + throws Exception { + emailClient.sendMessage(SendEmailOptions.builder() + .recipientEmail(recipientEmail) + .subject(subject) + .body(html) + .build()); + result.succeed(); + } + + /** Summary of a batch send so the frontend can report how many emails went out. */ + public static final class SendResult { + private int sent = 0; + private final List errors = new ArrayList<>(); + + void succeed() { + sent++; + } + + void fail(final String email, final Exception e) { + errors.add(email + ": " + e.getMessage()); + } + + public int getSent() { + return sent; + } + + public int getFailed() { + return errors.size(); + } + + public List getErrors() { + return errors; + } + } + + /** Mirrors the {@code User} interface serialized by parseCSV.ts. */ + public record UserDto( + String name, + String email, + String intro, + String linkedin, + String industry, + String preferences, + String topics, + String anything) {} + + /** Mirrors the {@code Pair} interface serialized by parseCSV.ts. */ + public record PairDto(String fullNameA, String emailA, String fullNameB, String emailB) {} +} diff --git a/src/main/java/org/patinanetwork/patchats/common/email/EmailClient.java b/src/main/java/org/patinanetwork/patchats/common/email/EmailClient.java new file mode 100644 index 0000000..0cb75da --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/email/EmailClient.java @@ -0,0 +1,21 @@ +package org.patinanetwork.patchats.common.email; + +import java.util.List; +import org.patinanetwork.patchats.common.email.error.EmailException; +import org.patinanetwork.patchats.common.email.options.SendEmailOptions; + +/** + * The base email interface. + * + *

NOTE: Inherited classes may NOT have every method implemented. You should check the implementation of each Email + * type and ensure that it has the features you require. + */ +public abstract class EmailClient { + + public abstract List getPastMessages() throws EmailException; + + public abstract void sendMessage(SendEmailOptions sendEmailOptions) throws EmailException; + + /** Validate that the connection and all the properties work, without actually doing any action. */ + public abstract void testConnection() throws EmailException; +} diff --git a/src/main/java/org/patinanetwork/patchats/common/email/Message.java b/src/main/java/org/patinanetwork/patchats/common/email/Message.java new file mode 100644 index 0000000..fbfb392 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/email/Message.java @@ -0,0 +1,32 @@ +package org.patinanetwork.patchats.common.email; + +import java.util.Date; + +/** + * This class exists due to the fact that when the email connection is closed, all Messages are subsequently closed as + * well. + */ +public class Message { + + private final String subject; + private final String message; + private final Date sentAt; + + public Message(final String subject, final String message, final Date sentAt) { + this.subject = subject; + this.message = message; + this.sentAt = sentAt; + } + + public String getSubject() { + return subject; + } + + public String getMessage() { + return message; + } + + public Date getSentAt() { + return sentAt; + } +} diff --git a/src/main/java/org/patinanetwork/patchats/common/email/client/patchats/OfficialPatChatsEmailClient.java b/src/main/java/org/patinanetwork/patchats/common/email/client/patchats/OfficialPatChatsEmailClient.java new file mode 100644 index 0000000..34d1f51 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/email/client/patchats/OfficialPatChatsEmailClient.java @@ -0,0 +1,87 @@ +package org.patinanetwork.patchats.common.email.client.patchats; + +import io.micrometer.core.annotation.Timed; +import jakarta.mail.Authenticator; +import jakarta.mail.PasswordAuthentication; +import jakarta.mail.Session; +import jakarta.mail.Transport; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import java.util.List; +import java.util.Properties; +import org.patinanetwork.patchats.common.email.EmailClient; +import org.patinanetwork.patchats.common.email.Message; +import org.patinanetwork.patchats.common.email.error.EmailException; +import org.patinanetwork.patchats.common.email.options.SendEmailOptions; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * This is the official Patchats email client which we use to interface with our actual users. + * + *

For example, we use this client to send emails to users to notify them of pairings. + */ + +@Component +@EnableConfigurationProperties(OfficialPatChatsEmailClientProperties.class) +@Timed(value = "email.client.execution") + + +public class OfficialPatChatsEmailClient extends EmailClient { + + private final OfficialPatChatsEmailClientProperties emailProperties; + private Session session; + + public OfficialPatChatsEmailClient(final OfficialPatChatsEmailClientProperties emailProperties) { + this.emailProperties = emailProperties; + final Properties properties = new Properties(); + properties.setProperty("mail.smtp.host", emailProperties.getHost()); + properties.setProperty("mail.smtp.port", emailProperties.getPort()); + properties.setProperty("mail.smtp.starttls.enable", "true"); + properties.setProperty("mail.smtp.starttls.required", "true"); + properties.setProperty("mail.smtp.auth", "true"); + + session = Session.getInstance(properties, new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(emailProperties.getUsername(), emailProperties.getPassword()); + } + }); + } + + /** @deprecated - This is not supported. */ + @Override + @Deprecated + public List getPastMessages() throws EmailException { + throw new EmailException("Reading messages is not supported"); + } + + @Override + public void sendMessage(final SendEmailOptions sendEmailOptions) throws EmailException { + try { + jakarta.mail.Message message = new MimeMessage(session); + message.setFrom(new InternetAddress(emailProperties.getUsername())); + message.setRecipient( + jakarta.mail.Message.RecipientType.TO, new InternetAddress(sendEmailOptions.getRecipientEmail())); + message.setSubject(sendEmailOptions.getSubject()); + message.setContent(sendEmailOptions.getBody(), "text/html; charset=UTF-8"); + + Transport.send(message); + } catch (Exception e) { + throw new EmailException("Something went wrong when sending message", e); + } + } + + @Override + public void testConnection() throws EmailException { + try { + // Should trigger connection already by this point, but just to be safe, calling + // something with session. + new MimeMessage(session); + // TODO - May need to actually test sending an email out, which can be added + // pretty trivially. + } catch (Exception e) { + throw new EmailException("Something went wrong when testing connection", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/patinanetwork/patchats/common/email/client/patchats/OfficialPatChatsEmailClientProperties.java b/src/main/java/org/patinanetwork/patchats/common/email/client/patchats/OfficialPatChatsEmailClientProperties.java new file mode 100644 index 0000000..daea09e --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/email/client/patchats/OfficialPatChatsEmailClientProperties.java @@ -0,0 +1,53 @@ +package org.patinanetwork.patchats.common.email.client.patchats; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "patchats.email") +public class OfficialPatChatsEmailClientProperties { + + private String host; + private String port; + private String type; + private String username; + private String password; + + public String getHost() { + return host; + } + + public void setHost(final String host) { + this.host = host; + } + + public String getPort() { + return port; + } + + public void setPort(final String port) { + this.port = port; + } + + public String getType() { + return type; + } + + public void setType(final String type) { + this.type = type; + } + + public String getUsername() { + return username; + } + + public void setUsername(final String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(final String password) { + this.password = password; + } +} diff --git a/src/main/java/org/patinanetwork/patchats/common/email/error/EmailException.java b/src/main/java/org/patinanetwork/patchats/common/email/error/EmailException.java new file mode 100644 index 0000000..e800588 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/email/error/EmailException.java @@ -0,0 +1,17 @@ +package org.patinanetwork.patchats.common.email.error; + +/** Base exception for {@link org.patinanetwork.patchats.common.email.EmailClient} */ +public class EmailException extends Exception { + + public EmailException() { + super(); + } + + public EmailException(final String message) { + super(message); + } + + public EmailException(final String message, final Throwable e) { + super(message, e); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/common/email/options/SendEmailOptions.java b/src/main/java/org/patinanetwork/patchats/common/email/options/SendEmailOptions.java new file mode 100644 index 0000000..e05ed81 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/email/options/SendEmailOptions.java @@ -0,0 +1,15 @@ +package org.patinanetwork.patchats.common.email.options; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class SendEmailOptions { + + private final String recipientEmail; + private final String subject; + private final String body; +} \ No newline at end of file diff --git a/src/main/java/org/patinanetwork/patchats/common/email/template/ReactEmailTemplater.java b/src/main/java/org/patinanetwork/patchats/common/email/template/ReactEmailTemplater.java new file mode 100644 index 0000000..dee0795 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/email/template/ReactEmailTemplater.java @@ -0,0 +1,19 @@ +package org.patinanetwork.patchats.common.email.template; + +import java.io.IOException; + +public interface ReactEmailTemplater { + /** + * Load the generated HTML from ClassPathResources as a String then injects variables using Jsoup and renders HTML + * as a string. + * + * @param recipientName + * @param verifyUrl + * @param supportEmail + * @return the rendered HTML as a string + * @throws IOException + */ + String createExampleTemplate(String recipientName, String verifyUrl, String supportEmail) throws IOException; //example from CodeBloom + +} + diff --git a/src/main/java/org/patinanetwork/patchats/common/email/template/ReactEmailTemplaterImpl.java b/src/main/java/org/patinanetwork/patchats/common/email/template/ReactEmailTemplaterImpl.java new file mode 100644 index 0000000..95998d3 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/email/template/ReactEmailTemplaterImpl.java @@ -0,0 +1,33 @@ +package org.patinanetwork.patchats.common.email.template; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; + +@Component +public class ReactEmailTemplaterImpl implements ReactEmailTemplater { + + private String getHtmlAsString(final String path) throws IOException { + ClassPathResource resource = new ClassPathResource(path); + return StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); + } + + @Override + public String createExampleTemplate(final String recipientName, final String verifyUrl, final String supportEmail) + throws IOException { + final String html = getHtmlAsString("static/email/example.html"); + final Document doc = Jsoup.parse(html); + + doc.getElementById("input-recipientName-innerText").text(recipientName); + doc.getElementById("input-verifyUrl-innerText").text(verifyUrl); + doc.getElementById("input-verifyUrl-href").attr("href", verifyUrl); + doc.getElementById("input-supportEmail-innerText").text("mailto:" + supportEmail); + doc.getElementById("input-supportEmail-href").attr("href", "mailto:" + supportEmail); + + return doc.outerHtml(); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/common/email/template/ReactEmailTemplaterTest.java b/src/test/java/org/patinanetwork/patchats/common/email/template/ReactEmailTemplaterTest.java new file mode 100644 index 0000000..5b3eec7 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/common/email/template/ReactEmailTemplaterTest.java @@ -0,0 +1,66 @@ +package org.patinanetwork.patchats.common.email.template; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.junit.jupiter.api.Test; + +class ReactEmailTemplaterTest { + + @Test + void exampleTemplateTest() throws IOException { + ReactEmailTemplater templater = new ReactEmailTemplaterImpl(); + + String recipientName = "Example"; + String verifyUrl = "https://example.com"; + String supportEmail = "patchats@patinanetwork.org"; + + String renderedHtml = templater.createExampleTemplate(recipientName, verifyUrl, supportEmail); + + Document doc = Jsoup.parse(renderedHtml); + + Element recipientText = doc.getElementById("input-recipientName-innerText"); + assertNotNull(recipientText, "Missing element: input-recipientName-innerText"); + assertEquals("Example", recipientText.text(), "recipientName text not set"); + + Element verifyText = doc.getElementById("input-verifyUrl-innerText"); + assertNotNull(verifyText, "Missing element: input-verifyUrl-innerText"); + assertEquals("https://example.com", verifyText.text(), "verifyUrl text not set"); + + Element verifyHref = doc.getElementById("input-verifyUrl-href"); + assertNotNull(verifyHref, "Missing element: input-verifyUrl-href"); + assertEquals("https://example.com", verifyHref.attr("href"), "verifyUrl href not set"); + + Element supportText = doc.getElementById("input-supportEmail-innerText"); + assertNotNull(supportText, "Missing element: input-supportEmail-innerText"); + assertEquals("codebloom@patinanetwork.org", supportText.text(), "supportEmail text not set"); + + Element supportHref = doc.getElementById("input-supportEmail-href"); + assertNotNull(supportHref, "Missing element: input-supportEmail-href"); + assertEquals("codebloom@patinanetwork.org", supportHref.attr("href"), "supportEmail href not set"); + } +/* + @Test + void emailTest() throws IOException { + ReactEmailTemplater templater = new ReactEmailTemplaterImpl(); + String verifyUrl = "https://example.com/example/href"; + + String renderedHtml = templater.schoolEmailTemplate(verifyUrl); + + Document doc = Jsoup.parse(renderedHtml); + + Element verifyText = doc.getElementById("input-verifyUrl-innerText"); + assertNotNull(verifyText, "Missing element: input-verifyUrl-innerText"); + assertEquals(verifyUrl, verifyText.text(), "verifyUrl text not set"); + + Element verifyHref = doc.getElementById("input-verifyUrl-href"); + assertNotNull(verifyHref, "Missing element: input-verifyUrl-href"); + assertEquals(verifyUrl, verifyHref.attr("href"), "verifyUrl href not set"); + } +*/ +} +