如何使用ASP.NET Core和SendGrid建立一个电子邮件通讯应用程序

170 阅读15分钟

发送新闻简报是让你的受众了解最新新闻的一个好方法。有一些现有的通讯产品,如SendGrid电子邮件营销,然而,在客户要求更多定制的情况下,使用SendGrid电子邮件API自己建立一个通讯应用程序是一个很好的选择。

构建你自己的电子报应用程序的好处是,你可以控制系统的核心部分,并且仍然可以通过SendGrid最大限度地提高交付能力和衡量参与度。

在本教程中,你将学习如何使用ASP.NET Core Razor Pages和SendGrid构建一个新闻通讯应用程序。

解决方案概述

每个电子报应用都涉及两方:电子报的订阅者和电子报的作者

订阅者的旅程是这样的。即将成为订阅者的人填写了一份订阅表格,其中包括他们的电子邮件地址和其他细节(取决于你的业务需求)。当订阅者提交表格时,应用程序会在你的数据库中创建一个新的联系人,订阅者会收到一封电子邮件,其中有确认订阅的链接。然后,订阅者点击确认链接,完成这一过程。这个流程被称为双重选择,可以防止恶意用户为其他人订阅。

此后,订阅者将收到你的新闻邮件。

如果订阅者想取消订阅,订阅者点击取消订阅链接,该链接将在你的新闻邮件的底部。一旦点击,订阅者就会被重定向到你的网络应用,他们的联系信息将从你的数据库中删除。

另一方面,作者必须用HTML写一个新的通讯问题。然后,作者可以上传HTML文件,将通讯发送到你的联系人列表。

因此,你要建立的页面是:

  • 一个用户的注册页面。
  • 一个确认页。
  • 一个取消订阅的页面,以及
  • 通讯上传页面。

前提条件

你将需要一些东西来跟随:

你可以在这个GitHub资源库中找到你要构建的应用程序的源代码,如果你遇到困难,可以参考一下。

配置SendGrid

为了使用SendGrid,你必须配置两样东西;电子邮件发送器和API密钥。电子邮件发送者确认你拥有你想要发送邮件的电子邮件地址或域名。API密钥可以让你对SendGrid的API进行认证,并给予你发送电子邮件的授权。

在SendGrid中配置电子邮件发送器

为了快速上手,你将在本教程中使用单一发件人验证。这将验证你是否拥有应用程序将发送电子邮件的电子邮件地址。单一发件人验证对于测试来说是很好的,但不建议用于生产。

Twilio建议生产环境使用域名认证。一个经过认证的域名可以向电子邮件提供商证明你拥有该域名,并删除 "通过sendgrid.net "的文字,否则收件箱提供商会将其附加到你的发件地址。

登录SendGrid应用程序,导航到设置 部分并点击发送者认证。点击 "验证单个发件人"。 你应该看到一个 ,看起来就像下面这个屏幕。

The Sender Screen Picture contains a field for name, email address, reply to, company address, city, country, zip code and nickname

填写表格并点击创建。一旦填写完毕,就会有一封确认邮件发送到你在发件人邮件地址栏 中输入的邮件地址。进入你的邮箱,找到该邮件并点击验证单一发件人 按钮(如下图)以完成该过程。

SendGrid Sender confirmation email with a button labeled as Verify Single Sender

导航回到 "发件人验证">"设置"页面。单一发件人验证部分应该显示你的电子邮件地址为已验证状态。

生成API密钥

导航到设置 部分,点击API密钥。 点击页面右上方的创建API密钥 按钮,继续。

创建API密钥页面,填写API密钥名称 ,并将API密钥权限 设置为 限制性访问

API key page showing the list of permissions

向下滚动到 "邮件发送 ",并单击以显示其下方的权限。将邮件发送上的滑块拖到左边。

List of permission toggles for the SendGrid API key. The user toggled the Mail Send permission to on.

继续点击创建和查看 按钮。

SendGrid现在将向你显示API密钥。请确保将其复制到安全的地方。API密钥将不会再次显示,所以如果你丢失了它,你需要生成一个新的密钥。

点击 "完成 "按钮。

随着发送者的验证和API密钥的生成,现在是时候开始构建网络应用了。

构建网络应用程序

打开你的终端,使用以下命令创建一个新的文件夹:

mkdir NewsletterApp

然后通过运行这个命令进入新的文件夹:

cd NewsletterApp

在这个新文件夹中,你将创建一个ASP.NET Core Razor项目。运行下面的命令来创建该项目:

dotnet new razor

你可以使用命令dotnet run 来启动该应用程序。然后导航到其中一个URL来查看网络应用。

Ctrl + C 关闭该应用程序。

更新网站CSS

GitHub上这个文件的CSS更新wwwroot/css/site.css文件,并更新Pages/Shared/_Layout.cshtml ,使其看起来像下面的代码 ,以删除模板中的导航和页脚:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - NewsletterApp</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
    <link rel="stylesheet" href="~/NewsletterApp.styles.css" asp-append-version="true" />
</head>
<body>

   @RenderBody()

    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>

    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>

安装SendGrid SDK和设置API密钥

敏感数据,如SendGrid API Key,在.NET中被存储为本地开发的用户秘密,而用户秘密的结构是一个键值对。首先,通过在终端运行以下命令来初始化用户秘密:

dotnet user-secrets init

然后使用下面的命令来设置键值对的keySendGridApiKey:

dotnet user-secrets set "SendGridApiKey" "<YOUR_SENDGRID_API_KEY>" 

用你之前复制的API Key替换<YOUR_SENDGRID_API_KEY>

现在,SendGrid的API Key可以通过.NET中的IConfiguration

接下来,安装依赖项:

使用以下命令安装SendGrid依赖注入(DI)NuGet包。

dotnet add package SendGrid 
dotnet add package SendGrid.Extensions.DependencyInjection

SendGrid 包将被用来发送邮件,DI包将把SendGrid客户端添加到DI容器中。要了解更多关于SendGrid包的功能,请查看SendGrid C# .NET GitHub仓库

现在,使用你喜欢的代码编辑器打开你的项目。

然后更新Program.cs文件,将SendGrid 注册到依赖注入(DI)容器中的SendGridApiKey:

using SendGrid.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSendGrid(options =>
    options.ApiKey = builder.Configuration["SendGridApiKey"]
);

接下来,在appsettings.json文件中添加以下属性:

{
  ...
  "SendGridSenderEmail": "[SENDER_EMAIL]", 
  "SendGridSenderName": "[SENDER_NAME]"
}

用你先前在SendGrid上配置的发送者电子邮件替换[SENDER_EMAIL],用你的名字或你的企业或你的通讯替换[SENDER_NAME][SENDER_NAME] 是你的电子邮件的收件人在收到电子邮件时将看到的名字。

稍后,你将从.NET的配置中获取这些设置,并使用它来发送电子邮件。

创建数据模型和数据库

在这个应用程序中,你将使用Entity Framework Core(EF Core)--来自微软的对象关系映射(ORM)--在一个SQLite数据库中存储联系人。

安装EF Core Sqlite包:

dotnet add package Microsoft.EntityFrameworkCore.Sqlite

接下来,安装EF Core工具EF Core设计包以帮助运行数据库迁移:

dotnet tool install --global dotnet-ef
dotnet add package Microsoft.EntityFrameworkCore.Design

在你的项目中创建一个Data 文件夹,在Data文件夹中添加一个文件Contact.cs,代码如下:

namespace NewsletterApp.Data;

public class Contact
{
    public int Id { get; set; }
    public string FullName { get; set; }
    public string Email { get; set; }
    public Guid ConfirmationId { get; set; }
    public bool IsConfirmed { get; set; }
}

这个文件将保存Contact 模型类,用来描述用户的联系信息,它将被用来保存数据到数据库。

Data文件夹中创建另一个文件,命名为NewsletterDbContext.cs并添加以下代码:

using Microsoft.EntityFrameworkCore;

namespace NewsletterApp.Data;

public class NewsletterDbContext : DbContext
{
    public NewsletterDbContext(DbContextOptions<NewsletterDbContext> options) : base(options)
    {
    }

    public DbSet<Contact> Contacts {get; set;}

    protected override void OnModelCreating(ModelBuilder builder)
    {
        builder.Entity<Contact>()
            .HasIndex(c => c.Email)
            .IsUnique();
    }
}

NewsletterDbContext 将让你通过Contacts 属性来处理数据库中的联系人数据。

在 appsettings.json中创建一个ConnectionStrings JSON对象 ,并添加以下数据库连接字符串:

{
 ...
 "ConnectionStrings": {
    "DefaultConnection":"Data Source=Contacts.db"
  }
}

当连接字符串不包含任何秘密信息时,你可以在appsettings.json中存储连接字符串,比如在这个开发场景中。但是,如果你的连接字符串中有凭证或其他敏感信息,不要使用appsettings.json,而是使用用户秘密(在开发过程中)、环境变量或外部金库服务。

接下来,更新Program.cs文件,将NewsletterDbContext ,用你刚刚配置的连接字符串注册到依赖注入(DI)容器中:

using Microsoft.EntityFrameworkCore;
using NewsletterApp.Data;
using SendGrid.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<NewsletterDbContext>(options => 
    options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"))
);

最后,运行下面的命令来生成数据库迁移并运行迁移:

dotnet ef migrations add InitialCreate
dotnet ef database update

结果是,现在应该已经创建了Contacts.dbSQLite数据库文件。

实现存储库模式

架构应用程序与数据库交互的一种常见方式是使用存储库模式。你不需要直接在控制器中编写数据库相关的代码,而是创建专门的类,即存储库,它只负责管理数据,然后从控制器中消耗这些存储库。

Data文件夹中创建一个新文件IContactRepository.cs,并添加以下代码:

namespace NewsletterApp.Data;

public interface IContactRepository
{
    void AddContact(Contact contact);
    Contact GetContactByEmail(string email);
    void ConfirmContact(string email);
    int GetConfirmedContactsCount();
    List<Contact> GetConfirmedContacts(int pageSize, int page);
    void DeleteContact(Contact contact);
}

接下来,在Data文件夹中添加另一个文件ContactRepository.cs,并添加以下代码:

using Microsoft.EntityFrameworkCore;

namespace NewsletterApp.Data;

public class ContactRepository : IContactRepository
{
    private readonly NewsletterDbContext _dbContext;
    
    public ContactRepository(NewsletterDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    
    public void AddContact(Contact contact)
    {
        contact.Email = contact.Email.Trim().ToLower();
        _dbContext.Contacts.Add(contact);
        _dbContext.SaveChanges();
    }
    
    public void DeleteContact(Contact subscriber)
    {
        _dbContext.Contacts.Remove(subscriber);
        _dbContext.SaveChanges();
    }

    public int GetConfirmedContactsCount() => _dbContext.Contacts.Count();

    public List<Contact> GetConfirmedContacts(int pageSize, int page)
    {
        return _dbContext.Contacts
            .AsNoTracking()
            .Where(m => m.IsConfirmed == true)
            .Skip(pageSize * page)
            .Take(pageSize)
            .ToList();
    }

    public Contact GetContactByEmail(string email)
    {
        email = email.Trim().ToLower();
        return _dbContext.Contacts
            .AsNoTracking()
            .FirstOrDefault(m => m.Email == email);
    }

    public void ConfirmContact(string email)
    {
        var contact = _dbContext.Contacts.Single(m => m.Email == email.ToLower());
        contact.IsConfirmed = true;
        _dbContext.SaveChanges();
    }
}

ContactRepository 类将被用来管理数据库的联系人数据,并通过IContactRepository 接口暴露。

最后,通过在Program.cs中注册SendGrid服务的地方之后添加以下一行,将ContactRepository 注册到DI容器中:

builder.Services.AddScoped<IContactRepository, ContactRepository>();

现在DI容器将在你的构造函数中接受一个IContactRepository 参数时将ContactRepository 注入你的Razor页面。

创建所有页面并更新appsettings

如前所述,这个新闻通讯应用程序将由一堆页面组成。运行下面的脚本来快速生成所有的页面:

cd Pages

dotnet new page --name SignUp --namespace NewsletterApp.Pages
dotnet new page --name SignUpSuccess --namespace NewsletterApp.Pages --no-pagemodel
dotnet new page --name Confirm --namespace NewsletterApp.Pages
dotnet new page --name Unsubscribe --namespace NewsletterApp.Pages
dotnet new page --name Upload --namespace NewsletterApp.Pages
dotnet new page --name UploadSuccess --namespace NewsletterApp.Pages --no-pagemodel

cd ..

该脚本首先导航到Pages文件夹,然后生成所有必要的页面,再导航回父文件夹。

每个dotnet new page 命令将生成一个Razor页面视图,即.cshtml-文件,和一个PageModel C#文件,即.chstml.cs文件,这是一个代码后台文件,你可以在其中添加你的逻辑。
当使用--no-pagemodel 参数时,只有View文件会被生成,而代码后台文件不会。这对那些只需要渲染Razor模板而不需要后面代码中的任何逻辑的页面很有用。

建立和测试电子邮件注册页面

创建一个名为Models的新文件夹,并在其中创建一个SignUpViewModel.cs文件,代码如下:

using System.ComponentModel.DataAnnotations;

namespace NewsletterApp.Models;

public class SignUpViewModel
{
    [Required]
    [DataType(DataType.Text)]
    public string FullName { get; set; }
    
    [Required]
    [DataType(DataType.EmailAddress)]
    public string Email { get; set; }
}

SignUpViewModel 类有一些属性,用于你将在表格中要求的信息,即 和 。通过对模型的属性应用 和 属性,ASP.NET将能够在以后为你验证表单。FullName EmailAddress Required DataType

接下来,在Pages/SignUp.cshtml 文件中添加以下代码:

@page
@model SignUpModel
@{
    ViewData["Title"] = "Sign up for newsletter";
}

<div class="form-container">
    <h1>Sign up for our monthly newsletter</h1>
    <p>Join our monthly newsletter to start receiving updates about our products.</p>
    <div>
        <form method="post">
            <div class="form-div">
                <label>Full Name:</label>
                <input asp-for="SignUpViewModel.FullName" required/>
            </div>
            <div>
                <label>Email:</label>
                <input asp-for="SignUpViewModel.Email" type="email" required/>
            </div>
            <button type="submit">Sign up</button>
        </form>
    </div>
</div>

SignUp 页面包含一个表单,供用户输入他们的联系信息:他们的电子邮件和全名。一旦表单被提交,后面代码中的OnPostAsync 方法就会被触发。

用以下代码更新Pages/SignUp.cshtml.cs文件:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using NewsletterApp.Models;
using NewsletterApp.Data;
using SendGrid;
using SendGrid.Helpers.Mail;
using System.Text.Encodings.Web;

namespace NewsletterApp.Pages;

public class SignUpModel : PageModel
{
    private readonly IConfiguration _config;
    private readonly ISendGridClient _sendGridClient;
    private readonly IContactRepository _contactRepository;
    private readonly HtmlEncoder _htmlEncoder;

    public SignUpModel(
        IConfiguration configuration, 
        ISendGridClient sendGridClient, 
        IContactRepository contactRepository,
        HtmlEncoder htmlEncoder
    )
    {
        _config = configuration;
        _sendGridClient = sendGridClient;
        _contactRepository = contactRepository;
        _htmlEncoder = htmlEncoder;
    }

    [BindProperty] public SignUpViewModel SignUpViewModel { get; set; }

    public void OnGet()
    {
    }

    public async Task<IActionResult> OnPostAsync()
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        var confirmationId = Guid.NewGuid();
        var confirmLink = Url.PageLink("Confirm", protocol: "https", values: new
        {
            email = SignUpViewModel.Email,
            confirmation = confirmationId
        });

       var message = new SendGridMessage
        {
            From = new EmailAddress(
                email: _config["SendGridSenderEmail"], 
                name: _config["SendGridSenderName"]
            ),
            Subject = "Confirm Newsletter Signup",
            HtmlContent = $@"<h3>Ahoy {_htmlEncoder.Encode(SignUpViewModel.FullName)}!</h3>
                            <p>Welcome to our Newsletter. <br>
                            Kindly click on the link below to confirm your subscription: <br>
                            <a href=""{confirmLink}"">Confirm your newsletter subscription</a></p>"
        };

        message.AddTo(new EmailAddress(SignUpViewModel.Email, SignUpViewModel.FullName));
        var response = await _sendGridClient.SendEmailAsync(message);

        if (!response.IsSuccessStatusCode)
        {
            throw new Exception("Sending confirmation email failed.");
        }
       
        var contact = new Contact
        {
            FullName = SignUpViewModel.FullName,
            Email = SignUpViewModel.Email,
            ConfirmationId = confirmationId
        };

        _contactRepository.AddContact(contact);

        return RedirectToPage("SignUpSuccess");
    }
}

DI容器将向SignUpModel 类的构造函数注入几个参数:IConfigurationISendGridClient 、 、IContactRepositoryHtmlEncoder 的实例。然后这些参数被存储在私有字段中,这样它们就可以在整个类中被访问:

    private readonly IConfiguration _config;
    private readonly ISendGridClient _sendGridClient;
    private readonly IContactRepository _contactRepository;
    private readonly HtmlEncoder _htmlEncoder;

    public SignUpModel(
        IConfiguration configuration, 
        ISendGridClient sendGridClient, 
        IContactRepository contactRepository,
        HtmlEncoder htmlEncoder
    )
    {
        _config = configuration;
        _sendGridClient = sendGridClient;
        _contactRepository = contactRepository;
        _htmlEncoder = htmlEncoder;
    }

HtmlEncoder 类被用来对用户的输入进行HTML编码,然后再将输入嵌入到HTML电子邮件中。这就是你如何防止对你的HTML电子邮件的HTML注入攻击

下面的代码部分将一个SignUpViewModel 的类绑定到页面上:

[BindProperty] public SignUpViewModel SignUpViewModel { get; set; }

最后一节包含了 OnGet 方法,它返回页面,以及 OnPostAsync 方法,该方法在用户提交表单时被触发。

列表中的 OnPostAsync方法遵循这个顺序:

  1. 使用ModelState.IsValid 验证用户发布的表单。
  2. 生成一个GUID confirmationId ,它将被用于确认和取消订阅的过程。由于只有用户会有确认ID作为其链接的一部分,它验证了事实上是合法的用户在进行操作,而不是恶意的用户。
  3. 构建一个格式为*:https://<BASE_URL>/Confirm?email=<CONTACT_EMAIL>&confirmation=<CONFIRMATION_ID>*的链接,使用Url.PageLink() 方法。在注册页面中,Url.PageLink() 方法帮助你生成一个通往确认 页面的URL。
  4. 此后,一个SendGridMessage 对象被创建,该对象具有来自表单的联系人详细信息和来自.NET配置的SendGrid发送者详细信息。
  5. 然后,注入的ISendGridClient 被用来向联系人发送确认SendGridMessage
  6. 如果电子邮件排队成功,它将使用_contactRepo 将用户添加到数据库中,并将用户重定向到SignUpSuccess 页面。

用以下代码更新Pages/SignUpSuccess**.cshtml文件:

@page
@{
    ViewData["Title"] = "Sign up Success";
}

<div class="form-container">
   <h1>Thanks for signing up.</h1>
    <p>An email has been sent to you. <br>Kindly confirm your subscription</p>
</div>

通过运行dotnet run 命令来启动应用程序。

打开你的浏览器,用这样的url导航到注册页面:https://localhost:/SignUp

A newsletter signup form with input fields for email and full name.

在表格中填写你的姓名和电子邮件地址,并提交。提交表格后,你应该被重定向到成功页面,看起来像这样。

Thanks for signing up. an email has ben sent to you. Kindly confirm your subscription.

检查你的电子邮件收件箱,看看是否有类似下面这样的电子邮件。

an email message with a confirm your newsletter subscription link

接下来,你将创建确认页面。

建立并测试确认页

确认页面是当用户点击他们收件箱中的确认链接以确认他们的通讯订阅时被重定向到的页面。

用以下代码更新Pages 文件夹中的Confirm.cshtml 文件:

@page
@model ConfirmModel
@{
    ViewData["Title"] = "Newsletter Confirmation";
}

<div class="form-container">
    <h1>@Model.ResponseMessage</h1>
</div>

该代码显示了从页面模型中传递给它的ResponseMessage 属性。

接下来,用下面的代码更新Pages/Confirm.cshtml.cs的后台代码:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using NewsletterApp.Data;
using SendGrid;

namespace NewsletterApp.Pages;

public class ConfirmModel : PageModel
{
    private readonly ISendGridClient _sendGridClient;
    private readonly IContactRepository _contactRepository;

    public ConfirmModel(ISendGridClient sendGridClient, IContactRepository contactRepository)
    {
        _sendGridClient = sendGridClient;
        _contactRepository = contactRepository;
    }

    public string ResponseMessage { get; set; }

    public void OnGet(
        [FromQuery(Name = "email")] string emailAddress,
        [FromQuery(Name = "confirmation")] Guid confirmationId
    )
    {
        var contact = _contactRepository.GetContactByEmail(emailAddress);
        if(contact == null)
        {
            ResponseMessage = "Sorry, but this is an invalid link";
            return;
        }

        if(contact.ConfirmationId.Equals(confirmationId))
        {
            _contactRepository.ConfirmContact(emailAddress);
            ResponseMessage = "Thank you for Signing up for our newsletter.";
        }
        else 
        {
            ResponseMessage = "Sorry, but this is an invalid link";   
        }
    }
}

OnGet 方法在用户点击确认链接时被触发,它从查询字符串中检索电子邮件地址和确认ID。

然后使用_contactRepository.GetContactByEmail(emailAddress) 方法从数据库中检索出联系人。如果找到了联系人,就使用_contactRepository.ConfirmContact(emailAddress) ,并将成功信息设置到ResponseMessage 属性中。

使用dotnet run 命令启动应用程序,并在收件箱中找到确认邮件。点击邮件中的确认链接,应该打出确认页面,该页面将显示如下图所示的页面:

Thank you for signing up for our newsletter.

下一步是允许用户取消订阅。

建立退订页面

当用户点击通讯底部的退订 ,他们会被重定向到退订页面。

添加以下代码到Razor视图文件Pages/Unsubscribe.cshtml:

@page
@model UnsubscribeModel
@{
    ViewData["Title"] = "Unsubscribe";
}

<div class="form-container">
   <p>@Model.ResponseMessage</p>
</div>

然后用下面的代码更新Pages/Unsubscribe.cshtml.cs 文件:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using NewsletterApp.Data;
using SendGrid;

namespace NewsletterApp.Pages;

public class UnsubscribeModel : PageModel
{
    private readonly ISendGridClient _sendGridClient;
    private readonly IContactRepository _contactRepository;

    public UnsubscribeModel(ISendGridClient sendGridClient, IContactRepository contactRepository)
    {
        _sendGridClient = sendGridClient;
        _contactRepository = contactRepository;
    }

    public string ResponseMessage { get; set; }

    public void OnGet(
        [FromQuery(Name = "email")] string emailAddress,
        [FromQuery(Name = "confirmation")] Guid confirmationId
    )
    {
        var contact = _contactRepository.GetContactByEmail(emailAddress);
        if (contact == null)
        {
            ResponseMessage = "Sorry, but it looks like you already unsubscribed";
            return;
        }
        
        if (contact.ConfirmationId.Equals(confirmationId))
        {
            _contactRepository.DeleteContact(contact);
            ResponseMessage = "Thank you. You have been successfully " +
            "removed from this subscriber list and  won't receive any further emails from us \n\n";
        }
        else
        {
            ResponseMessage = "Invalid link   " +
            "Sorry, we cannot unsubscribe you because this links appears to be corrupted";
        }
    }
}

当用户点击退订 链接时,OnGet 方法被触发,emailAddressconfirmationId 参数将从查询字符串中绑定。

使用emailAddress ,从数据库中检索联系人,使用_contactRepository ,比较确认ID。如果查询字符串中的confirmationId 与联系人的确认ID相匹配,_contactRepository.DeleteContact 方法将从数据库中删除该联系人。

建立和测试电子报上传页面

拼图的最后一点是向用户发送通讯。

Models 文件夹中创建一个名为UploadNewsletterViewModel**.cs 的文件。该 UploadNewsletterViewModel类将被绑定到上传通讯的表单中:

using System.ComponentModel.DataAnnotations;

namespace NewsletterApp.Models;

public class UploadNewsletterViewModel
{
    [Required]
    [DataType(DataType.Text)]
    public string EmailSubject { get; set; }
    
    [Required]
    [DataType(DataType.Upload)]
    public IFormFile Newsletter { get; set; }
}

通过使用[DataType(DataType.Upload)] 属性,ASP.NET将能够验证使用表单提交的文件,并且你将能够使用IFormFile 对象读取文件。

接下来,用以下代码更新Pages/Upload.cshtml文件:

@page
@model UploadModel
@{
    ViewData["Title"] = "Upload Newsletter";
}

<div class="form-container">
    <h1>Send Newsletter</h1>
    <p>Upload the Newsletter file</p>
    @if (Model.ErrorMessage != null)
    {
        <p class="critical">
            @Model.ErrorMessage
        </p>
    }
    <div>
        <form method="post" enctype="multipart/form-data">
            <div>
                <label>Email Subject</label>
                <input type="text" asp-for="NewsletterViewModel.EmailSubject" required />
            </div>
            <div>
                <label>Newsletter</label>
                <input type="file" asp-for="NewsletterViewModel.Newsletter" required />
            </div>

            <button type="submit">Upload</button>
        </form>
    </div>
</div>

该视图渲染了一个表单,供作者上传通讯的HTML文件:

Form with two fields: a text field to enter the Email Subject, and a file picker to submit the newsletter HTML file. Below the fields is a purple button saying "Upload".

上传 按钮被点击时,后面代码中的OnPostAsync 方法被触发。

更新代码后台文件,Pages/Upload.cshtml.cs 和下面的代码,并绑定类型为NewsletterViewModel 的属性UploadNewsletterViewModel:

using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using NewsletterApp.Models;
using NewsletterApp.Data;
using SendGrid;
using SendGrid.Helpers.Mail;

namespace NewsletterApp.Pages;

public class UploadModel : PageModel
{
    private readonly ISendGridClient _sendGridClient;
    private readonly HtmlEncoder _htmlEncoder;
    private readonly IContactRepository _contactRepository;
    private readonly IConfiguration _config;
    private readonly ILogger<UploadModel> _logger;

    public UploadModel(
        ISendGridClient sendGridClient, 
        HtmlEncoder htmlEncoder, 
        IContactRepository contactRepository, 
        IConfiguration config,
        ILogger<UploadModel> logger
    )
    {
        _sendGridClient = sendGridClient;
        _htmlEncoder = htmlEncoder;
        _contactRepository = contactRepository;
        _config = config;
        _logger = logger;
    }

    public string ErrorMessage { get; set; }
    [BindProperty] public UploadNewsletterViewModel NewsletterViewModel { get; set; }

    public void OnGet()
    {
    }

    public async Task<IActionResult> OnPostAsync()
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        StringBuilder newsletterContentBuilder = new();

        // read newsletter file to string
        using (var reader = new StreamReader(NewsletterViewModel.Newsletter.OpenReadStream()))
        {
            newsletterContentBuilder.Append(await reader.ReadToEndAsync());
        }

        // Remove unnecessary newline and return characters 
        newsletterContentBuilder.Replace("\n", "").Replace("\r", "");

        // create unsubscribeUrl with substitution tags
        var unsubscribeUrl = $"{Url.PageLink("Unsubscribe")}?email=-email-&confirmation=-confirmation-";
        newsletterContentBuilder.Replace("{UnsubscribeLink}", $@"<a href=""{unsubscribeUrl}"">Unsubscribe</a>");

        var contactsCount = _contactRepository.GetConfirmedContactsCount();
        if (contactsCount == 0)
        {
            ErrorMessage = "There are currently no subscribers";
            return Page();
        }

        // paginate contacts by pageSize amount per page
        // to not load too many contacts into memory at one time
        const int pageSize = 1000;
        var amountOfPages = (int) Math.Ceiling((double) contactsCount / pageSize);

        for (var currentPage = 0; currentPage < amountOfPages; currentPage++)
        {
            var contacts = _contactRepository.GetConfirmedContacts(pageSize, currentPage);
            var message = new SendGridMessage
            {
                // max 1000 Personalizations per message!
                Personalizations = contacts.Select(subscriber => new Personalization
                {
                    Tos = new List<EmailAddress> {new(subscriber.Email)},
                    // total collective size of Substitutions may not exceed 10,000 bytes
                    Substitutions = new Dictionary<string, string>
                    {
                        // HTML encode to prevent HTML injection attacks
                        {"-email-", _htmlEncoder.Encode(subscriber.Email)},
                        {"-confirmation-", _htmlEncoder.Encode(subscriber.ConfirmationId.ToString())}
                    }
                }).ToList(),

                From = new EmailAddress(
                    email: _config["SendGridSenderEmail"],
                    name: _config["SendGridSenderName"]
                ),
                Subject = NewsletterViewModel.EmailSubject,
                HtmlContent = newsletterContentBuilder.ToString()
            };

            var response = await _sendGridClient.SendEmailAsync(message);

            if (!response.IsSuccessStatusCode)
            {
                _logger.LogError(
                    "Failed to send Newsletter: {ResponseStatusCode} - {ResponseBody}",
                    response.StatusCode,
                    await response.Body.ReadAsStringAsync()
                );
                ErrorMessage = "Sorry, there was a problem while trying to send the newsletters. " +
                               "Some emails may have been sent, see https://app.sendgrid.com/email_activity.";
                return Page();
            }
        }
        
        return Redirect("UploadSuccess");
    }
}

PostAsync 方法从验证用户输入开始,并继续读取通讯作者上传的文件。然后在退订页面的基础上构建一个unsubscribeUrl ,以及电子邮件地址和确认ID的替换 标签

使用替换标签,你可以创建一个单一的电子邮件模板,但将每个电子邮件收件人的独特数据交换出来。

使用_contactRepo.GetConfirmedContacts 方法分批查询联系人,每批1,000个。然后为被检索的联系人创建一个SendGridMessage 对象。SendGridMessagePersonalizations 属性有助于创建多个电子邮件收件人,还可以使用Substitutions 属性对"-email-""-confirmation-" 做替换。

Personalizations 属性允许每条信息有多达1000个联系人,这可以帮助你向你的订户批量发送电子邮件。

一旦通讯被发送给所有的订阅者,作者就会被重定向到UploadSuccess 页面。

创建Pages/UploadSuccess.cshtml并添加以下代码:

@page
@{
    ViewData["Title"] = "Upload success";
}

<div class="form-container">
    <p>
       Newsletter has been sent to all subscribers
    </p>
</div>

要测试运行dotnet run ,并导航到https://localhost**:/Upload

可以从GitHub repo下载下面格式的样本通讯:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Newsletter Issue #1</title>
</head>
<body>
 Learn about this very interesting news!
 {UnsubscribeLink}
</body>
</html>

在上传表格中填写样本通讯,并验证通讯是否被送到了你的邮箱。

下面是一个订阅者收到的通讯样本:

Email in Gmail with subject "Welcome to First Issue" and body "Learn about this very interesting news!". At the end of the email there"s a blue "Unsubscribe" link.

用电子邮件营销活动发送电子报邮件

作为一个替代自己建立一切的方法,你可以使用电子邮件营销活动 来完成你到目前为止所建立的一切。

一旦登录到SendGrid仪表板,你可以在营销部分导航到注册表格 。在这里,你可以创建用户注册的表格,然后你可以继续与你的用户分享表格的链接,或者将表格嵌入到你的网站中。

对于电子邮件的发送,你可以使用 "营销">"自动化 "选项来设置自动电子邮件,或者使用 "营销">"单一发送 "选项来手动发送电子邮件。你可以使用可视化编辑器设计你的电子邮件,或自己编写HTML代码。这个解决方案还包括退订功能。

市场营销 > 联系人选项可用于建立不同的列表,当用户订阅时,该用户会被添加到一个特定的列表中。该名单可以作为营销活动的目标,也可以根据业务需要忽略不计。

未来的改进

你刚刚建立的解决方案是一个奇妙的开始。然而,你有很多方法可以改进这个应用。

SQLite对本地开发很有帮助,但在功能和性能上是有限的。你可以在保留现有的EF Core代码的情况下,切换到不同的数据库,以满足你的需求。

目前,任何人都可以访问上传页面,并向所有订阅者发送通讯。你应该在Upload页面上添加认证和授权,这样只有被授权的用户才能发送newsletter!

你的解决方案可以向数以千计的订阅者发送电子报,但是,由于两个原因,邮件到达的时间会略有不同:

  1. 如果你有20,000个订阅者,你将调用邮件发送API 20次,因为你每次是按1000个订阅者分页的。当你给这1000个用户排好队的时候,前1000个用户可能已经收到了邮件。
  2. 当你正确调用邮件发送API时,API将返回一个成功的HTTP状态代码。
    然而,这意味着SendGrid已经接受了你的请求,并且邮件被排队。SendGrid将处理它们,并最终将它们发送给收件人。

对于少量的收件人来说,这并不重要,但随着你的订阅者名单的增长,你应该考虑使用SendAt 参数,以获得更好的邮件群发性能,并让你的通讯同时发送给所有的订阅者。

引述SendGrid文档

这种技术允许以一种更有效的方式来分配大型的电子邮件请求,并可以改善整体的邮件交付时间性能,这一功能:

  • 提高了处理和分发大量电子邮件的效率。
  • 减少电子邮件的预处理时间。
  • 使你能够掌握电子邮件到达的时间,以提高打开率。
  • 对所有SendGrid客户都是免费提供的。

你可以在上传页面的表格中添加一个日期字段,这样作者就可以安排通讯邮件的发送时间,同时提高性能和一致性。

总结

通过ASP.NET Core、Entity Framework和SendGrid,你创建了一个简讯应用程序,用于订阅简讯并向大量受众发送简讯邮件。在本教程中,你学会了如何:

  • 验证SendGrid发送器并配置SendGrid API密钥
  • 为用户建立一个表格来注册你的时事通讯并确认他们的订阅
  • 建立一个系统,让用户取消订阅你的新闻通讯。
  • 建立一个你可以上传新闻简报HTML文件的页面。
  • 并学习如何向大量受众群发电子邮件。