Activities of "maliming"

Great : )

Hi punta,

Since your solution is tiered, I'll call out where each file goes.

1. Add a physical LastName column on AbpUsers

In *.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.

2. Show Surname as "Middle Name" only on the UI

The Surname label is read from two different localization resources:

  • AccountResource — used on /Account/Manage (Pro Account module)
  • IdentityResource — used on /Identity/Users admin pages

Override 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.

3. Reorder Register page fields: Name → Middle Name (Surname) → Last Name

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.

Quick recap on file locations (tiered)

| 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.

1. Add a physical LastName column on AbpUsers

In *.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.

2. Show Surname as "Middle Name" only on the UI

The Surname label is read from two different localization resources:

  • AccountResource — used on /Account/Manage (Pro Account module)
  • IdentityResource — used on /Identity/Users admin pages

Override 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.

3. Reorder Register page fields: Name → Middle Name (Surname) → Last Name

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.

Quick recap on file locations (tiered)

| 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).

Option 1 — Single action, two route templates (simplest)

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.

Option 2 — Controller-level route prefix + multiple actions

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.

Option 3 — Conventional route in Program.cs

If 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.

Optional — restrict invalid handles at the routing layer

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.

Scenario 2 — the "enter a handle and go" form

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>

About the AI's PathBase middleware idea

It'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:

  • It rewrites 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).
  • Cross-handle navigation (your Scenario 2 — jumping from /@Punta/... to /@John) is harder, because link generation under a PathBase of /@Punta will keep prepending /@Punta/ unless you opt out.
  • It's an extra moving part to maintain, with no benefit over attribute routing for what you described.

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.
  • To resolve the handle from anywhere in your app code, inject 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.

1) Pick the right ABP primitive: Worker, not Job

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.

2) Don't try to express "night in every timezone" as a cron

The naive instinct is "create one trigger per timezone." Don't. Instead:

  • Run the worker frequently (every 15–30 minutes).
  • For each tenant, compute "what's the local time for this tenant right now?" and only process if it falls inside your night window (e.g. 01:00–04:00).

ABP already gives you everything you need for this — no need to build your own timezone table:

  • The framework ships a built-in setting 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

3) The worker skeleton

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.
  • Process in batches inside ProcessPendingImagesAsync (e.g. 50 images per pass) so a single pass doesn't run for hours and block the next tick.

4) Make it idempotent

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.

5) Storage

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.

6) The image processing itself

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.

7) Things to do up front

  • Set 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).
  • Make sure each tenant has the Abp.Timing.Timezone setting filled in — otherwise the worker will skip them.
  • Decide on your night window (e.g. 01:00–04:00 local) and your batch size early, since they drive how long a pass takes.
  • If you ever scale to multiple worker hosts, switch to the Quartz integration with a SQL Server jobStore and clustering — that's the only way to guarantee one trigger across the cluster. But on day one, the default worker on a single host is the simpler answer.

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.

Register a Subject template alongside the Body template

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}}!

Render both in the dispatcher

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.

Admin UI behavior

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

👍

hi

Can you try this?

Thanks.

Showing 531 to 540 of 12926 entries
Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.8.0-preview. Updated on November 02, 2026, 06:56
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.