💼 Business problem
Multi-subsidiary (or multi-brand) accounts need customer emails to come from the correct brand mailbox (e.g., billing@brandA.com
for Subsidiary A and accounts@brandB.com
for B). The standard “Email” action often uses a single sender or the logged-in user—leading to SPF/DMARC issues and confused replies.
Goal: Provide a Send Branded Email action on transactions that:
- Picks author (sender employee/service mailbox) by subsidiary
- Uses a template (subject/body merge)
- Attaches the PDF for the transaction
- Sets Reply-To, CC/BCC, and logs an audit note
🧠 Approach
- A Suitelet renders a small form (To/CC/BCC, template picker, preview) and sends the email using
N/email
. - A User Event (beforeLoad) adds a “Send Branded Email” button to Invoices/Estimates/Sales Orders.
- JSON mapping (script parameter) maps
subsidiaryId → {authorEmployeeId, replyTo, defaultCc}
. - Use
N/render
to generate the transaction PDF attachment from your Advanced PDF template.
🔧 Prerequisites
- Create service Employees for each brand mailbox (or use company-wide email if enabled). Ensure they can send email.
- Optional File Cabinet email HTML templates, or keep inline.
- Deployment parameter (Long Text):
custscript_brand_email_map
with JSON like:
{
"2": { "author": 1234, "replyTo": "accounts@brandA.com", "cc": "billing@brandA.com" },
"3": { "author": 5678, "replyTo": "invoices@brandB.com", "cc": "" },
"default": { "author": 1234, "replyTo": "accounts@brandA.com", "cc": "" }
}
(IDs are examples: 2=Subsidiary A, 3=Subsidiary B; author=Employee internal ID used as email sender.)
- Optional body field on transactions for audit:
custbody_last_brand_email_note
(Text).
🧩 User Event (adds the button)
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* Title: UE | Add "Send Branded Email" button
*/
define(['N/url','N/runtime'], (url, runtime) => {
const beforeLoad = (ctx) => {
const { form, newRecord, type } = ctx;
if (![ctx.UserEventType.VIEW, ctx.UserEventType.EDIT].includes(type)) return;
if (!['invoice','estimate','salesorder','cashsale'].includes(newRecord.type)) return;
const slUrl = url.resolveScript({
scriptId: 'customscript_sl_brand_email',
deploymentId: 'customdeploy_sl_brand_email',
params: { txnId: newRecord.id, txnType: newRecord.type }
});
form.addButton({
id: 'custpage_send_branded_email',
label: 'Send Branded Email',
functionName: `window.open('${slUrl}','_blank')`
});
};
return { beforeLoad };
});
🧩 Suitelet (form → send email)
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
* Title: SL | Send Branded Email
*/
define(['N/ui/serverWidget','N/runtime','N/record','N/render','N/email','N/file','N/format','N/log'],
(ui, runtime, record, render, email, file, format, log) => {
const P_MAP = 'custscript_brand_email_map'; // deployment parameter
function onRequest(ctx){
const req = ctx.request, res = ctx.response;
const txnId = req.parameters.txnId, txnType = req.parameters.txnType || 'invoice';
if (!txnId) return writeError(res, 'Missing transaction id');
if (req.method === 'GET') {
const { toEmail, customerName, subsidiaryId, tranId, amount, defaultSubject, defaultBody } =
primeTxnInfo(txnType, txnId);
const map = loadMap();
const cfg = map[subsidiaryId] || map.default || {};
const form = ui.createForm({ title: `Send Branded Email — ${tranId}` });
const fldTo = form.addField({ id:'custpage_to', label:'To', type: ui.FieldType.EMAIL });
fldTo.defaultValue = toEmail || '';
form.addField({ id:'custpage_cc', label:'CC', type: ui.FieldType.TEXT }).defaultValue = cfg.cc || '';
form.addField({ id:'custpage_bcc', label:'BCC', type: ui.FieldType.TEXT });
const fldSubj = form.addField({ id:'custpage_subject', label:'Subject', type: ui.FieldType.TEXT });
fldSubj.defaultValue = defaultSubject;
const fldBody = form.addField({ id:'custpage_body', label:'Body (HTML allowed)', type: ui.FieldType.LONGTEXT });
fldBody.defaultValue = defaultBody;
const fldReply = form.addField({ id:'custpage_replyto', label:'Reply-To', type: ui.FieldType.EMAIL });
fldReply.defaultValue = cfg.replyTo || '';
form.addField({ id:'custpage_txnid', label:'txnId', type: ui.FieldType.TEXT }).updateDisplayType({ displayType: ui.FieldDisplayType.HIDDEN }).defaultValue = String(txnId);
form.addField({ id:'custpage_txntype', label:'txnType', type: ui.FieldType.TEXT }).updateDisplayType({ displayType: ui.FieldDisplayType.HIDDEN }).defaultValue = String(txnType);
form.addSubmitButton({ label: 'Send Email' });
res.writePage(form);
return;
}
// POST — send
const to = req.parameters.custpage_to;
const cc = req.parameters.custpage_cc;
const bcc = req.parameters.custpage_bcc;
const subject = req.parameters.custpage_subject || '';
const body = req.parameters.custpage_body || '';
const replyTo = req.parameters.custpage_replyto || '';
const postTxnId = req.parameters.custpage_txnid;
const postTxnType = req.parameters.custpage_txntype;
try {
const info = primeTxnInfo(postTxnType, postTxnId);
const map = loadMap();
const cfg = map[info.subsidiaryId] || map.default || {};
if (!cfg.author) throw new Error('Missing author in mapping for this subsidiary');
const pdf = renderPDF(postTxnType, postTxnId); // file.File object
const author = Number(cfg.author);
email.send({
author,
recipients: to,
cc: (cc || '').split(',').map(s=>s.trim()).filter(Boolean),
bcc: (bcc || '').split(',').map(s=>s.trim()).filter(Boolean),
subject: merge(subject, info),
body: merge(body, info),
attachments: pdf ? [pdf] : [],
relatedRecords: { transactionId: Number(postTxnId) },
replyTo
});
// audit on transaction
try {
record.submitFields({
type: toRecType(postTxnType),
id: Number(postTxnId),
values: { custbody_last_brand_email_note: `Sent via Suitelet by ${author} → ${to} (${new Date().toISOString()})` },
options: { enableSourcing: false, ignoreMandatoryFields: true }
});
} catch (_e){}
res.write(`<html><body><h3>Email sent</h3><p>To: ${to}</p><a href="javascript:window.close()">Close</a></body></html>`);
} catch (e) {
log.error('send failed', e);
writeError(res, `Failed to send: ${e.message || e}`);
}
}
function primeTxnInfo(txnType, id){
const rec = record.load({ type: toRecType(txnType), id: Number(id) });
const entity = rec.getValue('entity');
const toEmail = rec.getValue('email') || rec.getValue('custbody_email_override') || '';
const customerName = rec.getText('entity') || '';
const subsidiaryId = String(rec.getValue('subsidiary') || '');
const tranId = rec.getValue('tranid') || '';
const total = rec.getValue('total') || '';
const defaultSubject = `[${tranId}] Invoice from {{company}}`;
const defaultBody = [
`Hello ${customerName || 'Customer'},`,
`<br/><br/>Please find your ${txnType} <b>${tranId}</b> attached.`,
`<br/>Total: ${format.format({ value: total, type: format.Type.CURRENCY })}`,
`<br/><br/>Thank you,`,
`<br/>{{company}}`
].join('\n');
return { toEmail, customerName, subsidiaryId, tranId, amount: total, defaultSubject, defaultBody };
}
function renderPDF(txnType, id){
try {
return render.transaction({ entityId: Number(id), printMode: render.PrintMode.PDF, inCompanyLanguage: false });
} catch (_e) { return null; }
}
function merge(str, info){
if (!str) return '';
return String(str)
.replace(/\{\{company\}\}/g, runtime.accountId) // or fetch company name/subsidiary name if you prefer
.replace(/\{\{tranid\}\}/g, info.tranId)
.replace(/\{\{amount\}\}/g, info.amount);
}
function loadMap(){
try {
const raw = runtime.getCurrentScript().getParameter({ name: P_MAP }) || '{}';
return JSON.parse(raw);
} catch (_e) { return {}; }
}
function toRecType(type){
switch (String(type)) {
case 'invoice': return record.Type.INVOICE;
case 'salesorder': return record.Type.SALES_ORDER;
case 'estimate': return record.Type.ESTIMATE;
case 'cashsale': return record.Type.CASH_SALE;
default: return record.Type.INVOICE;
}
}
function writeError(res, msg){
res.write(`<html><body><h3 style="color:#b00;">${escapeHtml(msg)}</h3><a href="javascript:window.close()">Close</a></body></html>`);
}
function escapeHtml(s){ return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
return { onRequest };
});
✅ How it works in the UI
- Open an Invoice (or SO/Estimate) → click Send Branded Email.
- Suitelet pre-fills To, Subject, Body, Reply-To, CC based on subsidiary mapping.
- Click Send → it emails with the correct sender and attaches the PDF.
- An audit note is stored on the transaction.
🧪 Testing checklist
- Map Subsidiary → Author (Employee ID) in the JSON parameter.
- Test with two subsidiaries to confirm the From mailbox switches.
- Verify Reply-To goes to the brand inbox.
- Confirm the PDF attaches and the subject/body placeholders render.
- DMARC/SPF: send to an external mailbox and inspect headers (“From”, “Sender”, “Reply-To”).
🌱 Enhancements
- Pull subsidiary legal name and brand phone into the body (use
record.load
on Subsidiary or cache in the map). - Add a template selector (dropdown from custom record of templates).
- Add a “Send Statement” mode (render a Statement PDF via
render.statement
). - Add role permissions: only AR roles see the button.
- Add multi-language placeholders based on customer language.
📌 Summary
Need | Solution |
---|---|
Emails must come from the correct brand | Subsidiary→sender JSON mapping |
Attach branded PDFs | render.transaction attachment |
Keep replies in the right inbox | replyTo per subsidiary |
Track and audit | Body field note per send |
Leave a Reply