mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Stalwart does no BIMI, so sender icons were only ever favicons, which anyone controls for any domain. The webmail server now does the receiver side itself at /api/bimi, and avatars ask it only for messages whose own Authentication-Results say dmarc=pass for the exact From domain. - `lib/bimi/`: DMARC policy check (quarantine or reject, pct=100, the parent domain's sp and p for subdomains), the assertion record with fallback to the parent domain, mark certificate validation (chain to pinned DigiCert, GlobalSign and SSL.com VMC roots, BIMI key purpose, validity, SAN, SCTs present, CRL revocation, logo from the logotype extension), SVG Tiny PS checks and rendering to PNG, caching - logos backed only by the DNS record show by default; the admin setting "Require a mark certificate" (`BIMI_REQUIRE_CERTIFICATE`) limits them to VMC and CMC backed ones - a check mark beside the sender's name for certificate-backed logos - avatars try contact photo, BIMI logo, plugin avatar, favicon; the sender warning shield still wins, and logos follow the sender icons setting and the no-third-party-traffic mode - the two-label public suffix list moved to `lib/root-domain.ts` SCT signatures are not verified and only the default selector is used.
533 lines
18 KiB
TypeScript
533 lines
18 KiB
TypeScript
import { AuthenticationResults } from './jmap/types';
|
|
import { parseUnsubscribeUrls } from './validation';
|
|
|
|
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
|
type SpfEntry = NonNullable<NonNullable<AuthenticationResults['spf']>['all']>[number];
|
|
|
|
/**
|
|
* Severity ranking for SPF results. Higher = more severe / more actionable.
|
|
* A hard `fail` is a definitive policy violation and must outrank ambiguous
|
|
* states like `temperror`, so a spoofed message isn't softened to a
|
|
* "temporary failure" headline when one identity hard-fails.
|
|
*/
|
|
const SPF_SEVERITY: Record<SpfResult, number> = {
|
|
fail: 6,
|
|
softfail: 5,
|
|
permerror: 4,
|
|
temperror: 3,
|
|
neutral: 2,
|
|
none: 1,
|
|
pass: 0,
|
|
};
|
|
|
|
/**
|
|
* Whether the authentication results indicate the visible From identity can't
|
|
* be trusted (i.e. the message is likely spoofed). Used to suppress UI that
|
|
* would otherwise imply the message legitimately came from one of the user's
|
|
* own identities (e.g. the "via <identity>" badge).
|
|
*/
|
|
export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean {
|
|
if (!auth) return false;
|
|
// DMARC aligns the visible From with SPF/DKIM, so a DMARC fail is the
|
|
// strongest single spoofing signal.
|
|
if (auth.dmarc?.result === 'fail') return true;
|
|
// Otherwise a hard SPF fail with no valid DKIM signature means the sender
|
|
// isn't authorized for the envelope domain.
|
|
if (auth.spf?.result === 'fail' && !hasDkimPass(auth)) return true;
|
|
return false;
|
|
}
|
|
|
|
function hasDkimPass(auth: AuthenticationResults): boolean {
|
|
return auth.dkim?.result === 'pass' || !!auth.dkim?.all?.some((entry) => entry.result === 'pass');
|
|
}
|
|
|
|
/**
|
|
* Whether `candidate` is the From domain or a parent/child of it: the
|
|
* relaxed alignment DMARC applies, without a public-suffix list.
|
|
*/
|
|
function alignedWith(fromDomain: string, candidate: string | undefined): boolean {
|
|
const other = candidate ? domainOf(`@${candidate}`) : undefined;
|
|
if (!other) return false;
|
|
return other === fromDomain || other.endsWith(`.${fromDomain}`) || fromDomain.endsWith(`.${other}`);
|
|
}
|
|
|
|
/**
|
|
* An SPF pass for the MAIL FROM identity whose domain aligns with the From
|
|
* domain. Any sender can pass SPF for a domain of their own and still write
|
|
* someone else's address in From; only an aligned pass says anything about
|
|
* the visible sender. A HELO pass never does.
|
|
*/
|
|
function hasAlignedSpfPass(auth: AuthenticationResults, fromDomain: string): boolean {
|
|
if (auth.spf?.all) {
|
|
return auth.spf.all.some((entry) => entry.identity !== 'helo' && entry.result === 'pass' && alignedWith(fromDomain, entry.domain));
|
|
}
|
|
return auth.spf?.result === 'pass' && alignedWith(fromDomain, auth.spf.domain);
|
|
}
|
|
|
|
/** A DKIM pass for a signing domain that aligns with the From domain. */
|
|
function hasAlignedDkimPass(auth: AuthenticationResults, fromDomain: string): boolean {
|
|
const entries = auth.dkim?.all ?? (auth.dkim ? [auth.dkim] : []);
|
|
return entries.some((entry) => entry.result === 'pass' && alignedWith(fromDomain, entry.domain));
|
|
}
|
|
|
|
/**
|
|
* Whether the receiving server's checks tie the message to the visible From
|
|
* domain: an aligned DMARC, SPF or DKIM pass. Shared by the sender
|
|
* verification notice and the calendar invitation trust assessment.
|
|
*/
|
|
export function hasAlignedAuthenticationPass(auth: AuthenticationResults | undefined, fromEmail: string | undefined): boolean {
|
|
if (!auth || !fromEmail) return false;
|
|
const domain = domainOf(fromEmail);
|
|
if (!domain) return false;
|
|
return auth.dmarc?.result === 'pass' || hasAlignedSpfPass(auth, domain) || hasAlignedDkimPass(auth, domain);
|
|
}
|
|
|
|
function domainOf(address: string): string | undefined {
|
|
const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, '');
|
|
return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined;
|
|
}
|
|
|
|
export interface SenderVerification {
|
|
/**
|
|
* `failed`: the message fails the From domain's checks (see
|
|
* isAuthenticationSpoofed). `unverified`: neither SPF nor DKIM passes, so
|
|
* nothing ties the message to any domain, let alone the one in From.
|
|
*/
|
|
status: 'failed' | 'unverified';
|
|
/** Domain of the visible From address. */
|
|
domain: string;
|
|
/** Envelope (MAIL FROM) host, when it differs from `domain`. */
|
|
sentFrom?: string;
|
|
}
|
|
|
|
/**
|
|
* Whether the receiving server's checks back the visible From domain.
|
|
* Returns null when they do, or when there are no results to judge by.
|
|
*
|
|
* DMARC alone can't answer this: mail-auth (Stalwart) only checks alignment
|
|
* once SPF or DKIM passes, so a message that passes neither reports
|
|
* `dmarc=none` even when the From domain publishes a policy. That is the
|
|
* plainest kind of forgery, so it gets its own verdict here.
|
|
*/
|
|
export function getSenderVerification(
|
|
auth: AuthenticationResults | undefined,
|
|
fromEmail: string | undefined,
|
|
): SenderVerification | null {
|
|
if (!auth || !fromEmail || (!auth.spf && !auth.dkim && !auth.dmarc)) return null;
|
|
const domain = domainOf(fromEmail);
|
|
if (!domain) return null;
|
|
|
|
// DMARC only counts the MAIL FROM identity; a HELO pass proves nothing
|
|
// about who wrote the message.
|
|
const mailFrom = auth.spf?.all?.find((entry) => entry.identity === 'mailfrom');
|
|
const envelope = mailFrom?.domain ?? auth.spf?.domain;
|
|
const envelopeDomain = envelope ? domainOf(envelope) : undefined;
|
|
const sentFrom = envelopeDomain && envelopeDomain !== domain ? envelopeDomain : undefined;
|
|
|
|
if (isAuthenticationSpoofed(auth)) return { status: 'failed', domain, sentFrom };
|
|
// Only a pass that aligns with the From domain counts: `spf=pass` for the
|
|
// sender's own envelope domain under someone else's From is the forgery
|
|
// this notice exists for, and a DKIM signature by a third domain is no
|
|
// better. The DMARC verdict is `none` in both cases (no policy published).
|
|
if (hasAlignedAuthenticationPass(auth, fromEmail)) return null;
|
|
return { status: 'unverified', domain, sentFrom };
|
|
}
|
|
|
|
/**
|
|
* The From domain whose BIMI logo this message may show, or undefined. BIMI
|
|
* needs a DMARC pass for the very domain in From: an aligned SPF or DKIM
|
|
* pass without a DMARC verdict isn't enough, and neither is a pass that the
|
|
* receiving server recorded for another header.from. Whether the domain
|
|
* enforces DMARC and publishes a logo is /api/bimi's to find out.
|
|
*/
|
|
export function brandIndicatorDomain(
|
|
email: { from?: { email?: string | null }[] | null; authenticationResults?: AuthenticationResults } | undefined,
|
|
): string | undefined {
|
|
const auth = email?.authenticationResults;
|
|
const fromEmail = email?.from?.[0]?.email ?? undefined;
|
|
if (!auth?.dmarc || auth.dmarc.result !== 'pass' || !fromEmail) return undefined;
|
|
const domain = domainOf(fromEmail);
|
|
if (!domain || isAuthenticationSpoofed(auth)) return undefined;
|
|
const checked = auth.dmarc.domain ? domainOf(`@${auth.dmarc.domain}`) : domain;
|
|
return checked === domain ? domain : undefined;
|
|
}
|
|
|
|
interface ResInfo {
|
|
method: string;
|
|
result: string;
|
|
props: Record<string, string>;
|
|
}
|
|
|
|
/**
|
|
* Split one Authentication-Results header into its `;`-separated parts
|
|
* (RFC 8601), dropping comments. A `;` inside a quoted string or a comment
|
|
* does not split: both can carry sender-chosen text such as the envelope
|
|
* address.
|
|
*/
|
|
function splitResinfo(header: string): string[] {
|
|
const parts: string[] = [];
|
|
let current = '';
|
|
let depth = 0;
|
|
let quoted = false;
|
|
for (let i = 0; i < header.length; i++) {
|
|
const c = header[i];
|
|
if (c === '\\' && (quoted || depth > 0)) {
|
|
if (depth === 0) current += c + (header[i + 1] ?? '');
|
|
i++;
|
|
continue;
|
|
}
|
|
if (quoted) {
|
|
current += c;
|
|
if (c === '"') quoted = false;
|
|
continue;
|
|
}
|
|
if (c === '(') {
|
|
depth++;
|
|
continue;
|
|
}
|
|
if (depth > 0) {
|
|
if (c === ')' && --depth === 0) current += ' ';
|
|
continue;
|
|
}
|
|
if (c === '"') quoted = true;
|
|
if (c === ';') {
|
|
parts.push(current);
|
|
current = '';
|
|
continue;
|
|
}
|
|
current += c;
|
|
}
|
|
parts.push(current);
|
|
return parts.map((part) => part.trim()).filter(Boolean);
|
|
}
|
|
|
|
const METHOD_RE = /^([a-z0-9][a-z0-9_-]*)(?:\/\d+)?\s*=\s*([a-z]+)(?=\s|$)/i;
|
|
const PROP_RE = /\s*([^\s=]+)\s*=\s*("(?:[^"\\]|\\.)*"|\S*)/y;
|
|
|
|
/**
|
|
* Read one resinfo: the method must open the part, so a `dmarc=pass` that
|
|
* appears inside a property value (say an envelope local part in
|
|
* `smtp.mailfrom=`) is never taken for a result.
|
|
*/
|
|
function parseResinfo(part: string): ResInfo | null {
|
|
const match = METHOD_RE.exec(part);
|
|
if (!match) return null;
|
|
const props: Record<string, string> = {};
|
|
const rest = part.slice(match[0].length);
|
|
PROP_RE.lastIndex = 0;
|
|
let prop: RegExpExecArray | null;
|
|
while ((prop = PROP_RE.exec(rest)) !== null && prop[0].length > 0) {
|
|
const key = prop[1].toLowerCase();
|
|
let value = prop[2];
|
|
if (value.startsWith('"')) value = value.slice(1, -1).replace(/\\(.)/g, '$1');
|
|
if (!(key in props)) props[key] = value;
|
|
}
|
|
return { method: match[1].toLowerCase(), result: match[2].toLowerCase(), props };
|
|
}
|
|
|
|
function parseResinfos(header: string): ResInfo[] {
|
|
return splitResinfo(header)
|
|
.map(parseResinfo)
|
|
.filter((info): info is ResInfo => info !== null);
|
|
}
|
|
|
|
const DMARC_SEVERITY: Record<string, number> = {
|
|
fail: 3,
|
|
permerror: 2,
|
|
temperror: 2,
|
|
none: 1,
|
|
pass: 0,
|
|
};
|
|
|
|
/**
|
|
* Parse Authentication-Results headers into SPF, DKIM, DMARC results.
|
|
*
|
|
* Pass the headers in message order. The topmost one is the receiving
|
|
* server's own; anything below it may have been written by the sender, so
|
|
* DKIM, DMARC and iprev come from the topmost header only, and the others
|
|
* can only escalate SPF to a failure, never supply a pass.
|
|
*/
|
|
function identityDomain(identity: string | undefined): string | undefined {
|
|
const at = identity?.lastIndexOf('@') ?? -1;
|
|
return identity && at >= 0 && at < identity.length - 1 ? identity.slice(at + 1) : undefined;
|
|
}
|
|
|
|
export function parseAuthenticationResults(headers: string | readonly string[]): AuthenticationResults {
|
|
const results: AuthenticationResults = {};
|
|
const list = typeof headers === 'string' ? [headers] : headers;
|
|
const perHeader = list.map(parseResinfos);
|
|
const own = perHeader[0] ?? [];
|
|
const foreign = perHeader.slice(1).flat();
|
|
|
|
type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
|
type DmarcResult = 'pass' | 'fail' | 'none';
|
|
type DmarcPolicy = 'reject' | 'quarantine' | 'none';
|
|
|
|
// Parse SPF. A single Authentication-Results header can carry more than one
|
|
// SPF result when the server evaluates multiple identities (HELO and MAIL
|
|
// FROM). Collect them all so a hard fail on any identity isn't softened to
|
|
// an ambiguous state recorded for another one.
|
|
const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1;
|
|
const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror;
|
|
const toSpfEntry = (info: ResInfo): SpfEntry => {
|
|
const identity = info.props['smtp.mailfrom'] !== undefined
|
|
? 'mailfrom'
|
|
: info.props['smtp.helo'] !== undefined ? 'helo' : undefined;
|
|
return {
|
|
result: info.result as SpfResult,
|
|
identity: identity as SpfEntry['identity'],
|
|
domain: identity ? info.props[`smtp.${identity}`] || undefined : undefined,
|
|
};
|
|
};
|
|
const spfResults: SpfEntry[] = [
|
|
...own.filter((info) => info.method === 'spf').map(toSpfEntry),
|
|
...foreign.filter((info) => info.method === 'spf').map(toSpfEntry).filter((e) => isFailure(e.result)),
|
|
];
|
|
if (spfResults.length > 0) {
|
|
// MAIL FROM is the primary SPF identity. Another identity (HELO) may only
|
|
// escalate the headline to a genuine failure state — a HELO `none` or
|
|
// `neutral` must not downgrade a MAIL FROM `pass`, since most senders
|
|
// publish no SPF record for their EHLO hostname.
|
|
let primary =
|
|
spfResults.find((e) => e.identity === 'mailfrom') ?? spfResults[0];
|
|
for (const cur of spfResults) {
|
|
if (isFailure(cur.result) && severity(cur.result) > severity(primary.result)) {
|
|
primary = cur;
|
|
}
|
|
}
|
|
results.spf = {
|
|
result: primary.result,
|
|
domain: primary.domain,
|
|
...(spfResults.length > 1 ? { all: spfResults } : {}),
|
|
};
|
|
}
|
|
|
|
// A message can carry several signatures (the author's domain and the
|
|
// sending service's). The first one stays the headline; keep them all so
|
|
// a pass further down still counts.
|
|
const dkimResults = own
|
|
.filter((info) => info.method === 'dkim')
|
|
.map((info) => ({
|
|
result: info.result as DkimResult,
|
|
// Some receivers name the signature only by its identity (`header.i`,
|
|
// `@domain` or `user@domain`, RFC 8601 2.7.1); its domain is the signer's.
|
|
domain: info.props['header.d'] ?? identityDomain(info.props['header.i']),
|
|
selector: info.props['header.s'],
|
|
}));
|
|
if (dkimResults.length > 0) {
|
|
results.dkim = {
|
|
...dkimResults[0],
|
|
...(dkimResults.length > 1 ? { all: dkimResults } : {}),
|
|
};
|
|
}
|
|
|
|
// One DMARC verdict per message; should a header carry several, the most
|
|
// severe stands.
|
|
const dmarc = own
|
|
.filter((info) => info.method === 'dmarc')
|
|
.reduce<ResInfo | undefined>(
|
|
(worst, info) => (!worst || (DMARC_SEVERITY[info.result] ?? -1) > (DMARC_SEVERITY[worst.result] ?? -1) ? info : worst),
|
|
undefined,
|
|
);
|
|
if (dmarc) {
|
|
results.dmarc = {
|
|
result: dmarc.result as DmarcResult,
|
|
domain: dmarc.props['header.from'],
|
|
policy: dmarc.props['policy.dmarc'] as DmarcPolicy | undefined,
|
|
};
|
|
}
|
|
|
|
const iprev = own.find((info) => info.method === 'iprev');
|
|
if (iprev) {
|
|
results.iprev = {
|
|
result: iprev.result as 'pass' | 'fail',
|
|
ip: iprev.props['policy.iprev'],
|
|
};
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Parse spam score from X-Spam-Result or X-Spam-Status headers
|
|
*/
|
|
export function parseSpamScore(header: string): { score: number; status: string } | null {
|
|
// Try X-Spam-Status format: "No, score=-0.25"
|
|
// And try X-Spam-Score format (Stalwart): "ham, score=-0.25"
|
|
const statusMatch = header.match(/^(Yes|No|spam|ham),?\s+score=([-\d.]+)/i);
|
|
if (statusMatch) {
|
|
return {
|
|
status: statusMatch[1].toLowerCase(),
|
|
score: parseFloat(statusMatch[2])
|
|
};
|
|
}
|
|
|
|
// Try to extract just the score
|
|
const scoreMatch = header.match(/score[=:]?\s*([-\d.]+)/i);
|
|
if (scoreMatch) {
|
|
const score = parseFloat(scoreMatch[1]);
|
|
return {
|
|
score,
|
|
status: score > 5 ? 'spam' : 'ham'
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse Received headers to extract mail routing path
|
|
*/
|
|
interface ReceivedHeaderInfo {
|
|
from: string;
|
|
by: string;
|
|
timestamp?: string;
|
|
protocol?: string;
|
|
id?: string;
|
|
}
|
|
|
|
export function parseReceivedHeaders(headers: string[]): ReceivedHeaderInfo[] {
|
|
const path: ReceivedHeaderInfo[] = [];
|
|
|
|
for (const header of headers) {
|
|
const fromMatch = header.match(/from\s+([^\s]+)(?:\s+\([^)]+\))?/);
|
|
const byMatch = header.match(/by\s+([^\s]+)/);
|
|
const dateMatch = header.match(/;\s+(.+)$/);
|
|
const protoMatch = header.match(/with\s+(\w+)/);
|
|
const idMatch = header.match(/id\s+([^\s;]+)/);
|
|
|
|
if (fromMatch || byMatch) {
|
|
path.push({
|
|
from: fromMatch?.[1] || 'unknown',
|
|
by: byMatch?.[1] || 'unknown',
|
|
timestamp: dateMatch?.[1],
|
|
protocol: protoMatch?.[1],
|
|
id: idMatch?.[1]
|
|
});
|
|
}
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Format bytes to human readable size
|
|
*/
|
|
export function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
|
}
|
|
|
|
/**
|
|
* Get security status color and icon based on result
|
|
*/
|
|
export function getSecurityStatus(result?: string): {
|
|
color: string;
|
|
icon: 'check' | 'x' | 'alert' | 'minus';
|
|
bgColor: string;
|
|
borderColor: string;
|
|
} {
|
|
switch (result) {
|
|
case 'pass':
|
|
return {
|
|
color: 'text-green-700 dark:text-green-400',
|
|
icon: 'check',
|
|
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
|
borderColor: 'border-l-4 border-green-600 dark:border-green-500'
|
|
};
|
|
case 'fail':
|
|
case 'permerror':
|
|
return {
|
|
color: 'text-red-700 dark:text-red-400',
|
|
icon: 'x',
|
|
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
|
borderColor: 'border-l-4 border-red-600 dark:border-red-500'
|
|
};
|
|
case 'softfail':
|
|
case 'neutral':
|
|
case 'temperror':
|
|
return {
|
|
color: 'text-warning',
|
|
icon: 'alert',
|
|
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
|
borderColor: 'border-l-4 border-warning'
|
|
};
|
|
default:
|
|
return {
|
|
color: 'text-gray-700 dark:text-gray-400',
|
|
icon: 'minus',
|
|
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
|
borderColor: 'border-l-4 border-gray-400 dark:border-gray-600'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse X-Spam-LLM header to extract AI verdict and explanation
|
|
*/
|
|
export function parseSpamLLM(header: string): { verdict: string; explanation: string } | null {
|
|
// Format: "LEGITIMATE (explanation)" or "SPAM (explanation)"
|
|
// Trim the header first to remove any leading/trailing whitespace
|
|
const trimmed = header.trim();
|
|
const match = trimmed.match(/^(LEGITIMATE|SPAM|SUSPICIOUS)\s*\((.+)\)\s*$/i);
|
|
|
|
if (match) {
|
|
return {
|
|
verdict: match[1].toUpperCase(),
|
|
explanation: match[2].trim()
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract list headers (List-Unsubscribe, List-Id, etc.)
|
|
*/
|
|
interface ListHeaders {
|
|
listId?: string;
|
|
listUnsubscribe?: {
|
|
http?: string;
|
|
mailto?: string;
|
|
preferred?: 'http' | 'mailto';
|
|
};
|
|
listHelp?: string;
|
|
listPost?: string;
|
|
}
|
|
|
|
export function extractListHeaders(headers: Record<string, string | string[]>): ListHeaders {
|
|
const result: ListHeaders = {};
|
|
|
|
if (headers['List-Id']) {
|
|
result.listId = Array.isArray(headers['List-Id'])
|
|
? headers['List-Id'][0]
|
|
: headers['List-Id'];
|
|
}
|
|
|
|
if (headers['List-Unsubscribe']) {
|
|
const unsub = Array.isArray(headers['List-Unsubscribe'])
|
|
? headers['List-Unsubscribe'][0]
|
|
: headers['List-Unsubscribe'];
|
|
|
|
const parsed = parseUnsubscribeUrls(unsub);
|
|
if (parsed.preferred) {
|
|
result.listUnsubscribe = parsed;
|
|
}
|
|
}
|
|
|
|
if (headers['List-Help']) {
|
|
result.listHelp = Array.isArray(headers['List-Help'])
|
|
? headers['List-Help'][0]
|
|
: headers['List-Help'];
|
|
}
|
|
|
|
if (headers['List-Post']) {
|
|
result.listPost = Array.isArray(headers['List-Post'])
|
|
? headers['List-Post'][0]
|
|
: headers['List-Post'];
|
|
}
|
|
|
|
return result;
|
|
} |