This document explains the full Deno Mailer usage flow, from SMTP configuration to advanced message features like attachments, embedded images, calendar invites, and troubleshooting.
- Installation
- Configuration
- Basic Usage
- Advanced Features
- API Reference
- Provider Examples
- Troubleshooting
# Install package from JSR.
deno add jsr:@neabyte/deno-mailerImport the mailer from the same specifier your project uses for JSR (after deno add, the import map entry is usually @neabyte/deno-mailer):
import { mailer } from 'jsr:@neabyte/deno-mailer'
// or: import { mailer } from '@neabyte/deno-mailer'Types ship with the package. Use import type { EmailMessage, SmtpConnectionConfig, SmtpSendResult } from 'jsr:@neabyte/deno-mailer' when you only need types.
Tip
After deno add, prefer the import map key (often @neabyte/deno-mailer) so paths stay short and consistent.
// Define SMTP host, port, and auth.
const config = {
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
type: 'password',
user: 'your-email@gmail.com',
pass: 'your-app-password'
}
}Deno Mailer supports two explicit authentication modes:
type: 'password'using LOGIN with PLAIN fallbacktype: 'oauth2'using XOAUTH2 bearer token
// Password authentication object.
auth: {
type: 'password',
user: 'username',
pass: 'password'
}
// OAuth2 authentication object.
auth: {
type: 'oauth2',
user: 'username',
accessToken: 'access-token-value'
}
// For local SMTP without auth, omit `auth`.Important
When you pass auth, you must set type to 'password' or 'oauth2' and include the matching fields (pass or accessToken). Implicit auth objects are not supported.
// Enable DKIM signing with selector and private key.
const transporter = mailer.transporter({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
type: 'password',
user: 'your-email@gmail.com',
pass: 'your-app-password'
},
dkim: {
domainName: 'example.com',
keySelector: 'mail',
privateKey: Deno.env.get('DKIM_PRIVATE_KEY') || ''
}
})Optional dkim.headerFieldNames lists which message headers are included in the DKIM h= tag (use lowercase names). If you omit it, the default set is from, to, subject, date, message-id, mime-version, content-type.
Warning
If you set dkim, validation requires non-empty domainName, keySelector, and privateKey. An empty privateKey (for example from a missing env var) fails at transporter creation.
// Reuse SMTP clients across sends with bounded pool config.
const transporter = mailer.transporter({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
type: 'password',
user: 'your-email@gmail.com',
pass: 'your-app-password'
},
pool: {
maxConnections: 2,
maxMessagesPerConnection: 100,
idleTimeoutMs: 60000
}
})When secure is omitted, it behaves like false (plain TCP first, then optional STARTTLS).
With secure: false, the client opens a plain TCP connection, sends EHLO, then:
- If the server advertises
STARTTLS, the client upgrades to TLS and sendsEHLOagain (this applies to any port where the server offersSTARTTLS, not only 587). - On port 587, the server must advertise
STARTTLS. If it does not, the client throws an error.
With secure: true, the connection uses TLS from the first byte (typical for port 465). Use this for implicit TLS, not for port 587 submission with STARTTLS.
Warning
If 587 fails with a STARTTLS error, confirm the server advertises STARTTLS after EHLO. Some setups expect port 465 with secure: true (implicit TLS) instead.
// Port 587: plain connect, then mandatory STARTTLS upgrade after EHLO.
{
host: 'smtp.gmail.com',
port: 587,
secure: false
}
// Port 465: TLS from connect.
{
host: 'smtp.gmail.com',
port: 465,
secure: true
}// Send plain text email and inspect delivery result.
const result = await transporter.send({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Hello World',
text: 'This is a plain text email'
})
console.log(result.acceptedRecipients)// Send HTML email body.
await transporter.send({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'HTML Email',
html: '<h1>Hello!</h1><p>This is an <strong>HTML</strong> email.</p>'
})// Send text and HTML alternatives together.
await transporter.send({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Mixed Content',
text: 'This is the plain text version',
html: '<h1>This is the HTML version</h1>'
})// Use to, cc, bcc, and replyTo fields.
await transporter.send({
from: 'sender@example.com',
to: ['user1@example.com', 'user2@example.com'],
cc: 'manager@example.com',
bcc: 'admin@example.com',
replyTo: 'support@example.com',
subject: 'Email to Multiple Recipients',
text: 'This email goes to multiple people'
})// Mix string and object recipient formats.
await transporter.send({
from: { name: 'John Doe', address: 'john@example.com' },
to: [
'user1@example.com',
{ name: 'Jane Smith', address: 'jane@example.com' },
'"Display Name" <user3@example.com>'
],
cc: ['manager@example.com', { name: 'Team Lead', address: 'lead@example.com' }],
bcc: 'admin@example.com',
replyTo: { name: 'Support Team', address: 'support@example.com' },
subject: 'Advanced Recipient Formats',
text: 'Supports various recipient formats'
})The formatter builds one MIME structure per message. It does not combine embedded images, calendar parts, and file attachments into a single custom multipart tree. Evaluation order is:
embeddedImages→ multipart/related (with HTML and related parts)- Else
calendarEvent→ multipart/alternative including the calendar - Else
attachments→ multipart/mixed - Else
htmlandtexttogether → multipart/alternative - Else
htmlonly, or plaintextonly
If you set more than one of embeddedImages, calendarEvent, and attachments, the earlier branch wins and the others are ignored for structure (for example calendar plus attachments in one send is not supported as one merged layout).
Important
Only one of these shapes applies per message: embedded images or calendar or file attachments for the top-level MIME choice. You cannot merge them into a single custom multipart tree in one send.
// Send one attachment.
await transporter.send({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Email with Attachment',
text: 'Check the attached file',
attachments: [
{
filename: 'document.pdf',
content: fileContent,
contentType: 'application/pdf',
encoding: 'base64'
}
]
})filename cannot contain ", CR, or LF (they would break the MIME filename="..." parameter). If you set contentType, it cannot contain CR or LF. Embedded image cid values cannot contain line breaks inside the angle brackets.
Transfer encoding behavior:
base64: Pass raw content (stringas UTF-8 text, orUint8Arrayas bytes). The library encodes to Base64. Do not pass a string that is already Base64 unless you want it encoded again (wrong for binary files).7bit: Content must be ASCII only (bytes 0–127). Non-ASCII input throws.quoted-printable: Bytes are escaped per quoted-printable rules. The encoder does not fold lines to ~76 characters, which some strict MTAs expect for long lines.
Note
For base64, pass raw file bytes or text. Passing an already Base64-encoded string will be encoded again and corrupt binary payloads.
// Use base64, 7bit, or quoted-printable transfer encoding.
await transporter.send({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Attachment Encoding Options',
text: 'Different transfer encodings are supported',
attachments: [
{
filename: 'base64.txt',
content: rawFileContent,
contentType: 'text/plain',
encoding: 'base64'
},
{
filename: 'plain.txt',
content: 'Plain text body',
contentType: 'text/plain',
encoding: '7bit'
},
{
filename: 'qp.txt',
content: 'Quoted printable body',
contentType: 'text/plain',
encoding: 'quoted-printable'
}
]
})// Send multiple attachments with mixed content types.
await transporter.send({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Multiple Attachments',
text: 'Check the attached files',
attachments: [
{
filename: 'document.pdf',
content: pdfData,
contentType: 'application/pdf',
encoding: 'base64'
},
{
filename: 'image.jpg',
content: imgData,
contentType: 'image/jpeg',
encoding: 'base64'
},
{
filename: 'readme.txt',
content: 'Plain text content',
contentType: 'text/plain',
encoding: '7bit'
}
]
})// Embed inline image in HTML body.
await transporter.send({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Email with Image',
html: '<h1>Hello!</h1><img src="cid:logo">',
embeddedImages: [
{
filename: 'logo.png',
content: imageContent,
contentType: 'image/png',
cid: '<logo@example.com>',
disposition: 'inline'
}
]
})// Set inline or attachment disposition for embedded images.
await transporter.send({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Embedded Image Disposition',
html: '<h1>Hello!</h1><img src="cid:logo">',
embeddedImages: [
{
filename: 'logo.png',
content: imageContent,
contentType: 'image/png',
cid: '<logo@example.com>',
disposition: 'inline',
encoding: 'base64'
},
{
filename: 'badge.png',
content: badgeContent,
contentType: 'image/png',
cid: '<badge@example.com>',
disposition: 'attachment',
encoding: 'base64'
}
]
})// Send basic calendar invitation.
await transporter.send({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Meeting Invitation',
text: 'You are invited to a meeting',
calendarEvent: {
uid: 'meeting-123',
summary: 'Team Meeting',
description: 'Weekly team sync',
location: 'Conference Room A',
startTime: '2024-01-15T10:00:00Z',
endTime: '2024-01-15T11:00:00Z',
organizer: 'organizer@example.com',
attendees: ['attendee1@example.com', 'attendee2@example.com']
}
})// Send complete calendar event payload.
await transporter.send({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Important Meeting',
text: 'Please attend this important meeting',
html: '<h1>Important Meeting</h1><p>Please attend this meeting.</p>',
calendarEvent: {
uid: 'important-meeting-456',
summary: 'Quarterly Review',
description: 'Q4 performance review and planning for next quarter',
location: 'Main Conference Room, Building A',
startTime: '2024-01-20T14:00:00Z',
endTime: '2024-01-20T16:00:00Z',
organizer: 'manager@company.com',
attendees: ['employee1@company.com', 'employee2@company.com', 'hr@company.com'],
status: 'CONFIRMED'
}
})// Attach custom SMTP headers.
await transporter.send({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Custom Headers',
text: 'Email with custom headers',
headers: {
'X-Priority': '1',
'X-Custom-Header': 'Custom Value'
}
})Rules:
- Header names must match token characters (see RFC 5322
atext-style set used in code). Empty names are rejected. - Names and values must not contain CR or LF.
- These names are reserved and cannot be set via
headers(the library owns them):bcc,cc,content-disposition,content-id,content-transfer-encoding,content-type,date,from,message-id,mime-version,reply-to,subject,to.
Warning
Setting a reserved header name (case-insensitive) throws. Use the top-level to, cc, bcc, replyTo, subject, and body fields instead of duplicating them in headers.
| Method | Description | Parameters | Returns |
|---|---|---|---|
mailer.transporter(config) |
Creates email transporter | SmtpConnectionConfig |
EmailSender |
transporter.send(message) |
Sends email message | EmailMessage |
Promise<SmtpSendResult> |
| Option | Type | Required | Description | Example |
|---|---|---|---|---|
host |
string | yes | SMTP server hostname | 'smtp.gmail.com' |
port |
number | yes | SMTP server port | 587, 465, 25 |
secure |
boolean | no | Implicit TLS from connect | true (465), false or omit (587) |
auth |
object | no | Omit for servers without AUTH | See Authentication |
auth.type |
string | when auth set |
Password or OAuth2 | 'password', 'oauth2' |
auth.user |
string | when auth set |
SMTP username | 'user@example.com' |
auth.pass |
string | when type: 'password' |
SMTP password | 'your-password' |
auth.accessToken |
string | when type: 'oauth2' |
OAuth2 bearer token | 'ya29.a0...' |
pool |
boolean/object | no | Enable SMTP client reuse | true, {...} |
pool.maxConnections |
number | no | Maximum pooled clients | 2 |
pool.maxMessagesPerConnection |
number | no | Recycle client after N sends | 100 |
pool.idleTimeoutMs |
number | no | Idle disconnect timeout ms | 60000 |
dkim.domainName |
string | no | DKIM signing domain | 'example.com' |
dkim.keySelector |
string | no | DKIM DNS selector | 'mail' |
dkim.privateKey |
string | no | PEM private signing key | '-----BEGIN PRIVATE KEY-----...' |
dkim.headerFieldNames |
string[] | no | Headers in DKIM h= list |
['from','to','subject',...] |
| Property | Type | Required | Description |
|---|---|---|---|
from |
string/object | yes | Sender email address |
to |
string/array | yes | Recipient email address(es) |
subject |
string | yes | Email subject line |
text |
string | no | Plain text content |
html |
string | no | HTML content |
cc |
string/array | no | Carbon copy recipients |
bcc |
string/array | no | Blind carbon copy recipients |
replyTo |
string/object | no | Reply-to address |
attachments |
array | no | File attachments |
embeddedImages |
array | no | Embedded images |
calendarEvent |
object | no | Calendar invitation |
headers |
object | no | Custom email headers |
Attachment and embedded image encoding supports base64 (library encodes raw content), 7bit (ASCII only), and quoted-printable (no automatic line folding). Embedded image disposition supports inline and attachment.
For SMTP envelope validation, mailbox strings used as bare emails (after parsing) cannot contain CR or LF.
The subject string cannot contain CR or LF. For calendarEvent, values in uid, summary, startTime, endTime, description, location, organizer, and each attendees entry cannot contain CR or LF.
transporter.send() resolves a structured result:
messageId: value from the generatedMessage-IDheader (empty string if parsing ever fails)envelope:fromis SMTPMAIL FROM,tolists every address used inRCPT TO(includesto,cc, andbccrecipients)acceptedRecipients: addresses accepted by the server forRCPT TOrejectedRecipients: addresses rejected forRCPT TOresponse: final SMTP response after the terminatingDATAdot
Note
envelope.to lists every address sent in RCPT TO, including BCC recipients. Do not treat that list as a public “visible To” header if you must hide BCC addresses in your own UI or logs.
Many failures surface as Error messages prefixed with Failed to send message: or SMTP connection failed: plus the underlying reason. Simple message.includes('…') checks can miss cases if the wording changes, so prefer logging the full message or matching a broader substring.
// Catch transport or SMTP response errors.
try {
await transporter.send(message)
} catch (error) {
const text = error instanceof Error ? error.message : String(error)
console.error('Email failed:', text)
if (/authentication|auth/i.test(text)) {
// Handle auth error.
} else if (/connection|connect|STARTTLS|TLS/i.test(text)) {
// Handle connectivity or TLS error.
}
}// Gmail STARTTLS setup.
const transporter = mailer.transporter({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
type: 'password',
user: 'your-email@gmail.com',
pass: 'your-app-password'
}
})// Outlook STARTTLS setup.
const transporter = mailer.transporter({
host: 'smtp-mail.outlook.com',
port: 587,
secure: false,
auth: {
type: 'password',
user: 'your-email@outlook.com',
pass: 'your-password'
}
})// Custom SMTP setup.
const transporter = mailer.transporter({
host: 'mail.yourcompany.com',
port: 587,
secure: false,
auth: {
type: 'password',
user: 'noreply@yourcompany.com',
pass: 'your-password'
}
})- Verify SMTP username and password
- For Gmail, use App Passwords instead of account password
- If 2FA is enabled, generate and use an App Password
- Verify SMTP host and port values
- Check firewall or network restrictions
- Try alternative SMTP ports (
587,465,25)
- On port
587, the server must advertiseSTARTTLSor the client will error - Use
secure: falsefor587(STARTTLS after EHLO) - Use
secure: truefor465(direct TLS) - Verify the server certificate chain
// Log success and failure details.
try {
await transporter.send(message)
console.log('Email sent successfully')
} catch (error) {
const text = error instanceof Error ? error.message : String(error)
console.error('SMTP Error:', text)
}