Great : )
Hi punta,
Since your solution is tiered, I'll call out where each file goes.
LastName column on AbpUsersIn *.Domain.Shared/YourProjectNameModuleExtensionConfigurator.cs:
using System.ComponentModel.DataAnnotations;
using Volo.Abp.Identity;
using Volo.Abp.Localization;
using Volo.Abp.ObjectExtending;
ObjectExtensionManager.Instance.Modules()
.ConfigureIdentity(identity =>
{
identity.ConfigureUser(user =>
{
user.AddOrUpdateProperty<string>(
"LastName",
property =>
{
property.Attributes.Add(new StringLengthAttribute(64));
property.DisplayName = new FixedLocalizableString("Last Name");
property.UI.OnCreateForm.IsVisible = true;
property.UI.OnEditForm.IsVisible = true;
property.UI.OnTable.IsVisible = true;
property.Configuration[IdentityModuleExtensionConsts.ConfigurationNames.AllowUserToEdit] = true;
});
});
});
In *.EntityFrameworkCore/YourProjectNameEfCoreEntityExtensionMappings.cs:
ObjectExtensionManager.Instance
.MapEfCoreProperty<IdentityUser, string>(
"LastName",
(entityBuilder, propertyBuilder) =>
{
propertyBuilder.HasMaxLength(64);
});
Then Add-Migration Added_LastName_To_AbpUsers + run DbMigrator.
The AllowUserToEdit = true line is what makes LastName automatically render on /Account/Manage (Personal info tab) — no extra code needed for that page.
Surname as "Middle Name" only on the UIThe Surname label is read from two different localization resources:
AccountResource — used on /Account/Manage (Pro Account module)IdentityResource — used on /Identity/Users admin pagesOverride both for the change to be consistent. Don't translate or rename the property in code, just override the localization keys.
Create Localization/AccountOverrides/en.json and Localization/IdentityOverrides/en.json in your *.AuthServer project, both with:
{
"Culture": "en",
"Texts": {
"DisplayName:Surname": "Middle Name"
}
}
Mark them as EmbeddedResource in the .csproj, then in YourProjectNameAuthServerModule.ConfigureServices:
Configure<AbpLocalizationOptions>(options =>
{
options.Resources
.Get<Volo.Abp.Account.Localization.AccountResource>()
.AddVirtualJson("/Localization/AccountOverrides");
options.Resources
.Get<Volo.Abp.Identity.Localization.IdentityResource>()
.AddVirtualJson("/Localization/IdentityOverrides");
});
For other languages just add zh-Hans.json, de.json, etc. in the same folders.
The RegisterModel you'll be subclassing is Volo.Abp.Account.Public.Web.Pages.Account.RegisterModel from the Pro Account module (NuGet package Volo.Abp.Account.Pro.Public.Web). Its default PostInput only contains UserName/EmailAddress/Password/Code — Name and Surname aren't collected during registration out of the box, so you need to extend it.
In *.AuthServer/Pages/Account/Register.cshtml.cs:
using System.ComponentModel.DataAnnotations;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Volo.Abp.Account.ExternalProviders;
using Volo.Abp.Account.Public.Web;
using Volo.Abp.Account.Public.Web.Pages.Account;
using Volo.Abp.Data;
using Volo.Abp.Identity;
using Volo.Abp.Security.Claims;
using IdentityUser = Volo.Abp.Identity.IdentityUser;
namespace YourCompany.YourProject.AuthServer.Pages.Account;
public class CustomRegisterModel : RegisterModel
{
[BindProperty]
public new CustomPostInput Input { get; set; } = new();
public CustomRegisterModel(
IAuthenticationSchemeProvider schemeProvider,
IOptions<AbpAccountOptions> accountOptions,
IAccountExternalProviderAppService accountExternalProviderAppService,
ICurrentPrincipalAccessor currentPrincipalAccessor,
IHttpClientFactory httpClientFactory)
: base(schemeProvider, accountOptions, accountExternalProviderAppService, currentPrincipalAccessor, httpClientFactory)
{
}
public override Task<IActionResult> OnPostAsync()
{
// Sync the extended input back to the base Input so base.OnPostAsync sees UserName/Email/Password/Code.
base.Input = new PostInput
{
UserName = Input.UserName,
EmailAddress = Input.EmailAddress,
Password = Input.Password,
Code = Input.Code
};
return base.OnPostAsync();
}
protected override async Task<IdentityUser> RegisterLocalUserAsync()
{
// Base call creates the user via AccountAppService.RegisterAsync (UserName/Email/Password only).
var user = await base.RegisterLocalUserAsync();
user.Name = Input.Name;
user.Surname = Input.Surname;
user.SetProperty("LastName", Input.LastName);
(await UserManager.UpdateAsync(user)).CheckErrors();
return user;
}
public class CustomPostInput : PostInput
{
[Required]
[StringLength(64)]
public string Name { get; set; }
[StringLength(64)]
public string Surname { get; set; }
[StringLength(64)]
public string LastName { get; set; }
}
}
Then *.AuthServer/Pages/Account/Register.cshtml (copy the original from Volo.Abp.Account.Pro.Public.Web/Pages/Account/Register.cshtml and adjust). The important parts:
@model YourCompany.YourProject.AuthServer.Pages.Account.CustomRegisterModel
...
@* keep the default UserName / Email / Password blocks here *@
@* === Custom fields in the order you want === *@
<div class="form-floating mb-2">
<input asp-for="Input.Name" type="text" class="form-control" placeholder="Name">
<label asp-for="Input.Name">Name</label>
<span asp-validation-for="Input.Name" />
</div>
<div class="form-floating mb-2">
<input asp-for="Input.Surname" type="text" class="form-control" placeholder="Middle Name">
<label asp-for="Input.Surname">Middle Name</label>
<span asp-validation-for="Input.Surname" />
</div>
<div class="form-floating mb-3">
<input asp-for="Input.LastName" type="text" class="form-control" placeholder="Last Name">
<label asp-for="Input.LastName">Last Name</label>
<span asp-validation-for="Input.LastName" />
</div>
ABP's Razor Pages override is path-based: a same-path .cshtml in the AuthServer project takes precedence over the one shipped in Volo.Abp.Account.Pro.Public.Web.
| File | Project |
|---|---|
| ModuleExtensionConfigurator (LastName property) | *.Domain.Shared |
| EfCoreEntityExtensionMappings + Migration | *.EntityFrameworkCore |
| Register.cshtml + Register.cshtml.cs | *.AuthServer |
| AccountOverrides/*.json + IdentityOverrides/*.json + Configure<AbpLocalizationOptions> | *.AuthServer |
Thanks
Hi punta,
Since your solution is tiered, I'll call out where each file goes.
LastName column on AbpUsersIn *.Domain.Shared/YourProjectNameModuleExtensionConfigurator.cs:
using System.ComponentModel.DataAnnotations;
using Volo.Abp.Identity;
using Volo.Abp.Localization;
using Volo.Abp.ObjectExtending;
ObjectExtensionManager.Instance.Modules()
.ConfigureIdentity(identity =>
{
identity.ConfigureUser(user =>
{
user.AddOrUpdateProperty<string>(
"LastName",
property =>
{
property.Attributes.Add(new StringLengthAttribute(64));
property.DisplayName = new FixedLocalizableString("Last Name");
property.UI.OnCreateForm.IsVisible = true;
property.UI.OnEditForm.IsVisible = true;
property.UI.OnTable.IsVisible = true;
property.Configuration[IdentityModuleExtensionConsts.ConfigurationNames.AllowUserToEdit] = true;
});
});
});
In *.EntityFrameworkCore/YourProjectNameEfCoreEntityExtensionMappings.cs:
ObjectExtensionManager.Instance
.MapEfCoreProperty<IdentityUser, string>(
"LastName",
(entityBuilder, propertyBuilder) =>
{
propertyBuilder.HasMaxLength(64);
});
Then Add-Migration Added_LastName_To_AbpUsers + run DbMigrator.
The AllowUserToEdit = true line is what makes LastName automatically render on /Account/Manage (Personal info tab) — no extra code needed for that page.
Surname as "Middle Name" only on the UIThe Surname label is read from two different localization resources:
AccountResource — used on /Account/Manage (Pro Account module)IdentityResource — used on /Identity/Users admin pagesOverride both for the change to be consistent. Don't translate or rename the property in code, just override the localization keys.
Create Localization/AccountOverrides/en.json and Localization/IdentityOverrides/en.json in your *.AuthServer project, both with:
{
"Culture": "en",
"Texts": {
"DisplayName:Surname": "Middle Name"
}
}
Mark them as EmbeddedResource in the .csproj, then in YourProjectNameAuthServerModule.ConfigureServices:
Configure<AbpLocalizationOptions>(options =>
{
options.Resources
.Get<Volo.Abp.Account.Localization.AccountResource>()
.AddVirtualJson("/Localization/AccountOverrides");
options.Resources
.Get<Volo.Abp.Identity.Localization.IdentityResource>()
.AddVirtualJson("/Localization/IdentityOverrides");
});
For other languages just add zh-Hans.json, de.json, etc. in the same folders.
The RegisterModel you'll be subclassing is Volo.Abp.Account.Public.Web.Pages.Account.RegisterModel from the Pro Account module (NuGet package Volo.Abp.Account.Pro.Public.Web). Its default PostInput only contains UserName/EmailAddress/Password/Code — Name and Surname aren't collected during registration out of the box, so you need to extend it.
In *.AuthServer/Pages/Account/Register.cshtml.cs:
using System.ComponentModel.DataAnnotations;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Volo.Abp.Account.ExternalProviders;
using Volo.Abp.Account.Public.Web;
using Volo.Abp.Account.Public.Web.Pages.Account;
using Volo.Abp.Data;
using Volo.Abp.Identity;
using Volo.Abp.Security.Claims;
using IdentityUser = Volo.Abp.Identity.IdentityUser;
namespace YourCompany.YourProject.AuthServer.Pages.Account;
public class CustomRegisterModel : RegisterModel
{
[BindProperty]
public new CustomPostInput Input { get; set; } = new();
public CustomRegisterModel(
IAuthenticationSchemeProvider schemeProvider,
IOptions<AbpAccountOptions> accountOptions,
IAccountExternalProviderAppService accountExternalProviderAppService,
ICurrentPrincipalAccessor currentPrincipalAccessor,
IHttpClientFactory httpClientFactory)
: base(schemeProvider, accountOptions, accountExternalProviderAppService, currentPrincipalAccessor, httpClientFactory)
{
}
public override Task<IActionResult> OnPostAsync()
{
// Sync the extended input back to the base Input so base.OnPostAsync sees UserName/Email/Password/Code.
base.Input = new PostInput
{
UserName = Input.UserName,
EmailAddress = Input.EmailAddress,
Password = Input.Password,
Code = Input.Code
};
return base.OnPostAsync();
}
protected override async Task<IdentityUser> RegisterLocalUserAsync()
{
// Base call creates the user via AccountAppService.RegisterAsync (UserName/Email/Password only).
var user = await base.RegisterLocalUserAsync();
user.Name = Input.Name;
user.Surname = Input.Surname;
user.SetProperty("LastName", Input.LastName);
(await UserManager.UpdateAsync(user)).CheckErrors();
return user;
}
public class CustomPostInput : PostInput
{
[Required]
[StringLength(64)]
public string Name { get; set; }
[StringLength(64)]
public string Surname { get; set; }
[StringLength(64)]
public string LastName { get; set; }
}
}
Then *.AuthServer/Pages/Account/Register.cshtml (copy the original from Volo.Abp.Account.Pro.Public.Web/Pages/Account/Register.cshtml and adjust). The important parts:
@model YourCompany.YourProject.AuthServer.Pages.Account.CustomRegisterModel
...
@* keep the default UserName / Email / Password blocks here *@
@* === Custom fields in the order you want === *@
<div class="form-floating mb-2">
<input asp-for="Input.Name" type="text" class="form-control" placeholder="Name">
<label asp-for="Input.Name">Name</label>
<span asp-validation-for="Input.Name" />
</div>
<div class="form-floating mb-2">
<input asp-for="Input.Surname" type="text" class="form-control" placeholder="Middle Name">
<label asp-for="Input.Surname">Middle Name</label>
<span asp-validation-for="Input.Surname" />
</div>
<div class="form-floating mb-3">
<input asp-for="Input.LastName" type="text" class="form-control" placeholder="Last Name">
<label asp-for="Input.LastName">Last Name</label>
<span asp-validation-for="Input.LastName" />
</div>
ABP's Razor Pages override is path-based: a same-path .cshtml in the AuthServer project takes precedence over the one shipped in Volo.Abp.Account.Pro.Public.Web.
| File | Project |
|---|---|
| ModuleExtensionConfigurator (LastName property) | *.Domain.Shared |
| EfCoreEntityExtensionMappings + Migration | *.EntityFrameworkCore |
| Register.cshtml + Register.cshtml.cs | *.AuthServer |
| AccountOverrides/*.json + IdentityOverrides/*.json + Configure<AbpLocalizationOptions> | *.AuthServer |
Thanks
Hi,
For all three scenarios you described, plain ASP.NET Core attribute routing is enough — no middleware, no PathBase rewriting. Below are three working ways to do it (all standard MVC, pick the one that matches your style).
public class PrivateSiteController : AbpController
{
[HttpGet("@{handle}")]
[HttpGet("@{handle}/{**subpath}")]
public async Task<IActionResult> Index(string handle, string? subpath)
{
var owner = await _privateSiteAppService.FindByHandleAsync(handle);
if (owner == null)
{
return NotFound();
}
return View(new PrivateSiteViewModel(owner, subpath));
}
}
/@Punta hits the first template, /@Punta/posts/42 hits the second (with subpath = "posts/42"). Use {**subpath} (two asterisks) so / characters inside the subpath are preserved instead of URL-encoded — see Route templates → catch-all.
Putting two [HttpGet] attributes on the same action is officially supported, see Multiple attribute routes.
If a private site has fixed sub-pages (e.g. /@Punta, /@Punta/posts/42, /@Punta/about):
[Route("@{handle}")]
public class PrivateSiteController : AbpController
{
[HttpGet] // GET /@Punta
public IActionResult Index(string handle) { /* ... */ return View(); }
[HttpGet("posts/{id:int}")] // GET /@Punta/posts/42
public IActionResult Post(string handle, int id) { /* ... */ return View(); }
[HttpGet("about")] // GET /@Punta/about
public IActionResult About(string handle) { /* ... */ return View(); }
}
Routes on the controller are prepended to routes on each action — see Combining attribute routes. This style plays nicest with link generation: while serving /@Punta/about, calling Url.Action("Post", new { id = 42 }) (or <a asp-action="Post" asp-route-id="42">) automatically produces /@Punta/posts/42, because handle = "Punta" is an ambient route value and the target action's template also has {handle}. You don't have to pass handle manually inside the controller.
Program.csIf you prefer all routes in one place:
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "private-site",
pattern: "@{handle}/{**subpath}",
defaults: new { controller = "PrivateSite", action = "Index" });
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
Then PrivateSiteController is a plain controller with no [Route] attributes — Index(string handle, string? subpath) works as-is. Reference: MapControllerRoute.
If you want bad handles to never reach the action (and instead get a clean 404), add inline constraints:
[HttpGet("@{handle:length(2,32):regex(^[[A-Za-z0-9_.-]]+$)}")]
[HttpGet("@{handle:length(2,32):regex(^[[A-Za-z0-9_.-]]+$)}/{**subpath}")]
length(2,32) enforces 2–32 characters; the regex restricts to letters/digits/_/./-. [[ and ]] are how you escape literal [ ] inside a route template (see Route constraints). Multiple constraints chain with :.
Then existence is still validated inside the action against your store.
Server-side redirect (clean and lets you put validation in one place):
public class HandleLookupController : AbpController
{
[HttpPost("/handle/go")]
[ValidateAntiForgeryToken]
public IActionResult Go(string handle)
{
if (string.IsNullOrWhiteSpace(handle))
{
return RedirectToAction("Index", "Home");
}
return Redirect("/@" + Uri.EscapeDataString(handle.Trim()));
}
}
<form method="post" action="/handle/go">
@Html.AntiForgeryToken()
<input name="handle" placeholder="Enter a handle" />
<button type="submit">Go</button>
</form>
PathBase middleware ideaIt's a real pattern, but it targets a different problem: when each handle should behave like an isolated sub-site where every page, every static asset, every generated URL is rooted under /@handle/... (think GitHub Pages-style username.github.io/...). For that style you do want PathBase = "/@handle" so the framework treats the whole app as if it were hosted there.
For your case — one controller (or a small group of actions) loading the correct user's data based on the handle — that machinery is overkill and brings real downsides:
Request.Path and Request.PathBase for every matching request, which can interact awkwardly with ABP's built-in middlewares (auth, antiforgery, exception handling) and with non-handle routes you also serve from the same host (/Account/Login, /Identity/..., Swagger, health checks, static files)./@Punta/... to /@John) is harder, because link generation under a PathBase of /@Punta will keep prepending /@Punta/ unless you opt out.So my recommendation is: stay with plain attribute routing (Option 1 or 2). If later you grow into a true sub-site model (per-handle theming, per-handle static asset folders, etc.), you can introduce the PathBase middleware then — it's an additive change, not something you need to commit to upfront.
References:
Thanks
Hi,
Good news — for www.website.com/@handle you don't need any middleware or PathBase rewriting. This is plain ASP.NET Core routing. The @ character is not a reserved routing character (only { } [ ] are), so you can use it as a literal directly in a route template. ABP doesn't need to add anything on top of this.
Pick whichever style matches your UI:
Razor Pages (Pages/PrivateSite/Index.cshtml):
@page "/@{handle}/{**subpath?}"
@model PrivateSiteModel
MVC controller:
public class PrivateSiteController : AbpController
{
[HttpGet("@{handle}")]
[HttpGet("@{handle}/{**subpath}")]
public IActionResult Index(string handle, string? subpath)
{
// load the private site for `handle`
return View();
}
}
Minimal API:
app.MapGet("/@{handle}/{**subpath}", (string handle, string? subpath) => ...);
A few things worth knowing:
{**subpath} (two asterisks) is a catch-all that preserves the / separators. {*subpath} (one asterisk) URL-encodes them to %2F, which you usually don't want for a private-site sub-tree. See Route templates → catch-all.@ is just literal text in a route template, so the route /@{handle}/... only matches paths that actually start with /@. Standard routes like /Account/Login, /Identity/..., ABP swagger, etc. are not affected — ASP.NET Core's route precedence considers literal segments more specific than parameter segments. See Route template precedence.handle is fine as a parameter name. Reserved names are only action, area, controller, handler, page — see Reserved routing names.IHttpContextAccessor and read RouteData.Values["handle"], or define a small ICurrentHandle abstraction backed by it — same pattern as ICurrentTenant.About the second form, handle@www.website.com — that one isn't really usable as a routing scheme. In URI syntax (RFC 3986), userinfo@host is the userinfo component, and browsers treat http://handle@www.website.com/ as HTTP Basic auth credentials (the handle part gets sent in the Authorization: Basic ... header, not as part of the path or host). It never reaches your routing layer as something you can dispatch on.
If what you actually want is a subdomain like handle.website.com, that's host-based routing — you'd map the wildcard subdomain in DNS and either filter by Host in middleware/endpoint metadata, or model it the same way ABP's domain-based tenant resolver works (see ABP DomainTenantResolveContributor). Let me know if that's the direction you want and I can share an example.
References:
Thanks
Hi,
A few things worth knowing before you start, because the design choices here matter more than the code.
ABP has two different things and the names are easy to confuse:
IBackgroundJobManager.EnqueueAsync(...) — fire-and-forget, one-time work item. Good for "user just uploaded an image, process this single one in the background."BackgroundWorker — recurring task that runs on a schedule. Good for "scan nightly and process whatever still needs retina versions."Your scenario is the second one. You don't need Quartz, Hangfire, or any extra package for this — the default ABP background worker is enough. Quartz integration only becomes useful if you later need cron expressions, persistent schedules, or cluster-wide single-trigger guarantees across multiple worker hosts. For one host running a periodic scan, skip it.
The two can also be combined: the worker scans for "images needing retina versions" and enqueues a BackgroundJob per image, so multiple jobs can run in parallel and retries are handled per-image. But for a first version, doing the work inline in the worker is fine.
The naive instinct is "create one trigger per timezone." Don't. Instead:
ABP already gives you everything you need for this — no need to build your own timezone table:
TimingSettingNames.TimeZone (key: Abp.Timing.Timezone). Tenant admins can set it from the standard settings UI per tenant.IClock.ConvertToUserTime(DateTime utcNow) converts UTC to the tenant's local time, using ICurrentTimezoneProvider.TimeZone.ICurrentTimezoneProvider.Change(tz) gives you a disposable scope to swap the active timezone inside the worker loop.One requirement: configure the clock to use UTC, otherwise SupportsMultipleTimezone returns false and ConvertToUserTime silently returns the input unchanged:
Configure<AbpClockOptions>(options =>
{
options.Kind = DateTimeKind.Utc;
});
Important — read before flipping this switch. AbpClockOptions.Kind is an application-wide setting, not a worker-local one. The default is Unspecified, which effectively disables the entire ABP Clock/timezone system. Switching to Utc changes how Clock.Now, Clock.Normalize, ConvertToUserTime, and ConvertToUtc behave everywhere in the app — any code that stores, compares, or returns DateTime values is affected. If your app already has data and code written under Unspecified (or Local), audit it before flipping this. Full background, including all the conversion semantics and edge cases:
https://abp.io/docs/latest/framework/infrastructure/timing
public class RetinaImageNightlyWorker : AsyncPeriodicBackgroundWorkerBase
{
public RetinaImageNightlyWorker(
AbpAsyncTimer timer,
IServiceScopeFactory serviceScopeFactory)
: base(timer, serviceScopeFactory)
{
Timer.Period = 30 * 60 * 1000; // 30 minutes
}
protected override async Task DoWorkAsync(PeriodicBackgroundWorkerContext workerContext)
{
var tenantStore = workerContext.ServiceProvider.GetRequiredService<ITenantStore>();
var currentTenant = workerContext.ServiceProvider.GetRequiredService<ICurrentTenant>();
var timezoneProvider = workerContext.ServiceProvider.GetRequiredService<ICurrentTimezoneProvider>();
var settingProvider = workerContext.ServiceProvider.GetRequiredService<ISettingProvider>();
var clock = workerContext.ServiceProvider.GetRequiredService<IClock>();
var processor = workerContext.ServiceProvider.GetRequiredService<IRetinaImageProcessor>();
foreach (var tenant in await tenantStore.GetListAsync(includeDetails: false))
{
using (currentTenant.Change(tenant.Id))
{
var tenantTz = await settingProvider.GetOrNullAsync(TimingSettingNames.TimeZone);
if (tenantTz.IsNullOrWhiteSpace()) continue;
using (timezoneProvider.Change(tenantTz))
{
var localNow = clock.ConvertToUserTime(DateTime.UtcNow);
if (localNow.Hour < 1 || localNow.Hour >= 4) continue; // outside the 01:00-04:00 window
await processor.ProcessPendingImagesAsync(workerContext.CancellationToken);
}
}
}
}
}
A few things to notice:
ICurrentTenant.Change switches the data filter so your repositories see only that tenant's data.ICurrentTimezoneProvider.Change is flow-scoped (AsyncLocal-based), so it's safe inside the loop.ProcessPendingImagesAsync (e.g. 50 images per pass) so a single pass doesn't run for hours and block the next tick.Don't reprocess what you've already done. Add a small piece of state on the image record — a RetinaProcessedAt timestamp, a Status enum, or a hash of the source — and let the worker query for "rows where retina is missing or stale." This also handles failures naturally: a failed image stays "pending" and gets retried on the next pass. Cap the retry count so a permanently broken image doesn't loop forever.
Keep the original and the retina version in whatever storage you already use. ABP's BlobStoring module is convenient if you don't have one yet (it's tenant-aware out of the box), but anything works — your local DB, S3, Azure Blob, file system. Just store enough metadata to locate both versions.
This is where ABP doesn't really opine. "Retina = 2× width and height of the source" is a one-liner in any image library: pick whatever you're comfortable with (a managed library, a native binding, or even shelling out to an external tool). Just keep the resize code behind a thin domain/application service interface (IRetinaImageProcessor above) so the worker stays focused on orchestration and you can swap the underlying library without touching the scheduling logic. One thing to keep in mind: image resize is CPU and memory heavy, so if traffic grows it's worth running the worker in a dedicated host rather than sharing the web app's process.
AbpClockOptions.Kind = DateTimeKind.Utc (required for the timezone helpers to work — but this is application-wide, see the warning in section 2 and the timing docs before changing it on an existing app).Abp.Timing.Timezone setting filled in — otherwise the worker will skip them.Thanks
Hi,
Yes, you can. ITemplateRenderer.RenderAsync is just a string-template renderer — it doesn't care whether the output is HTML, plain text, or a single line for an email subject. So you simply register a second template per event for the subject and render it the same way you render the body.
For the subject template, don't pass a layout — you don't want the HTML layout wrapped around a one-line subject.
public class AppEmailTemplateDefinitionProvider : TemplateDefinitionProvider
{
public override void Define(ITemplateDefinitionContext context)
{
context.Add(new TemplateDefinition(
$"{AppNotificationEvents.UserRegistered}.Subject",
localizationResource: typeof(AppResource))
.WithVirtualFilePath("/Emailing/Templates/UserRegistered.Subject.tpl", isInlineLocalized: true));
context.Add(new TemplateDefinition(
$"{AppNotificationEvents.UserRegistered}.Body",
layout: StandardEmailTemplates.Layout,
localizationResource: typeof(AppResource))
.WithVirtualFilePath("/Emailing/Templates/UserRegistered.Body.tpl", isInlineLocalized: true));
}
}
UserRegistered.Subject.tpl is just one line:
Welcome {{model.user_name}} to {{model.tenant_name}}!
public virtual async Task DispatchEmailAsync(
string eventCode, string to, object model, string? cultureName = null)
{
var enabled = await SettingProvider.GetAsync<bool>($"{eventCode}.EmailEnabled");
if (!enabled)
{
return;
}
var subject = await TemplateRenderer.RenderAsync($"{eventCode}.Subject", model, cultureName);
var body = await TemplateRenderer.RenderAsync($"{eventCode}.Body", model, cultureName);
await EmailSender.QueueAsync(to, subject.Trim(), body);
}
subject.Trim() removes the trailing newline that template engines add to the rendered output — without it, some SMTP servers reject or fold the subject line.
The Text Template Management module lists every registered TemplateDefinition in its UI (it just calls ITemplateDefinitionManager.GetAllAsync() under the hood). So once you register the .Subject template, the host admin sees it next to the .Body template and can edit both independently — no extra configuration, permission, or feature flag needed.
Thanks