Documentation Index Fetch the complete documentation index at: https://docs.lettr.com/llms.txt
Use this file to discover all available pages before exploring further.
When someone sends an email with attachments to your inbound domain, Lettr extracts and stores the attachments temporarily, providing secure URLs for you to download them.
Attachment Data Structure
Each attachment in the webhook payload includes:
{
"attachments" : [
{
"filename" : "invoice.pdf" ,
"contentType" : "application/pdf" ,
"size" : 245678 ,
"url" : "https://inbound.lettr.com/attachments/abc123def456"
},
{
"filename" : "screenshot.png" ,
"contentType" : "image/png" ,
"size" : 89012 ,
"url" : "https://inbound.lettr.com/attachments/xyz789ghi012"
}
]
}
Field Type Description filenamestring Original filename of the attachment contentTypestring MIME type of the file sizenumber File size in bytes urlstring Temporary URL to download the attachment
Downloading Attachments
Download attachments using the provided URLs:
app . post ( '/webhooks/inbound' , async ( req , res ) => {
const { attachments , from , subject } = req . body . data ;
// Process each attachment
for ( const attachment of attachments ) {
// Download the file
const response = await fetch ( attachment . url );
const buffer = await response . arrayBuffer ();
// Save to your storage
await saveToStorage ({
filename: attachment . filename ,
contentType: attachment . contentType ,
data: Buffer . from ( buffer )
});
console . log ( `Saved: ${ attachment . filename } ( ${ attachment . size } bytes)` );
}
res . sendStatus ( 200 );
});
Attachment URL Expiration
Attachment URLs are valid for 24 hours after the email is received. Download and store attachments if you need them longer.
Handle expiration gracefully:
async function downloadAttachment ( attachment ) {
const response = await fetch ( attachment . url );
if ( response . status === 404 || response . status === 410 ) {
throw new Error ( `Attachment URL expired: ${ attachment . filename } ` );
}
if ( ! response . ok ) {
throw new Error ( `Failed to download: ${ response . statusText } ` );
}
return response . arrayBuffer ();
}
Storing Attachments
Save to Local Filesystem
import fs from 'fs/promises' ;
import path from 'path' ;
async function saveAttachmentLocally ( attachment , directory = './attachments' ) {
const response = await fetch ( attachment . url );
const buffer = await response . arrayBuffer ();
// Create safe filename
const safeFilename = sanitizeFilename ( attachment . filename );
const filepath = path . join ( directory , ` ${ Date . now () } - ${ safeFilename } ` );
await fs . writeFile ( filepath , Buffer . from ( buffer ));
return filepath ;
}
function sanitizeFilename ( filename ) {
return filename
. replace ( / [ ^ a-zA-Z0-9.- ] / g , '_' )
. substring ( 0 , 255 );
}
Save to Cloud Storage (AWS S3)
import { S3Client , PutObjectCommand } from '@aws-sdk/client-s3' ;
const s3 = new S3Client ({ region: 'us-east-1' });
async function saveToS3 ( attachment , emailId ) {
const response = await fetch ( attachment . url );
const buffer = await response . arrayBuffer ();
const key = `inbound/ ${ emailId } / ${ attachment . filename } ` ;
await s3 . send ( new PutObjectCommand ({
Bucket: 'your-bucket' ,
Key: key ,
Body: Buffer . from ( buffer ),
ContentType: attachment . contentType ,
Metadata: {
'original-filename' : attachment . filename ,
'email-id' : emailId
}
}));
return `s3://your-bucket/ ${ key } ` ;
}
Save to Cloud Storage (Google Cloud)
import { Storage } from '@google-cloud/storage' ;
const storage = new Storage ();
const bucket = storage . bucket ( 'your-bucket' );
async function saveToGCS ( attachment , emailId ) {
const response = await fetch ( attachment . url );
const buffer = await response . arrayBuffer ();
const filename = `inbound/ ${ emailId } / ${ attachment . filename } ` ;
const file = bucket . file ( filename );
await file . save ( Buffer . from ( buffer ), {
contentType: attachment . contentType ,
metadata: {
originalFilename: attachment . filename
}
});
return `gs://your-bucket/ ${ filename } ` ;
}
Validating Attachments
Always validate attachments before processing:
const ALLOWED_TYPES = [
'application/pdf' ,
'image/jpeg' ,
'image/png' ,
'image/gif' ,
'text/plain' ,
'text/csv' ,
'application/msword' ,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ,
'application/vnd.ms-excel' ,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
];
const MAX_SIZE = 25 * 1024 * 1024 ; // 25 MB
function validateAttachment ( attachment ) {
const errors = [];
// Check content type
if ( ! ALLOWED_TYPES . includes ( attachment . contentType )) {
errors . push ( `Unsupported file type: ${ attachment . contentType } ` );
}
// Check file size
if ( attachment . size > MAX_SIZE ) {
errors . push ( `File too large: ${ attachment . size } bytes (max ${ MAX_SIZE } )` );
}
// Check filename
if ( ! attachment . filename || attachment . filename . length > 255 ) {
errors . push ( 'Invalid filename' );
}
return {
valid: errors . length === 0 ,
errors
};
}
app . post ( '/webhooks/inbound' , async ( req , res ) => {
const { attachments } = req . body . data ;
for ( const attachment of attachments ) {
const validation = validateAttachment ( attachment );
if ( ! validation . valid ) {
console . warn ( `Skipping attachment: ${ validation . errors . join ( ', ' ) } ` );
continue ;
}
await processAttachment ( attachment );
}
res . sendStatus ( 200 );
});
Security Considerations
Never trust attachment filenames or content types from emails. Always validate and sanitize before processing.
Virus Scanning
Scan attachments for malware before storing:
import ClamScan from 'clamscan' ;
const clamscan = await new ClamScan (). init ({
clamdscan: {
host: 'localhost' ,
port: 3310
}
});
async function scanAndSave ( attachment ) {
const response = await fetch ( attachment . url );
const buffer = Buffer . from ( await response . arrayBuffer ());
// Scan for viruses
const { isInfected , viruses } = await clamscan . scanBuffer ( buffer );
if ( isInfected ) {
console . error ( `Infected file detected: ${ viruses . join ( ', ' ) } ` );
await quarantineAttachment ( attachment , viruses );
return { saved: false , reason: 'malware_detected' };
}
// Safe to save
await saveToStorage ( attachment . filename , buffer );
return { saved: true };
}
File Type Verification
Verify file contents match the declared content type:
import { fileTypeFromBuffer } from 'file-type' ;
async function verifyFileType ( attachment ) {
const response = await fetch ( attachment . url );
const buffer = Buffer . from ( await response . arrayBuffer ());
const detected = await fileTypeFromBuffer ( buffer );
if ( detected && detected . mime !== attachment . contentType ) {
console . warn (
`Content type mismatch: declared ${ attachment . contentType } , detected ${ detected . mime } `
);
return false ;
}
return true ;
}
Processing Specific File Types
PDF Documents
import pdf from 'pdf-parse' ;
async function extractPdfText ( attachment ) {
const response = await fetch ( attachment . url );
const buffer = Buffer . from ( await response . arrayBuffer ());
const data = await pdf ( buffer );
return {
text: data . text ,
pages: data . numpages ,
info: data . info
};
}
Images
import sharp from 'sharp' ;
async function processImage ( attachment ) {
const response = await fetch ( attachment . url );
const buffer = Buffer . from ( await response . arrayBuffer ());
// Get image metadata
const metadata = await sharp ( buffer ). metadata ();
// Create thumbnail
const thumbnail = await sharp ( buffer )
. resize ( 200 , 200 , { fit: 'inside' })
. jpeg ({ quality: 80 })
. toBuffer ();
return {
metadata ,
thumbnail
};
}
CSV Files
import { parse } from 'csv-parse/sync' ;
async function parseCsv ( attachment ) {
const response = await fetch ( attachment . url );
const text = await response . text ();
const records = parse ( text , {
columns: true ,
skip_empty_lines: true
});
return records ;
}
Attachment Limits
Limit Value Max attachment size 25 MB per file Max total attachments 100 MB per email URL validity 24 hours Max attachments per email 50 files
Complete Example
import express from 'express' ;
import { S3Client , PutObjectCommand } from '@aws-sdk/client-s3' ;
const app = express ();
const s3 = new S3Client ({ region: 'us-east-1' });
const ALLOWED_TYPES = [ 'application/pdf' , 'image/jpeg' , 'image/png' ];
const MAX_SIZE = 10 * 1024 * 1024 ; // 10 MB
app . post ( '/webhooks/inbound' , express . json (), async ( req , res ) => {
const { id , from , subject , attachments } = req . body . data ;
const savedAttachments = [];
for ( const attachment of attachments ) {
// Validate
if ( ! ALLOWED_TYPES . includes ( attachment . contentType )) {
console . warn ( `Skipping unsupported type: ${ attachment . contentType } ` );
continue ;
}
if ( attachment . size > MAX_SIZE ) {
console . warn ( `Skipping large file: ${ attachment . filename } ` );
continue ;
}
try {
// Download
const response = await fetch ( attachment . url );
if ( ! response . ok ) throw new Error ( 'Download failed' );
const buffer = Buffer . from ( await response . arrayBuffer ());
// Upload to S3
const key = `inbound/ ${ id } / ${ attachment . filename } ` ;
await s3 . send ( new PutObjectCommand ({
Bucket: 'my-bucket' ,
Key: key ,
Body: buffer ,
ContentType: attachment . contentType
}));
savedAttachments . push ({
filename: attachment . filename ,
s3Key: key ,
size: attachment . size
});
} catch ( error ) {
console . error ( `Failed to process ${ attachment . filename } :` , error );
}
}
// Store email record with attachment references
await saveEmailRecord ({
externalId: id ,
from ,
subject ,
attachments: savedAttachments
});
res . sendStatus ( 200 );
});
Webhooks Webhook payload structure
Security Security best practices
Best Practices Receiving best practices
Sending Attachments Attach files to outgoing emails