Improved newsletter subscribe, welcome and unsubscribe Mails
This commit is contained in:
parent
461ac238ac
commit
17ab022bbe
|
@ -1,7 +1,6 @@
|
||||||
@page "/Email/Unsubscribe"
|
@page "/Email/Unsubscribe"
|
||||||
@using Microsoft.AspNetCore.Identity.UI.Services
|
@using Microsoft.AspNetCore.Identity.UI.Services
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using Microsoft.Extensions.Options
|
|
||||||
@using Wave.Data
|
@using Wave.Data
|
||||||
@using Wave.Services
|
@using Wave.Services
|
||||||
@using Wave.Utilities
|
@using Wave.Utilities
|
||||||
|
@ -9,8 +8,6 @@
|
||||||
@inject ILogger<EmailEdit> Logger
|
@inject ILogger<EmailEdit> Logger
|
||||||
@inject IStringLocalizer<EmailEdit> Localizer
|
@inject IStringLocalizer<EmailEdit> Localizer
|
||||||
@inject IDbContextFactory<ApplicationDbContext> ContextFactory
|
@inject IDbContextFactory<ApplicationDbContext> ContextFactory
|
||||||
@inject IOptions<Customization> Customizations
|
|
||||||
@inject NavigationManager Navigation
|
|
||||||
@inject IEmailSender EmailSender
|
@inject IEmailSender EmailSender
|
||||||
@inject EmailTemplateService TemplateService
|
@inject EmailTemplateService TemplateService
|
||||||
@inject IMessageDisplay Messages
|
@inject IMessageDisplay Messages
|
||||||
|
@ -78,13 +75,7 @@
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
Messages.ShowSuccess(Localizer["Unsubscribe_Success"]);
|
Messages.ShowSuccess(Localizer["Unsubscribe_Success"]);
|
||||||
|
|
||||||
var customization = Customizations.Value;
|
await EmailSender.SendEmailAsync(subscriber.Email, Localizer["ConfirmEmailSubject"], Localizer["Unsubscribe_ConfirmEmailBody"]);
|
||||||
string body = TemplateService.Default(
|
|
||||||
Navigation.BaseUri,
|
|
||||||
!string.IsNullOrWhiteSpace(customization.LogoLink) ? customization.LogoLink : Navigation.ToAbsoluteUri("/img/logo.png").AbsoluteUri,
|
|
||||||
Localizer["Unsubscribe_ConfirmEmailTitle"],
|
|
||||||
Localizer["Unsubscribe_ConfirmEmailBody"]);
|
|
||||||
await EmailSender.SendEmailAsync(subscriber.Email, Localizer["ConfirmEmailSubject"], body);
|
|
||||||
await TemplateService.ValidateTokensAsync(Id!, Token!, "unsubscribe-" + Newsletter); // delete token
|
await TemplateService.ValidateTokensAsync(Id!, Token!, "unsubscribe-" + Newsletter); // delete token
|
||||||
} catch (EmailNotSendException ex) {
|
} catch (EmailNotSendException ex) {
|
||||||
Logger.LogWarning(ex, "Failed to send unsubscribe confirm email. The user has been unsubscribed anyway.");
|
Logger.LogWarning(ex, "Failed to send unsubscribe confirm email. The user has been unsubscribed anyway.");
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
@using Microsoft.Extensions.Options
|
@using Microsoft.Extensions.Options
|
||||||
@using Wave.Data
|
@using Wave.Data
|
||||||
@using System.ComponentModel.DataAnnotations
|
@using System.ComponentModel.DataAnnotations
|
||||||
@using System.Net
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using Wave.Services
|
@using Wave.Services
|
||||||
@using Wave.Utilities
|
@using Wave.Utilities
|
||||||
|
@ -13,10 +12,8 @@
|
||||||
@inject IStringLocalizer<EmailSignup> Localizer
|
@inject IStringLocalizer<EmailSignup> Localizer
|
||||||
@inject IDbContextFactory<ApplicationDbContext> ContextFactory
|
@inject IDbContextFactory<ApplicationDbContext> ContextFactory
|
||||||
@inject IOptions<Features> Features
|
@inject IOptions<Features> Features
|
||||||
@inject IOptions<Customization> Customizations
|
@inject IEmailService EmailService
|
||||||
@inject NavigationManager Navigation
|
@inject EmailFactory Email
|
||||||
@inject IAdvancedEmailSender EmailSender
|
|
||||||
@inject EmailTemplateService TemplateService
|
|
||||||
@inject IMessageDisplay Messages
|
@inject IMessageDisplay Messages
|
||||||
|
|
||||||
<PageTitle>@(TitlePrefix + Localizer["Title"])</PageTitle>
|
<PageTitle>@(TitlePrefix + Localizer["Title"])</PageTitle>
|
||||||
|
@ -57,7 +54,7 @@
|
||||||
|
|
||||||
if (Id is null || Token is null) return;
|
if (Id is null || Token is null) return;
|
||||||
try {
|
try {
|
||||||
var id = await TemplateService.ValidateTokensAsync(Id, Token, deleteToken: false);
|
var id = await Email.IsTokenValid(Id, Token);
|
||||||
|
|
||||||
if (id is null) {
|
if (id is null) {
|
||||||
Messages.ShowError(Localizer["Failure_Message"]);
|
Messages.ShowError(Localizer["Failure_Message"]);
|
||||||
|
@ -74,16 +71,24 @@
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
Messages.ShowSuccess(Localizer["Success_Message"]);
|
Messages.ShowSuccess(Localizer["Success_Message"]);
|
||||||
|
|
||||||
await TemplateService.ValidateTokensAsync(Id, Token, deleteToken: true);
|
await Email.ClearToken(Id, Token);
|
||||||
|
|
||||||
var articles = await context.Set<EmailNewsletter>()
|
var articles = await context.Set<EmailNewsletter>()
|
||||||
.IgnoreAutoIncludes()
|
.IgnoreAutoIncludes().IgnoreQueryFilters().Where(n => n.IsSend)
|
||||||
.Include(a => a.Article).ThenInclude(a => a.Author)
|
.Include(a => a.Article).ThenInclude(a => a.Author)
|
||||||
.OrderByDescending(a => a.DistributionDateTime)
|
.OrderByDescending(a => a.DistributionDateTime)
|
||||||
.Take(3)
|
.Take(3)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
await EmailSender.SendWelcomeMailAsync(subscriber,
|
var mail = await Email.CreateWelcomeEmail(
|
||||||
Localizer["WelcomeEmailSubject"], Localizer["WelcomeEmailTitle"], Localizer["WelcomeEmailBody"], articles);
|
subscriber, articles,
|
||||||
|
Localizer["WelcomeEmailSubject"],
|
||||||
|
Localizer["WelcomeEmailTitle"],
|
||||||
|
Localizer["WelcomeEmailBody"],
|
||||||
|
Localizer["WelcomeEmailBody"]);
|
||||||
|
|
||||||
|
await EmailService.ConnectAsync(CancellationToken.None);
|
||||||
|
await EmailService.SendEmailAsync(mail);
|
||||||
|
await EmailService.DisconnectAsync(CancellationToken.None);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Logger.LogError(ex, "Error trying to confirm subscriber.");
|
Logger.LogError(ex, "Error trying to confirm subscriber.");
|
||||||
Messages.ShowError(Localizer["Failure_Message"]);
|
Messages.ShowError(Localizer["Failure_Message"]);
|
||||||
|
@ -110,15 +115,19 @@
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
if (subscriber.Unsubscribed) {
|
if (subscriber.Unsubscribed) {
|
||||||
(string id, string token) = await TemplateService.CreateConfirmTokensAsync(subscriber.Id);
|
var email = await Email.CreateConfirmationEmail(subscriber,
|
||||||
|
Localizer["ConfirmEmailSubject"],
|
||||||
|
Localizer["ConfirmEmailTitle"],
|
||||||
|
Localizer["ConfirmEmailBody"],
|
||||||
|
Localizer["ConfirmEmailBody"],
|
||||||
|
Localizer["Submit"]);
|
||||||
|
|
||||||
string confirmLink = Navigation.ToAbsoluteUri(
|
await EmailService.ConnectAsync(CancellationToken.None);
|
||||||
$"/Email/Confirm?user={WebUtility.UrlEncode(id)}&token={WebUtility.UrlEncode(token)}").AbsoluteUri;
|
await EmailService.SendEmailAsync(email);
|
||||||
string body = string.Format(Localizer["ConfirmEmailBody"], Customizations.Value.AppName) +
|
await EmailService.DisconnectAsync(CancellationToken.None);
|
||||||
$"""<p style="text-align: center"><a href="{confirmLink}">{Localizer["Submit"]}</a></p>""";
|
|
||||||
await EmailSender.SendDefaultMailAsync(subscriber.Email, subscriber.Name, Localizer["ConfirmEmailSubject"],
|
|
||||||
Localizer["ConfirmEmailTitle"], body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Model = new();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Logger.LogError(ex, "Failed to create subscriber/send confirmation mail.");
|
Logger.LogError(ex, "Failed to create subscriber/send confirmation mail.");
|
||||||
}
|
}
|
||||||
|
|
|
@ -149,7 +149,6 @@
|
||||||
if (emailConfig.Smtp.Keys.Any(k => k.Equals("live", StringComparison.CurrentCultureIgnoreCase))) {
|
if (emailConfig.Smtp.Keys.Any(k => k.Equals("live", StringComparison.CurrentCultureIgnoreCase))) {
|
||||||
builder.Services.AddScoped(sp => sp.GetKeyedService<IEmailService>("live")!);
|
builder.Services.AddScoped(sp => sp.GetKeyedService<IEmailService>("live")!);
|
||||||
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
|
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
|
||||||
builder.Services.AddScoped<IAdvancedEmailSender, SmtpEmailSender>();
|
|
||||||
builder.Services.AddScoped<IEmailSender<ApplicationUser>, SmtpEmailSender>();
|
builder.Services.AddScoped<IEmailSender<ApplicationUser>, SmtpEmailSender>();
|
||||||
} else {
|
} else {
|
||||||
builder.Services.AddSingleton<IEmailSender<ApplicationUser>, IdentityNoOpEmailSender>();
|
builder.Services.AddSingleton<IEmailSender<ApplicationUser>, IdentityNoOpEmailSender>();
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
|
using StackExchange.Redis;
|
||||||
using Wave.Data;
|
using Wave.Data;
|
||||||
using Wave.Utilities;
|
using Wave.Utilities;
|
||||||
|
|
||||||
|
@ -20,6 +21,20 @@ public class EmailFactory(IOptions<Customization> customizations, EmailTemplateS
|
||||||
return new StaticEmail(receiverMail, receiverName, subject, title, body, $"{title}\n\n{bodyPlain}", FrozenDictionary<string, string>.Empty);
|
return new StaticEmail(receiverMail, receiverName, subject, title, body, $"{title}\n\n{bodyPlain}", FrozenDictionary<string, string>.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async ValueTask<IEmail> CreateConfirmationEmail(EmailSubscriber subscriber, string subject, string title, string bodyHtml, string bodyPlain = "", string confirmLabel = "confirm") {
|
||||||
|
(string user, string token) = await TemplateService.CreateConfirmTokensAsync(subscriber.Id);
|
||||||
|
(string? host, string _) = GetStaticData();
|
||||||
|
string confirmLink = new Uri(
|
||||||
|
new Uri(host, UriKind.Absolute),
|
||||||
|
new Uri($"/Email/Confirm?user={WebUtility.UrlEncode(user)}&token={WebUtility.UrlEncode(token)}", UriKind.Relative))
|
||||||
|
.AbsoluteUri;
|
||||||
|
|
||||||
|
return await CreateDefaultEmail(subscriber.Email, subscriber.Name, subject, title,
|
||||||
|
string.Format(bodyHtml, Customizations.AppName)
|
||||||
|
+ $"""<p style="text-align: center"><a href="{confirmLink}">{confirmLabel}</a></p>""",
|
||||||
|
string.Format(bodyPlain, Customizations.AppName) + $"\n\n{confirmLabel}: {confirmLink}");
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask<IEmail> CreateSubscribedEmail(EmailSubscriber subscriber, string browserLink, string subject, string title, string bodyHtml, string bodyPlain = "", string role = "unknown", string? replyTo = null) {
|
public async ValueTask<IEmail> CreateSubscribedEmail(EmailSubscriber subscriber, string browserLink, string subject, string title, string bodyHtml, string bodyPlain = "", string role = "unknown", string? replyTo = null) {
|
||||||
(string host, string logo) = GetStaticData();
|
(string host, string logo) = GetStaticData();
|
||||||
|
|
||||||
|
@ -44,16 +59,19 @@ public class EmailFactory(IOptions<Customization> customizations, EmailTemplateS
|
||||||
string articlePartial = await TemplateService.GetPartialAsync("email-article");
|
string articlePartial = await TemplateService.GetPartialAsync("email-article");
|
||||||
string footer = await TemplateService.GetPartialAsync("email-plain-footer");
|
string footer = await TemplateService.GetPartialAsync("email-plain-footer");
|
||||||
var articlesHtml = new StringBuilder("");
|
var articlesHtml = new StringBuilder("");
|
||||||
|
var articlesPlain = new StringBuilder("");
|
||||||
foreach (var n in articles) {
|
foreach (var n in articles) {
|
||||||
string articleLink = ArticleUtilities.GenerateArticleLink(n.Article, new Uri(Customizations.AppUrl, UriKind.Absolute));
|
string articleLink = ArticleUtilities.GenerateArticleLink(n.Article, new Uri(Customizations.AppUrl, UriKind.Absolute));
|
||||||
articlesHtml.AppendFormat(
|
articlesHtml.AppendFormat(
|
||||||
articlePartial,
|
articlePartial,
|
||||||
n.Article.Title, n.Article.Author.Name, n.Article.Body[..Math.Min(250, n.Article.Body.Length)], articleLink);
|
n.Article.Title, n.Article.Author.Name, n.Article.Body[..Math.Min(250, n.Article.Body.Length)], articleLink);
|
||||||
|
articlesPlain.AppendFormat("{0}\n\n{1}\n{2}\n{3}",
|
||||||
|
n.Article.Title, n.Article.Author.Name, n.Article.Body[..Math.Min(250, n.Article.Body.Length)], articleLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
string unsubscribeLink = await GetUnsubscribeLink(host, subscriber.Id, "welcome");
|
string unsubscribeLink = await GetUnsubscribeLink(host, subscriber.Id, "welcome");
|
||||||
string body = TemplateService.Welcome(host, logo, title, bodyHtml, unsubscribeLink, articlesHtml.ToString());
|
string body = TemplateService.Welcome(host, logo, title, bodyHtml, unsubscribeLink, articlesHtml.ToString());
|
||||||
bodyPlain += "\n" + HtmlUtilities.GetPlainText(articlesHtml.ToString());
|
bodyPlain += "\n\n\n" + articlesPlain;
|
||||||
bodyPlain += "\n\n" + footer.Replace(
|
bodyPlain += "\n\n" + footer.Replace(
|
||||||
$"[[{EmailTemplateService.Constants.EmailUnsubscribeLink}]]",
|
$"[[{EmailTemplateService.Constants.EmailUnsubscribeLink}]]",
|
||||||
unsubscribeLink, true, CultureInfo.InvariantCulture);
|
unsubscribeLink, true, CultureInfo.InvariantCulture);
|
||||||
|
@ -64,6 +82,14 @@ public class EmailFactory(IOptions<Customization> customizations, EmailTemplateS
|
||||||
}.ToFrozenDictionary());
|
}.ToFrozenDictionary());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Guid?> IsTokenValid(string id, string token) {
|
||||||
|
return await TemplateService.ValidateTokensAsync(id, token, deleteToken: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask ClearToken(string id, string token) {
|
||||||
|
await TemplateService.ValidateTokensAsync(id, token, deleteToken: true);
|
||||||
|
}
|
||||||
|
|
||||||
private (string host, string logo) GetStaticData() {
|
private (string host, string logo) GetStaticData() {
|
||||||
var host = new Uri(string.IsNullOrWhiteSpace(Customizations.AppUrl) ? "" : Customizations.AppUrl); // TODO get link
|
var host = new Uri(string.IsNullOrWhiteSpace(Customizations.AppUrl) ? "" : Customizations.AppUrl); // TODO get link
|
||||||
string logo = !string.IsNullOrWhiteSpace(Customizations.LogoLink)
|
string logo = !string.IsNullOrWhiteSpace(Customizations.LogoLink)
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
|
||||||
using Wave.Data;
|
|
||||||
|
|
||||||
namespace Wave.Services;
|
|
||||||
|
|
||||||
[Obsolete]
|
|
||||||
public interface IAdvancedEmailSender : IEmailSender {
|
|
||||||
Task SendEmailAsync(string email, string? name, string subject, string htmlMessage);
|
|
||||||
Task SendDefaultMailAsync(string receiverMail, string? receiverName, string subject, string title, string bodyHtml);
|
|
||||||
Task SendSubscribedMailAsync(EmailSubscriber subscriber, string subject, string title, string bodyHtml,
|
|
||||||
string browserUrl = "", string subscribedRole = "-1");
|
|
||||||
Task SendWelcomeMailAsync(EmailSubscriber subscriber, string subject, string title, string bodyHtml,
|
|
||||||
IEnumerable<EmailNewsletter> articles);
|
|
||||||
}
|
|
|
@ -1,11 +1,11 @@
|
||||||
using Azure.Core;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity.UI.Services;
|
||||||
using Wave.Data;
|
using Wave.Data;
|
||||||
using Wave.Utilities;
|
using Wave.Utilities;
|
||||||
|
|
||||||
namespace Wave.Services;
|
namespace Wave.Services;
|
||||||
|
|
||||||
public class SmtpEmailSender(EmailFactory email, [FromKeyedServices("live")]IEmailService emailService, [FromKeyedServices("bulk")]IEmailService bulkEmailService) : IEmailSender<ApplicationUser>, IAdvancedEmailSender, IAsyncDisposable {
|
public class SmtpEmailSender(EmailFactory email, [FromKeyedServices("live")]IEmailService emailService, [FromKeyedServices("bulk")]IEmailService bulkEmailService) : IEmailSender<ApplicationUser>, IEmailSender, IAsyncDisposable {
|
||||||
private EmailFactory Email { get; } = email;
|
private EmailFactory Email { get; } = email;
|
||||||
private IEmailService EmailService { get; } = emailService;
|
private IEmailService EmailService { get; } = emailService;
|
||||||
private IEmailService BulkEmailService { get; } = bulkEmailService;
|
private IEmailService BulkEmailService { get; } = bulkEmailService;
|
||||||
|
@ -32,24 +32,11 @@ public class SmtpEmailSender(EmailFactory email, [FromKeyedServices("live")]IEma
|
||||||
#region IEmailSender
|
#region IEmailSender
|
||||||
|
|
||||||
public Task SendEmailAsync(string email, string subject, string htmlMessage) {
|
public Task SendEmailAsync(string email, string subject, string htmlMessage) {
|
||||||
return SendEmailAsync(email, null, subject, htmlMessage);
|
return SendDefaultMailAsync(email, null, subject, subject, htmlMessage, HtmlUtilities.GetPlainText(htmlMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
public async Task SendEmailAsync(string email, string? name, string subject, string htmlMessage) {
|
|
||||||
await EmailService.ConnectAsync(CancellationToken.None);
|
|
||||||
await EmailService.SendEmailAsync(await Email.CreateDefaultEmail(email, name, subject, subject, htmlMessage, HtmlUtilities.GetPlainText(htmlMessage)));
|
|
||||||
await EmailService.DisconnectAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SendDefaultMailAsync(string receiverMail, string? receiverName, string subject, string title, string bodyHtml) {
|
|
||||||
await EmailService.ConnectAsync(CancellationToken.None);
|
|
||||||
var email = await Email.CreateDefaultEmail(receiverMail, receiverName, subject, title, bodyHtml, HtmlUtilities.GetPlainText(bodyHtml));
|
|
||||||
await EmailService.SendEmailAsync(email);
|
|
||||||
await EmailService.DisconnectAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
public async Task SendDefaultMailAsync(string receiverMail, string? receiverName, string subject, string title, string bodyHtml, string bodyPlain) {
|
public async Task SendDefaultMailAsync(string receiverMail, string? receiverName, string subject, string title, string bodyHtml, string bodyPlain) {
|
||||||
await EmailService.ConnectAsync(CancellationToken.None);
|
await EmailService.ConnectAsync(CancellationToken.None);
|
||||||
var email = await Email.CreateDefaultEmail(receiverMail, receiverName, subject, title, bodyHtml, bodyPlain);
|
var email = await Email.CreateDefaultEmail(receiverMail, receiverName, subject, title, bodyHtml, bodyPlain);
|
||||||
|
@ -57,20 +44,6 @@ public class SmtpEmailSender(EmailFactory email, [FromKeyedServices("live")]IEma
|
||||||
await EmailService.DisconnectAsync(CancellationToken.None);
|
await EmailService.DisconnectAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendSubscribedMailAsync(EmailSubscriber subscriber, string subject, string title, string bodyHtml,
|
|
||||||
string browserUrl = "", string subscribedRole = "-1") {
|
|
||||||
var email = await Email.CreateSubscribedEmail(subscriber, browserUrl, subject, title, bodyHtml, HtmlUtilities.GetPlainText(bodyHtml), subscribedRole);
|
|
||||||
await BulkEmailService.ConnectAsync(CancellationToken.None);
|
|
||||||
await BulkEmailService.SendEmailAsync(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SendWelcomeMailAsync(EmailSubscriber subscriber, string subject, string title, string bodyHtml,
|
|
||||||
IEnumerable<EmailNewsletter> articles) {
|
|
||||||
var email = await Email.CreateWelcomeEmail(subscriber, articles, subject, title, bodyHtml, HtmlUtilities.GetPlainText(bodyHtml));
|
|
||||||
await BulkEmailService.ConnectAsync(CancellationToken.None);
|
|
||||||
await BulkEmailService.SendEmailAsync(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() {
|
public async ValueTask DisposeAsync() {
|
||||||
GC.SuppressFinalize(this);
|
GC.SuppressFinalize(this);
|
||||||
await EmailService.DisposeAsync();
|
await EmailService.DisposeAsync();
|
||||||
|
|
Loading…
Reference in a new issue