/** * Email service, config, secret resolution, and orchestration. * * Config namespace: email.* * ───────────────────────── * The ConfigKey union is sealed and does not include email keys. * Email settings are accessed via ensureEmailConfigDefaults(), which * injects the email section into the ConfigManager's live config object * before first use. This follows the same pattern as other categories that * extend beyond the built-in schema. * * Settings registered: * email.enabled boolean , feature gate (default: false) * email.imapHost string , IMAP TLS host (default: '') * email.imapPort number , default 993 * email.smtpHost string , SMTP submission host (default: '') * email.smtpPort number , 465 (TLS) or 587 (STARTTLS, default) * email.username string , login username (default: '') * email.passwordRef string , goodvibes:// secret reference only; * NEVER a raw password * email.smtpPasswordRef string , optional; a second goodvibes:// reference * for providers that issue separate SMTP * credentials. Empty means "same password" * email.fromAddress string , From: address for outbound mail * email.mailbox string , mailbox to read; empty means INBOX * email.draftsMailbox string , Drafts folder; empty means ask the server * * Secret resolution * ───────────────── * `email.passwordRef` must be a goodvibes secret reference string. * The service calls `secretsManager.get(resolvedKey)` using the same * SecretsManager instance the product already has wired. * Plaintext passwords in config are rejected at validation time. * * Everything is injected * ────────────────────── * Not a line here opens a socket, reads a file or reaches for a global. The * transports (`EmailTransportPort`), the config reader, the secret store, the * sender-claim describer and the untrusted-ingest recorder all arrive as ports, * so the whole service runs against fakes with no machine. The concrete * bun/node transport lives in the sibling `email/node` entry. */ export { EmailCredentialUnavailableError, readEmailConfig, resolveEmailPassword, smtpPasswordRefFor, validateEmailConfig, } from './email-config.js'; import type { ImapAppendDraftResult, ImapMessageDetail } from './imap-client.js'; import type { EmailInboxUnreadableResponse, EmailMessageRead } from './email-read-results.js'; import type { SmtpSendResult } from './smtp-client.js'; import type { EmailSenderClaim, EmailSenderClaimDescriber } from './sender-claim.js'; import type { Socket } from 'node:net'; export type { EmailInboxUnreadableResponse, EmailMessageRead } from './email-read-results.js'; /** * Inject the email config section into the ConfigManager's live config * object if it is not already present. * * DEFAULT_CONFIG does not include an email section. ConfigManager.resolvePath() * walks the live config object and throws for any section that does not exist. * Calling this helper once before any email.* access ensures the traversal * succeeds. * * The helper is safe to call multiple times, it is a no-op after the first * call for a given configManager instance. */ export declare function ensureEmailConfigDefaults(configManager: object): void; /** SMTP connection security mode. 'auto' = port-based default (465→tls, else starttls). */ export type SmtpSecurityMode = 'tls' | 'starttls' | 'auto'; /** * IMAP connection security mode. 'tls' (default) is implicit TLS on the IMAP * port; 'plaintext' is an unencrypted connection, legitimate for a localhost or * test server, and what `surfaces.email.imap.secure: false` selects. No 'auto': * the operator either asked for TLS or asked not to have it. */ export type ImapSecurityMode = 'tls' | 'plaintext'; export interface EmailConfig { readonly enabled: boolean; readonly imapHost: string; readonly imapPort: number; /** * IMAP connection security. Absent means 'tls', which is what every config * written before this field existed means. `readEmailConfig` always populates * it; it is optional so that adding it does not break an embedder that builds * an `EmailConfig` itself. */ readonly imapSecurity?: ImapSecurityMode | undefined; readonly smtpHost: string; readonly smtpPort: number; /** SMTP connection security. Default: 'auto' (port-based). */ readonly smtpSecurity: SmtpSecurityMode; readonly username: string; /** Secret reference string, never a raw password. */ readonly passwordRef: string; /** * Secret reference for the SMTP password, when the provider issues one that * differs from the IMAP password. Empty, the common case, means submission * authenticates with `passwordRef` like everything else. */ readonly smtpPasswordRef: string; readonly fromAddress: string; /** * Mailbox to read. Empty, the common case, means INBOX. Set when the * account delivers to a folder, which is what a per-signup alias mailbox is. */ readonly mailbox: string; /** * Drafts folder. Empty means "ask the server", which is the better answer: * discovery reads the `\Drafts` special-use flag and gets `[Gmail]/Drafts` * right where a hard-coded `Drafts` silently creates a stray folder. Set it * only when the server does not advertise one. */ readonly draftsMailbox: string; } export interface EmailSummary { /** * The IMAP UID this message is read back by. Carried through from the * envelope because a listing whose entries cannot be opened is a listing * nobody can act on. * * A UID, and never a sequence number: a listing is read from later, and a * sequence number stops naming the same message as soon as anything below it * is expunged. */ readonly uid: number; /** The `Message-ID` header, for threading and correlation. '' when absent. */ readonly messageId: string; readonly from: string; readonly subject: string; readonly date: string; readonly unread: boolean; /** First ~4 KB of the plain-text body, fetched read-only. Empty string when unavailable. */ readonly bodyPreview: string; /** * The mailbox this message was fetched from. Delivery evidence: a message * cannot be talked into arriving in a mailbox that exists only for one * signup, which is what makes per-signup aliases worth minting. */ readonly mailbox: string; /** * Delivery-agent trace, top-most first. Written by the receiving mail * server, so, unlike `To:`, a sender cannot set it. Safe to correlate on. */ readonly deliveredTo: readonly string[]; /** * The `To:` header verbatim. **Display only, never evidence.** The sender * writes this field, so it proves nothing about where the message landed. * Named so that correlating on it reads as obviously wrong. */ readonly unverifiedToHeaderClaim: string; /** * The `From:` line described as a CLAIM, carrying the receiving server's * sender-authentication verdict as DISPLAY confidence. * * `senderClaim.commandAuthority` is the literal `'none'` and cannot hold any * other value. A message that passes DKIM, SPF and DMARC and writes the * owner's own address in its From header gets a more confident sentence for * a human to read, and exactly the same authority as a stranger's: none. */ readonly senderClaim: EmailSenderClaim; } /** * Result of a connection verification pass (a connect-wizard "test connection" * step). Never includes the raw password; `error` messages come from the * underlying client's plain-language exceptions. */ export interface EmailConnectionTestResult { readonly ok: boolean; /** Which stage failed, when ok is false. 'config' means validation failed before any connection was attempted. */ readonly stage?: 'config' | 'imap' | 'smtp'; readonly error?: string; } export interface SendMailOptions { readonly to: string; readonly subject: string; readonly body: string; /** Must be true at the call site; the service rejects sends without it. */ readonly confirm: boolean; } /** What to list, for `listInbox`. Every field is optional. */ export interface EmailInboxListInput { /** Maximum messages to return. Default: 10. */ readonly limit?: number | undefined; /** Restrict to messages the server dates on or after this day. */ readonly since?: Date | undefined; /** * Unread messages only. Default: true, the historical behaviour of * `checkInbox`. Setting it false lists everything, which is a different * SEARCH, not the same one filtered afterwards. */ readonly unreadOnly?: boolean | undefined; } export interface EmailInboxListResult { /** * The matched messages, **newest first**, capped at `limit`. * * The order is part of the contract rather than an accident of how IMAP * answers a search, because it WAS an accident before and two consumers * disagreed about it: one rendered the array as-is and so showed the newest * page with the oldest message at the top, the other re-sorted client-side * on the `Date:` header. A daemon that does not define an order makes every * consumer invent one, and one of those inventions sorted on a field the * sender writes. * * Ordered by UID, which the receiving server assigns, and never by `Date:`, * which whoever sent the message wrote, a forged date must not be able to * pin a message to the top of the owner's inbox. */ readonly messages: readonly EmailSummary[]; /** * How many messages MATCHED, before `limit` truncated the list. * * Deliberately not `messages.length`: a caller needs to be able to tell "that * is all of them" from "that is the first ten", and a total that always * equalled the page size would say there is never any more mail. */ readonly total: number; /** * FETCH responses on THIS page the client could not read. Absent means none. * * Here because a short page used to be silent, see * `EmailInboxUnreadableResponse` for the two facts that were being collapsed. * `total` cannot carry it: `total` counts the SEARCH match, and the loss * happens at the FETCH. Omitted when empty, so nothing consuming this shape * today sees a change; a caller that wants to know reads `unreadable?.length`. */ readonly unreadable?: readonly EmailInboxUnreadableResponse[] | undefined; } /** * The fields a draft is composed from. `from` is the only optional one, * omitting it uses the configured `email.fromAddress`, which is what a caller * that is not choosing an identity should do. */ export interface EmailDraftInput { readonly to: string; readonly subject: string; readonly body: string; /** Defaults to `email.fromAddress`. */ readonly from?: string | undefined; readonly inReplyTo?: string | undefined; readonly references?: string | undefined; /** Overrides Drafts-folder discovery. */ readonly mailbox?: string | undefined; } /** Opens one transport connection to a mail host. */ export type EmailSocketFactory = (host: string, port: number) => Promise; /** * The real connections this service can need. * * A port rather than a direct call so that the service half never imports * `node:tls`: the concrete implementation is `nodeEmailTransport` in the * sibling `email/node` entry, and a test supplies one that throws. */ export interface EmailTransportPort { /** IMAP over implicit TLS (port 993). */ readonly connectImapTls: EmailSocketFactory; /** * IMAP over a plain, unencrypted connection. Reached only when * `surfaces.email.imap.secure` is false. OPTIONAL because embedders implement * this public type and every existing one predates the member; one that omits * it is refused by name rather than quietly upgraded back to TLS. */ readonly connectImapPlain?: EmailSocketFactory | undefined; /** SMTP submission over implicit TLS (port 465). */ readonly connectSmtpTls: EmailSocketFactory; /** SMTP submission over a plain connection upgraded with STARTTLS (port 587). */ readonly connectSmtpStartTls: EmailSocketFactory; } export interface EmailServiceDeps { /** Untyped config getter, reads the `email.*` namespace. */ readonly getConfig: (key: string) => unknown; /** SecretsManager-compatible interface for resolving secret refs. */ readonly secretsManager: { readonly get: (key: string) => Promise; }; /** The real connections. Required: this module never opens one itself. */ readonly transport: EmailTransportPort; /** * Describes a `From:` header as a claim, for display. * * Injected because the wording of a trust boundary belongs to the surface * that renders it, and because a second copy in the SDK would drift from the * product's own. Its `commandAuthority` is the literal `'none'`; see * `sender-claim.ts`. */ readonly describeSenderClaim: EmailSenderClaimDescriber; /** Optional socket factory override for IMAP (injected in tests). */ readonly imapSocketFactory?: EmailSocketFactory; /** Optional socket factory override for SMTP (injected in tests). */ readonly smtpSocketFactory?: EmailSocketFactory; /** * Records that untrusted content entered the conversation. * * Reading a mailbox pulls in text written by anyone who knows the address, * which is the same exposure as loading a web page, and the outward-effect * guard only fires on exposure it has been told about. Injected rather than * reached for globally so the service stays testable and so a caller cannot * accidentally record into a different session's ledger. */ readonly recordUntrustedIngest?: (ingest: { readonly surface: 'email'; readonly origin: string; readonly at: string; /** * The message text that was read. * * Without it the guard downstream can only ask "has this process read * mail", which in a daemon is permanently true and therefore decides * nothing. With it, an outward action can be checked for DERIVATION from * this message, which is the owner's named threat: an injection arriving * by email. */ readonly content?: string | undefined; }) => void; } export declare class EmailService { private readonly deps; constructor(deps: EmailServiceDeps); /** Returns a redacted status summary, never includes secret values. */ getStatus(): { config: EmailConfig; errors: string[]; ready: boolean; }; /** * Fetch up to `limit` unread inbox summaries. * Messages are read via EXAMINE (read-only); unread flag is never modified. * * The unread-only listing, unchanged. `listInbox` is the general form. */ checkInbox(limit?: number): Promise; /** * List the inbox, NEWEST FIRST: unread only by default, everything when * `unreadOnly` is false, optionally bounded by a date. * * Read-only throughout, the mailbox is EXAMINEd and every fetch peeks, so * listing mail never marks it read. Returns the matched `total` alongside * the truncated page. */ listInbox(input?: EmailInboxListInput): Promise; /** * Read one whole message by UID, or null when it is no longer there. * * Read-only (BODY.PEEK throughout) and attachment-metadata only. The full * body is MORE attacker-controlled text than a preview, not less, so it * records the same untrusted ingest the listing does, one path into the * product, one labelling. * * **`null` means gone, and only gone.** It used to mean gone OR "the server * answered and this client could not read the answer", and the one caller of * this method turns `null` into the sentence "no message with UID n is in * the mailbox, it may have been moved or deleted since it was listed", * which in the second case is a false statement about the owner's mailbox. * An unreadable answer now THROWS, carrying what could not be read, because * every honest thing this signature can say about that case is "not the * message" and a caller has to be able to tell that apart from an expunge. * `readMessageResult` is the same read without the throw, for a caller that * would rather branch than catch. */ readMessage(uid: number): Promise; /** * The same read as `readMessage`, with "gone" and "could not be read" as * separate outcomes instead of one thrown error and one null. */ readMessageResult(uid: number): Promise; /** * Save a draft to the Drafts folder. Nothing is sent: a draft is the outcome * that leaves the decision to send with the owner. * * `from` defaults to the configured `email.fromAddress`. The folder is * discovered from the server's own `\Drafts` flag rather than guessed. */ createDraft(input: EmailDraftInput): Promise; /** * Record that mail text entered the conversation. * * Reading a mailbox is an untrusted ingest, exactly as loading a web page is: * the text was written by whoever chose to send it. The outward-effect guard * can only weigh exposure it has been told about, so it is told here rather * than after something has already been sent. * * Origin is the CLAIMED sender domain, and is labelled as claimed wherever it * surfaces. It is a useful label for the owner, never an identity check, the * claim is why the content is untrusted, not a reason to trust it. */ private recordIngest; /** * Verify the configured IMAP and SMTP connections without sending mail or * reading the inbox, a real connectivity + authentication check for a * connect-wizard "test connection" step. Does not require config.enabled; * callers that want to gate readiness on enabled should check separately. * * Never throws, returns a result describing which stage (if any) failed, * with a plain-language error message. Never includes the raw password. */ testConnection(): Promise; /** * Send a plain-text email. * Requires `confirm: true` at the call site, throws without it. * * Returns the `Message-ID` the sent message carried and the instant the * server accepted it, both taken from the send itself. A caller that needs * to say what it sent gets the real values rather than inventing an id that * matches nothing in the owner's mailbox. */ sendMail(opts: SendMailOptions): Promise; private getValidatedConfig; private defaultSmtpSocketFactory; } //# sourceMappingURL=email-service.d.ts.map