Skip to main content

Emails. General recommendations

· 2 min read
Siarhei Skalaban
Siarhei Skalaban
Maintainer of StarterKit

Transactional emails such as password resets, account confirmations, or security alerts must reach the inbox quickly. Yet many companies see these messages flagged as “suspicious” or filtered into spam because of avoidable mistakes in email design and configuration. Below are five practical recommendations to improve deliverability and reduce false positives in systems like SpamAssassin.

1. Always Provide a Plain-Text Version

Spam filters penalize emails that contain only HTML. Even if your design is clean, missing a text/plain alternative suggests a marketing blast or phishing attempt.

Use multipart/alternative and include a simple plain-text body.

Keep it short, direct, and readable without styling.

2. Host Images on Your Own Domain

External image hosts (for example, blob storage or generic CDNs) can trigger phishing rules. Filters expect brand consistency between sender domain and hosted resources.

Store logos and assets on your company’s main domain or a branded CDN.

Avoid linking to images on third-party blob storage.

Links with long random strings or hex tokens are common in password reset flows, but filters can flag them as suspicious.

Use shorter, branded paths such as /rp/abcd1234 and resolve the token server-side.

Ensure all links are HTTPS and under your primary domain.

4. Balance Text and Design

Emails that are mostly layout, buttons, or images with little real text resemble spam campaigns.

Add explanatory sentences about why the email was sent.

Include a fallback option: e.g., “If the button doesn’t work, copy and paste this link.”

Sign off with company details or support contact information.

5. Avoid External Fonts

Custom fonts (Google Fonts, etc.) are often blocked and can raise spam suspicion.

Stick with system fonts like Arial, Helvetica, or sans-serif.

This keeps your email lightweight and compatible across clients.

Paubox Configuration

· One min read
Siarhei Skalaban
Siarhei Skalaban
Maintainer of StarterKit

Required

Additional steps

DMARC

DMARC helps prevent spoofing of your domain and builds trust with mailbox providers. It allows you to enforce alignment of SPF and DKIM, giving you control over how unauthenticated messages are handled.

List-Unsubscribe Header

Adding a List-Unsubscribe header makes it easier for users to safely opt out of emails. Major providers (like Gmail and Outlook) use this to display a clear “unsubscribe” option, which reduces spam complaints.

BIMI (Brand Indicators for Message Identification)

BIMI lets you display your official logo next to authenticated emails. While not a direct deliverability booster, it increases brand recognition and signals trust when combined with DMARC enforcement.

Recommendations

Read Emails. General Recommendations

Dates. Without Pain

· 5 min read
Siarhei Skalaban
Siarhei Skalaban
Maintainer of StarterKit

In this post, we’ll briefly cover how we handle dates and datetimes in our system.

As most developers know, the key difference between a date and a datetime is that a date does not represent a fixed point in time. Its meaning depends on the timezone — the same date may correspond to different moments around the world.

To avoid ambiguity, we store all dates in our system in the format 03/02/2024T00:00:00.000+0000, which means midnight UTC. We may eventually migrate to a DateOnly type, but that time hasn’t come yet.

We also aim to spare the server from the pain of handling timezones whenever possible. That’s why virtually everything is stored in UTC, and we stick with UTC even on the frontend — until we get to the point of rendering. Only display components are allowed to localize values.

Another important convention in our system is field naming. We follow these rules:

  • CreatedAt: a specific point in time (date + time) when something was created
  • CreatedOn: a calendar date (without time) when something was created
  • CreatedDate: alias for CreatedOn

So, let’s break down what to use and when.

1. I want to add a form with a date and time field.

Let’s say we need to add a Task entity that has a field representing the date and time when it was performed. Since this is a datetime, we’ll name the field performedAt.

const TASK_INITIAL_FORM_VALUE = {
/**
* Let's say this field should be initialized with the current date and time.
* Using `startOf('minute')` is a good practice, as it prevents invisible seconds and milliseconds
* from being saved into the value.
*/
performedAt: dayjs().tzDefault().startOf('minute').utc(),
};

function TaskForm() {
return (
<Form {...form}>
<Form.Item name="performedAt">
{/*
* We use Form.DatePicker because we need it to store both date and time.
* We don’t need to worry about timezones — it's handled automatically.
*/}
<Form.DatePicker >
</Form.Item>
</Form>
);
}

function TaskTable() {
const columns: TableColumnType<Task> = [
{
key: 'performedAt',
filterable: {
type: 'date-range',
/**
* This is an important detail: we need to tell the filter that the value is a **date-time**
* so it can filter correctly.
* When the user selects 20/03/2024 - 25/03/2024 (in Europe/London),
* it should return all records where `performedAt` is within the range
* 19/03/2024T23:00:00Z - 25/03/2024T23:00:00Z (+1 day for a tasks performed 25/03, e.g. 25/03 17:30).
*/
valueType: 'date-time',
by: (item) => item.performedAt,
},
// render as date
render: (_, { performedAt }) => formatters.date(performedAt),
// render as datetime
render: (_, { performedAt }) => formatters.datetime(performedAt),
}
];

return <Table columns={columns} />;
}

// Let's take a look at the implementation of a custom filter form.
function TaskFilterForm() {
return (
<Form {...form}>
<Form.Item name="performedAt">
{/**
* This is an important detail: we need the DateRange to treat the value as a **datetime**.
* When the user selects 20/03/2024 - 25/03/2024 (in Europe/London),
* it should be recorded as 19/03/2024T23:00:00Z - 24/03/2024T23:00:00Z.
*/}
<Form.DateRange valueType="date-time" />
</Form.Item>
</Form>
);
}

// Now we can convert this into a FilterRule for server-side filtering.
function useData(filters: TaskFilterFormValue) {
return useTaskQuery({
filterRule: {
operator: 'and',
children: [
{ fieldId: 'performedAt', operator: '>=', value: filters.performedAt[0].toISOString() },
// We add one day because if the user selects 29/03,
// the result should include tasks performed on 29/03 — e.g., 29/03 at 17:30.
{ fieldId: 'performedAt', operator: '<', value: filters.performedAt[1].add(1, 'day').toISOString() },
],
}
})
}

2. I want to add a form with a date field

Let’s say we need to add a Task entity that has a field representing the date when it’s due. Since this is a date (not a date-time), we’ll name the field dueDate.

const TASK_INITIAL_FORM_VALUE = {
/**
* Let's say this field should be initialized with the current date.
* As you can see, we take the current date in the local timezone,
* then convert it to UTC while preserving the calendar day.
* So if it's 22/03/2024 00:30 in Europe/London,
* we get 22/03/2024T00:00:00Z — even though it's 21/03/2024 23:30 UTC.
*/
dueDate: dayjs().tzDefault().startOf('date').utc(true),
};

function TaskForm() {
return (
<Form {...form}>
<Form.Item name="dueDate">
{/*
* We use `Form.Date` because we need it to store a **date only**,
* completely independent of any timezone. (e.g. 20/03/2024T00:00:00Z)
*/}
<Form.Date />
</Form.Item>
</Form>
);
}

function TaskTable() {
const columns: TableColumnType<Task> = [
{
key: 'dueDate',
filterable: {
type: 'date-range',
/**
* This is an important detail: we need to tell the filter that the value is a **date only**,
* so it can filter correctly.
* When the user selects 20/03/2024 - 25/03/2024 (in Europe/London),
* it should return all records where `dueDate` is in the range
* 20/03/2024T00:00:00Z - 25/03/2024T00:00:00Z — with no time conversions applied.
*/
valueType: 'date',
by: (item) => item.dueDate,
},
// formatters.date won't work here because it converts the time to the current timezone,
// which we don't want — it can cause a "date shift".
render: (_, { dueDate }) =>
dayjs(dueDate).tzDefault().format(DAYJS_FORMAT.DATE),
},
];

return <Table columns={columns} />;
}

// Let's take a look at the implementation of a custom filter form.
function TaskFilterForm() {
return (
<Form {...form}>
<Form.Item name="dueDate">
{/**
* This is an important detail: we need the DateRange to interpret the value as a **date**.
* When the user selects 20/03/2024 - 25/03/2024 (in Europe/London),
* it should record exactly 20/03/2024T00:00:00Z - 25/03/2024T00:00:00Z,
* without any timezone shifts.
*/}
<Form.DateRange valueType="date" />
</Form.Item>
</Form>
);
}

// Now we can convert this into a FilterRule for server-side filtering.
function useData(filters: TaskFilterFormValue) {
return useTaskQuery({
filterRule: {
operator: 'and',
children: [
{
fieldId: 'dueDate',
operator: '>=',
value: filters.performedAt[0].toISOString(),
},
// Unlike datetime fields, here we don't add a day — we use `<=` instead of `<`
{
fieldId: 'dueDate',
operator: '<=',
value: filters.performedAt[1].toISOString(),
},
],
},
});
}