Fms-Java版开发实录:13 密码重置功能 - 重置密码

Fms-Java版开发实录:13 密码重置功能 - 重置密码

密码重置功能编写完啦

终于到了密码重置功能的最后一部分了。这里要做的事情,就是处理用户访问那个随机地址,然后进行重置密码的操作。

功能设计

老样子,我们要先来设计一下功能。这里提一下之前没有关注过的点,那就是需要在Spring Security中对/reset开头的路径都放开访问权限,否则用户就无法访问了。

这里我们要解决的逻辑相比上一部分,还是简单了不少:

  1. 用户访问邮件中的路径,控制器使用@PathVariable来获取uuid字符串
  2. 控制器先删除所有过期记录,然后检查字符串是否有效
  3. 如果有效,返回重置密码的页面,让用户输入两个密码,同时在页面中需要埋入刚才的path来继续在POST的时候进行验证
  4. 如果无效,提示用户无效,再去返回页面验证

下边就来逐步实现

处理用户GET请求的控制器

这个控制器会先删除所有过期的记录,然后来验证这个字符串,如果字符串通过验证,就会返回一个供用户填写密码的页面。如果验证失败,就返回info页面。

编写VO

对于要传入密码重置页面的数据,我们可以编写一个VO,用来存储两个密码和这个字符串,在之后的控制器中也好处理,如下:

import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotEmpty;

public class PasswordReset {

    @NotEmpty(message = "密码不能为空")
    @Length(max = 128, message = "密码最长为128位")
    private String password1;

    @NotEmpty(message = "确认密码不能为空")
    @Length(max = 128, message = "密码最长为128位")
    private String password2;

    @NotEmpty(message = "PATH不能为空")
    @Length(max = 32, message = "PATH最长为32个字符")
    private String path;
}

GET控制器

有了VO,就可以用@ModelAttribute来方便的编写控制器了。控制器验证失败的时候返回info页面,验证成功则向密码重置页面传入带有pathVO

@GetMapping("/reset/{key}")
public String validateResetKey(@ModelAttribute("resetData") PasswordReset resetData, @PathVariable("key") String path, Model model) {
    //删除所有过期的path
    passwordResetService.deleteAllExpiredPath();

    //验证成功,给resetData设置上path,传入页面
    if (passwordResetService.isValid(path)) {
        resetData.setPath(path);
        return "/account/resetInput";
    } else {
        //验证失败
        model.addAttribute("customTitle", "重置链接无效");
        model.addAttribute("info", "链接已失效");
        return "/account/info";
    }
}

输入密码页面

在控制器中我们已经命名了页面叫做resetInput.html,这个页面依然采用居中的模式,编写如下:

<main class="form-signin">
    <form action="/account/reset" th:action="|@{/account/reset/}*{path}|" method="post" th:object="${resetData}">
        <img class="mb-4" src="/img/sinochem.png" th:src="@{/img/sinochem.png}" alt="中化集团">
        <h1 class="h3 mb-3 fw-normal text-primary">输入新密码</h1>
        <input type="hidden" name="path" id="path" th:value="*{path}">
        <div th:if="${errorMessage} != null" class="alert alert-danger" role="alert" th:text="${errorMessage}">
            密码不一致或链接已失效
        </div>
        <div class="form-floating">
            <input type="password" maxlength="128" required class="form-control" th:field="*{password1}" id="password1"
                   placeholder="新密码">
            <label for="password1">新密码</label>
            <div th:if="${#fields.hasErrors('password1')}" class="alert alert-danger" role="alert"
                 th:errors="*{password1}"></div>
        </div>
        <div class="form-floating">
            <input type="password" maxlength="128" required class="form-control" th:field="*{password2}" id="password2"
                   placeholder="重复新密码">
            <label for="password2">重复新密码</label>
            <div th:if="${#fields.hasErrors('password2')}" class="alert alert-danger" role="alert"
                 th:errors="*{password2}"></div>
        </div>
            <button class="w-100 btn btn-lg btn-primary mt-3" type="submit">提交</button>
        <p class="mt-3 mb-3 text-muted">Copyrights &copy;2021
            <a href="https://conyli.cc" target="_blank" style="text-decoration: none">https://conyli.cc</a>
        </p>
        <p class="mt-3 mb-3 text-muted">All Rights Reserved</p>
    </form>
</main>

页面埋入了一个path,然后表单的action属性拼接了从控制器传入的地址。下一步就是在接受POST请求的控制器中进行校验和设置密码。

POST控制器编写

最后这个控制器的逻辑还比较复杂,详细如下:

  1. 验证用户输入的两个密码是否一致,不一致返回页面提示重新填写
  2. 通过@PathVariable来获取path
  3. 为了万无一失,将其与通过POST请求传入的resetData中的path属性进行对比,如果有错误就返回info页面提示失效
  4. 删除所有过期链接
  5. 检验path是否有效,无效依然返回info页面提示失效
  6. 上述检验都通过之后,根据path对象获取用户对象,设置上经过编码的密码,然后保存用户对象
  7. 保存完成之后,删除该用户所有的重置密码链接,重定向到登录页面

具体编写如下,要注意的点是完成之后要取消会话,强制用户重新登录;其他的按照上边逻辑直接编写即可:

@PostMapping("/reset/{key}")
public String processResetPassword(@Valid @ModelAttribute("resetData") PasswordReset resetData, BindingResult rs, @PathVariable("key") String path, Model model, HttpServletRequest request) {

    // Bean校验
    if (rs.hasErrors()) {
        model.addAttribute("errorMessage", "输入信息有误,请检查");
        return "/account/resetInput";
    }

    // 检测密码是否相同
    if (!resetData.getPassword1().equals(resetData.getPassword2())) {
        model.addAttribute("errorMessage", "两个密码不一致");
        return "/account/resetInput";
    }

    // 删除所有过期链接
    passwordResetService.deleteAllExpiredPath();

    // 判断两个path是否相同,或者是否已经失效
    if ((!path.equals(resetData.getPath())) || (!passwordResetService.isValid(path))) {
        model.addAttribute("customTitle", "链接已失效");
        model.addAttribute("info", "链接已失效");
        return "/account/info";
    }

    // 至此检验全部通过,通过path对象获取User,然后设置密码并保存
    User user = passwordResetService.getPathByPath(path).getUser();
    user.setPassword(passwordEncoder.encode(resetData.getPassword1()));
    userService.saveUser(user);
    // 删除该用户对应的所有path记录
    passwordResetService.deleteAllExpiredPathByUser(user.getUsername());
    // 万无一失,取消session
    request.getSession().invalidate();
    model.addAttribute("customTitle", "密码重置成功");
    model.addAttribute("info", "密码重置成功,请登录");
    return "/account/info";
}

既然是叫做UserService怎么能没有保存User的方法呢,添加一个即可,具体代码就不放了。

这里的逻辑是即使用户登录,也可以让其重置密码,但是发送邮件成功之后,必须要强制让其退出登录,所以这里需要在上一节已经编写的/resetPOST请求控制器中加上一行:

// 发送邮件
if (emailSendingService.sendMimeMessage(username, email, resetPath.getPath())) {
    model.addAttribute("customTitle", "返回登录");
    model.addAttribute("info", "邮件已发送,请按照邮件重置密码");
    // 取消用户登录
    request.getSession().invalidate();
    return "/account/info";

至此彻底编写好了重置密码的功能,顺便把Session失效时间设置为一小时:

server.servlet.session.timeout=3600

其他一些收尾工作

编写好了用户功能,现在整个用户功能都是独立于其他功能模块的,也方便和其他业务内容进行整合。后边可能还需要进行一些重构,把用户模块的控制器,DAO等再区分的详细一些。

一些地址的登录后访问跳转

现在可以回顾一下整个账户URL

URL安全登录后访问
/account/login完全放开跳转首页
/account/auth完全放开POST使用,GET无法访问
/account/logout需要登录正常访问
/account/signout完全放开POST使用,GET无法访问
/account/register完全放开跳转首页
/account/profile需要登录正常访问
/account/reset完全放开跳转首页

再检查一下控制器,发现我们的/reset路径还没有加上登录后访问自动跳转首页的功能,现在就补上:

@GetMapping("/reset")
public String passwordResetPage(HttpServletRequest request) {
    if (request.getUserPrincipal() != null) {
        return "redirect:/";
    }
    return "/account/reset";
}

另外为了统一起见,一律将所有的重定向都设置为redirect:/即跳转根目录。

修改HTML模板

为了让密码重置邮件看起来更加漂亮点,就找了一个模板改了改,至少比起干巴巴的样子好看不少,不过在各种客户端里还是有细微的区别,业务方法也修改一下,传入了时间和邮件地址,把代码贴在这里免得遗忘:

package cc.conyli.fms.service;

import cc.conyli.fms.config.EmailProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

import javax.mail.internet.MimeMessage;
import javax.transaction.Transactional;
import java.text.SimpleDateFormat;
import java.util.Date;

@Service
public class EmailSendingService {

    private final JavaMailSender emailSender;
    private final EmailProperties emailProperties;
    private final static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss");

    @Autowired
    public EmailSendingService(JavaMailSender emailSender, EmailProperties emailProperties) {
        this.emailSender = emailSender;
        this.emailProperties = emailProperties;
    }

    @Transactional
    public boolean sendMimeMessage(String username, String email, String path) {
        MimeMessage mimeMessage = emailSender.createMimeMessage();
        String message = template
                .replace("{{username}}", username)
                .replace("{{link}}", emailProperties.getPath() + path)
                .replace("{{interval}}", emailProperties.getInvalidInterval())
                .replace("{{mail}}", emailProperties.getFrom())
                .replace("{{time}}", simpleDateFormat.format(new Date()));
        try {
            MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);
            helper.setTo(email);
            helper.setFrom(emailProperties.getFrom());
            helper.setSubject("用户密码重置");
            helper.setText(message, true);
            emailSender.send(mimeMessage);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    private final static String template = "<!DOCTYPE html>\n" +
            "<html lang=\"en\" xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\">\n" +
            "<head>\n" +
            "    <meta charset=\"utf-8\"> <!-- utf-8 works for most cases -->\n" +
            "    <meta name=\"viewport\" content=\"width=device-width\"> <!-- Forcing initial-scale shouldn't be necessary -->\n" +
            "    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"> <!-- Use the latest (edge) version of IE rendering engine -->\n" +
            "    <meta name=\"x-apple-disable-message-reformatting\">  <!-- Disable auto-scale in iOS 10 Mail entirely -->\n" +
            "    <meta name=\"format-detection\" content=\"telephone=no,address=no,email=no,date=no,url=no\"> <!-- Tell iOS not to automatically link certain text strings. -->\n" +
            "    <meta name=\"color-scheme\" content=\"light\">\n" +
            "    <meta name=\"supported-color-schemes\" content=\"light\">\n" +
            "    <title>密码重置 - FMS</title>\n" +
            "    <![endif]-->\n" +
            "\n" +
            "\n" +
            "        <style>\n" +
            "            * {\n" +
            "                font-family: sans-serif !important;\n" +
            "            }\n" +
            "        </style>\n" +
            "    <![endif]-->\n" +
            "\n" +
            "\n" +
            "    <style>\n" +
            "\n" +
            "        :root {\n" +
            "          color-scheme: light;\n" +
            "          supported-color-schemes: light;\n" +
            "        }\n" +
            "\n" +
            "        html,\n" +
            "        body {\n" +
            "            margin: 0 auto !important;\n" +
            "            padding: 0 !important;\n" +
            "            height: 100% !important;\n" +
            "            width: 100% !important;\n" +
            "        }\n" +
            "\n" +
            "        * {\n" +
            "            -ms-text-size-adjust: 100%;\n" +
            "            -webkit-text-size-adjust: 100%;\n" +
            "        }\n" +
            "\n" +
            "        div[style*=\"margin: 16px 0\"] {\n" +
            "            margin: 0 !important;\n" +
            "        }\n" +
            "\n" +
            "        #MessageViewBody, #MessageWebViewDiv{\n" +
            "            width: 100% !important;\n" +
            "        }\n" +
            "\n" +
            "        table,\n" +
            "        td {\n" +
            "            mso-table-lspace: 0pt !important;\n" +
            "            mso-table-rspace: 0pt !important;\n" +
            "        }\n" +
            "\n" +
            "        th {\n" +
            "        \tfont-weight: normal;\n" +
            "        }\n" +
            "\n" +
            "        table {\n" +
            "            border-spacing: 0 !important;\n" +
            "            border-collapse: collapse !important;\n" +
            "            table-layout: fixed !important;\n" +
            "            margin: 0 auto !important;\n" +
            "        }\n" +
            "\n" +
            "        a {\n" +
            "            text-decoration: none;\n" +
            "        }\n" +
            "\n" +
            "        img {\n" +
            "            -ms-interpolation-mode:bicubic;\n" +
            "        }\n" +
            "\n" +
            "        a[x-apple-data-detectors],  /* iOS */\n" +
            "        .unstyle-auto-detected-links a,\n" +
            "        .aBn {\n" +
            "            border-bottom: 0 !important;\n" +
            "            cursor: default !important;\n" +
            "            color: inherit !important;\n" +
            "            text-decoration: none !important;\n" +
            "            font-size: inherit !important;\n" +
            "            font-family: inherit !important;\n" +
            "            font-weight: inherit !important;\n" +
            "            line-height: inherit !important;\n" +
            "        }\n" +
            "\n" +
            "        .im {\n" +
            "            color: inherit !important;\n" +
            "        }\n" +
            "\n" +
            "        .a6S {\n" +
            "           display: none !important;\n" +
            "           opacity: 0.01 !important;\n" +
            "\t\t}\n" +
            "\t\timg.g-img + div {\n" +
            "\t\t   display: none !important;\n" +
            "\t\t}\n" +
            "\n" +
            "\n" +
            "        @media only screen and (min-device-width: 320px) and (max-device-width: 374px) {\n" +
            "            u ~ div .email-container {\n" +
            "                min-width: 320px !important;\n" +
            "            }\n" +
            "        }\n" +
            "        @media only screen and (min-device-width: 375px) and (max-device-width: 413px) {\n" +
            "            u ~ div .email-container {\n" +
            "                min-width: 375px !important;\n" +
            "            }\n" +
            "        }\n" +
            "        @media only screen and (min-device-width: 414px) {\n" +
            "            u ~ div .email-container {\n" +
            "                min-width: 414px !important;\n" +
            "            }\n" +
            "        }\n" +
            "\n" +
            "    </style>\n" +
            "\n" +
            "    <style>\n" +
            "\n" +
            "        .button-td,\n" +
            "        .button-a {\n" +
            "            transition: all 100ms ease-in;\n" +
            "        }\n" +
            "\t    .button-td-primary:hover,\n" +
            "\t    .button-a-primary:hover {\n" +
            "\t        background: #555555 !important;\n" +
            "\t        border-color: #555555 !important;\n" +
            "\t    }\n" +
            "\n" +
            "        @media screen and (max-width: 600px) {\n" +
            "\n" +
            "            .email-container {\n" +
            "                width: 100% !important;\n" +
            "                margin: auto !important;\n" +
            "            }\n" +
            "\n" +
            "            .stack-column,\n" +
            "            .stack-column-center {\n" +
            "                display: block !important;\n" +
            "                width: 100% !important;\n" +
            "                max-width: 100% !important;\n" +
            "                direction: ltr !important;\n" +
            "            }\n" +
            "            .stack-column-center {\n" +
            "                text-align: center !important;\n" +
            "            }\n" +
            "\n" +
            "            .center-on-narrow {\n" +
            "                text-align: center !important;\n" +
            "                display: block !important;\n" +
            "                margin-left: auto !important;\n" +
            "                margin-right: auto !important;\n" +
            "                float: none !important;\n" +
            "            }\n" +
            "            table.center-on-narrow {\n" +
            "                display: inline-block !important;\n" +
            "            }\n" +
            "\n" +
            "            .email-container p {\n" +
            "                font-size: 17px !important;\n" +
            "            }\n" +
            "        }\n" +
            "\n" +
            "    </style>\n" +
            "    <!-- Progressive Enhancements : END -->\n" +
            "\n" +
            "</head>\n" +
            "\n" +
            "<body width=\"100%\" style=\"margin: 0; padding: 0 !important; mso-line-height-rule: exactly; background-color: #222222;\">\n" +
            "  <center role=\"article\" aria-roledescription=\"email\" lang=\"en\" style=\"width: 100%; background-color: #222222;\">\n" +
            "    <!--[if mso | IE]>\n" +
            "    <table role=\"presentation\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" style=\"background-color: #222222;\">\n" +
            "    <tr>\n" +
            "    <td>\n" +
            "    <![endif]-->\n" +
            "\n" +
            "        <div style=\"max-height:0; overflow:hidden; mso-hide:all;\" aria-hidden=\"true\">\n" +
            "            密码重置 - {{username}}\n" +
            "        </div>\n" +
            "\n" +
            "        <div style=\"display: none; font-size: 1px; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden; mso-hide: all; font-family: sans-serif;\">\n" +
            "            &zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;\n" +
            "        </div>\n" +
            "\n" +
            "        <table align=\"center\" role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" width=\"600\" style=\"margin: auto;\" class=\"email-container\">\n" +
            "            <tr>\n" +
            "                <td style=\"padding: 30px 0;font-size: 40px; text-align: center;color: whitesmoke\">\n" +
            "\t\t\t\t\tPassword Reset\n" +
            "                </td>\n" +
            "            </tr>\n" +
            "\n" +
            "            <tr>\n" +
            "                <td style=\"background-color: #ffffff;\">\n" +
            "                    <table role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" width=\"100%\">\n" +
            "                        <tr>\n" +
            "                            <td style=\"padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;\">\n" +
            "                                <h1 style=\"margin: 0 0 10px; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;\">亲爱的{{username}}:</h1>\n" +
            "                                <p style=\"margin: 0 0 10px;line-height: 25px\">您收到此封邮件是因为您于{{time}}在FMS上申请了密码重置。如果密码重置不是由您发起,请忽略此封邮件。</p>\n" +
            "                                <p style=\"margin: 0 0 10px;line-height: 25px\">请点击下方的重置链接来完成您的密码重置工作。链接将在{{interval}}小时后失效,失效后您需要重新进行密码重置。</p>\n" +
            "                            </td>\n" +
            "                        </tr>\n" +
            "                        <tr>\n" +
            "                            <td style=\"padding: 0 20px 20px;\">\n" +
            "                                <!-- Button : BEGIN -->\n" +
            "                                <table align=\"center\" role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" style=\"margin: auto;\">\n" +
            "                                    <tr>\n" +
            "                                        <td class=\"button-td button-td-primary\" style=\"border-radius: 4px; background: #222222;\">\n" +
            "\t\t\t\t\t\t\t\t\t\t\t<a class=\"button-a button-a-primary\" href=\"{{link}}\" style=\"background: #222222; border: 1px solid #000000; font-family: sans-serif; font-size: 15px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;\">重置链接</a>\n" +
            "\t\t\t\t\t\t\t\t\t\t</td>\n" +
            "                                    </tr>\n" +
            "                                </table>\n" +
            "                                <!-- Button : END -->\n" +
            "                            </td>\n" +
            "                        </tr>\n" +
            "\t\t\t\t\t\t<tr>\n" +
            "\t\t\t\t\t\t\t<td style=\"padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;\">\n" +
            "\t\t\t\t\t\t\t\t<p style=\"margin: 0 0 10px;line-height: 25px\">如果链接无法点击,请复制下列链接至浏览器地址栏打开:</p>\n" +
            "\t\t\t\t\t\t\t\t<p style=\"margin: 0 0 10px;font-size: 20px;color: #0a53be\">{{link}}</p>\n" +
            "\t\t\t\t\t\t\t</td>\n" +
            "\t\t\t\t\t\t</tr>\n" +
            "                    </table>\n" +
            "                </td>\n" +
            "            </tr>\n" +
            "\t        <tr>\n" +
            "\t            <td aria-hidden=\"true\" height=\"40\" style=\"font-size: 0px; line-height: 0px;\">\n" +
            "\t                &nbsp;\n" +
            "\t            </td>\n" +
            "\t        </tr>\n" +
            "\n" +
            "\t    </table>\n" +
            "\n" +
            "        <table align=\"center\" role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" width=\"600\" style=\"margin: auto;\" class=\"email-container\">\n" +
            "\t        <tr>\n" +
            "\t            <td style=\"padding: 20px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #ffffff;\">\n" +
            "\t                <webversion style=\"color: #ffffff; font-weight: bold;font-size: 16px\">Finance-Project Management System</webversion>\n" +
            "\t                <br><br>\n" +
            "\t                Developed by<br><a style=\"color: whitesmoke;font-size: 14px;text-decoration: underline\" target=\"_blank\" href=\"https://conyli.cc\">https://conyli.cc</a>\n" +
            "\t                <br><br>\n" +
            "\t            </td>\n" +
            "\t        </tr>\n" +
            "\t    </table>\n" +
            "\n" +
            "\t    <table role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" width=\"100%\" style=\"background-color: #709f2b;\">\n" +
            "\t        <tr>\n" +
            "\t            <td>\n" +
            "\t                <div align=\"center\" style=\"max-width: 600px; margin: auto;\" class=\"email-container\">\n" +
            "\t                    <!--[if mso]>\n" +
            "\t                    <table role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" width=\"600\" align=\"center\">\n" +
            "\t                    <tr>\n" +
            "\t                    <td>\n" +
            "\t                    <![endif]-->\n" +
            "\t                    <table role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" width=\"100%\">\n" +
            "\t                        <tr>\n" +
            "\t                            <td style=\"padding: 20px; text-align: left; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #ffffff;\">\n" +
            "\t                                <p style=\"margin: 0;text-align: center\">\n" +
            "\t\t\t\t\t\t\t\t\t\t无法自助重置密码,请发送邮件至<a style=\"color: whitesmoke;text-decoration: underline\" href=\"mailto:{{mail}}\">{{mail}}</a>。\n" +
            "\t\t\t\t\t\t\t\t\t</p>\n" +
            "\t                            </td>\n" +
            "\t                        </tr>\n" +
            "\t                    </table>\n" +
            "\t                    <!--[if mso]>\n" +
            "\t                    </td>\n" +
            "\t                    </tr>\n" +
            "\t                    </table>\n" +
            "\t                    <![endif]-->\n" +
            "\t                </div>\n" +
            "\t            </td>\n" +
            "\t        </tr>\n" +
            "\t    </table>\n" +
            "\n" +
            "    <!--[if mso | IE]>\n" +
            "    </td>\n" +
            "    </tr>\n" +
            "    </table>\n" +
            "    <![endif]-->\n" +
            "    </center>\n" +
            "</body>\n" +
            "</html>\n";
}
LICENSED UNDER CC BY-NC-SA 4.0
Comment