Connected email accounts
This capability is available for demo purposes only. Its navigation entry is feature-flagged and hidden in the production environment.
Brokers can connect their email accounts (Gmail or Outlook) to Korint. There is no dedicated page: accounts are managed from the general inbox (Emails → Boîte de réception), in a side drawer that opens over the inbox, so closing it returns the broker to where they were.
The mailbox selector at the top of the inbox lists the accounts with their status and unread count, and ends with two actions: Connecter une boîte opens the drawer on the connection funnel, Gérer les comptes… opens it on the account list. When the broker has no account at all, the inbox shows an empty state with the same Connecter une boîte button.
What a broker sees
The drawer requires an authenticated broker within a tenant, and it lists up to two groups, each shown only when it has accounts:
- Personnelles: the broker's personal accounts (
isShared: false). - Partagées: the brokerage firm's shared accounts (
isShared: true), including those connected by the logged-in broker. Shared accounts belong to the firm; each card shows who last connected the account.
The groups use the sharing mode, not the identity of the person who connected the account. Within each group, accounts are ordered by connection date, oldest first. Every card shows the provider logo, the email address and a status indicator for the connection health.
Connect and manage accounts
Connecter une boîte first asks who will see the mailbox: Personnelle or Partagée. Brokers without the shared-account management permission skip this question and connect a personal mailbox. The next step lists the providers: Gmail and Outlook, plus Boîte partagée Microsoft 365 for a shared connection only. Choosing Gmail or Outlook sends the broker to the provider; the outcome shows as a toast when they come back to the inbox.
A Microsoft 365 shared mailbox has no login of its own, so this choice first asks for the mailbox address, then sends the broker to Microsoft to sign in with their own account, which must have full access to that mailbox. Korint binds that sign-in to the shared mailbox: the connected account carries the mailbox address, not the broker's, and its mail is synchronized directly. Reconnecting such an account repeats the same delegated sign-in.
You can disconnect your personal accounts from the card's ⋯ menu, and
reconnect them from the card when their status is error. Shared-account
management requires the firm's EMAIL_ACCOUNT_SHARED_ADMIN permission, even
for the broker who originally connected the account. A permitted administrator
can disconnect or reconnect a shared account connected by a colleague. A
disconnected status indicates an automatic retry and does not show the
reconnect action.
A mailbox can be connected only once per brokerage firm for a given provider and email address. To change its sharing mode, disconnect it, then connect it again with the other type. This reuses the deleted account and applies the selected sharing mode to its history.
Feature flag and environment policy
Visibility is controlled by the feat_connected_email_accounts feature flag,
resolved through the in-house feature-flag registry. The flag is a row in the
FeatureFlag table, evaluated per environment, and defaults to disabled when no
row is present.
Because each environment has its own FeatureFlag table, the section is enabled
where it is wanted (local, staging, review environments) and left disabled in
production, where no row is created. Operators who wonder why the
Emails navigation is or is not visible should check this flag in the target
environment.
Gateway endpoint
The list is served by GET /email-accounts/@me. It resolves the current broker
from the authenticated session and the tenant, and returns the broker's own
accounts plus every account shared within the broker's brokerage firm. The
response also supplies canManageSharedAccounts, which controls the shared
section's management actions, and connectedBy on each account.
Both add buttons use POST /email-accounts/connect with the selected provider,
the frontend return URL, and isShared. A Microsoft 365 shared mailbox adds
delegatedMailboxAddress; it is accepted with the outlook provider only and
rejected with DELEGATED_MAILBOX_REQUIRES_OUTLOOK otherwise. Reconnection uses
POST /email-accounts/:emailAccountId/reconnect and preserves the sharing mode;
disconnection uses DELETE /email-accounts/:emailAccountId. The server enforces
the permissions independently of the UI. These endpoints are internal and are
not part of the public partner API.
How connected messages are synchronized
You can receive message metadata before its body is available. Korint ingests mailbox events and retrieves bodies asynchronously; there is no public API operation to trigger body retrieval.
- A
messageNewevent ingests the message only when its account is active and has not been deleted. If account creation is still pending, processing fails so the background job can retry. - Ingestion stores message metadata and attachment metadata, with
bodyHtmlinitiallynullandbodyStatusset topending. Drafts are skipped; Outlook messages in draft, junk, or trash folders are also skipped. - Replaying an ingested message updates its read state without duplicating the
email or its attachments. When
bodyStatusispending, ingestion queues a body-fetch job with up to five attempts. - A
messageUpdatedevent changes a known email's read state only when it contains a read-state change. For an unknown email, Korint fetches its current metadata and runs ingestion, including the account and folder checks. A message that no longer exists at the provider is ignored. - Inactive or deleted accounts skip new-message and message-update processing.
A
messageDeletedevent leaves the stored email in Korint.
How message bodies become available
The internal FETCH_EMAIL_BODY command uses fetchMessageBody to retrieve the
message without marking it as read. It stores EmailEngine's web-safe HTML in
Email.bodyHtml, without embedding attached images. If only plain text is
available, Korint escapes HTML characters and wraps the text in <pre> tags.
A message with no body stores an empty string, which counts as a completed
retrieval.
bodyStatus | Meaning |
|---|---|
pending | The body is awaiting retrieval or a retry. |
stored | Retrieval succeeded, including an empty body. Stored bodies are not fetched again. |
unavailable | The provider returned HTTP 404: the message is gone. The job completes without retrying. |
failed | Retrieval failed on the last known attempt. |
Other retrieval errors are recorded in bodyError and propagated for retry;
bodyHtml remains null. Without retry metadata, an error is not classified as
the final attempt. A successful retrieval clears bodyError.
Message responses larger than 10 MiB are rejected through the same error path. Korint does not truncate the response or save a partial body: truncating markup before sanitization can turn a non-empty message into an empty body.
General inbox
The Emails → Boîte de réception page lists conversations (threads), not individual emails. A thread is the set of emails of one account sharing the provider's thread id, or a single email when the provider gives none; the same conversation received by two connected accounts makes two threads. Nothing is stored per thread: the list is computed from the emails when it is read.
GET /email-threads returns the threads to process by default
(mode=to_process): those with at least one email that is not archived and has
no active linked row in EmailEntityLink. mode=processed returns the
complement, threads whose every email is archived or linked, so a thread is in
exactly one mode. In processed mode only, processedFilter=linked keeps threads
with at least one linked email and processedFilter=archived those with at
least one archived email; a thread with both appears under both. Sending
processedFilter in the other mode is a 400. unread=true keeps threads with
at least one unread email that is not archived.
Threads come newest first, ordered by their latest email, and page through an
opaque nextCursor. accountIds (repeatable) narrows the list to some
mailboxes; omitted, it covers every mailbox the caller may list, and ids outside
that set are ignored.
Every broker sees the threads of their own personal accounts. Brokers with the shared-account admin permission also see the firm's shared accounts. Colleagues' personal accounts are never listed. Disconnected accounts keep their threads.
Each thread shows its latest email (counterpart, subject, excerpt, date,
attachment flag), whether any of its non-archived emails is unread, toProcessCount,
isArchived (every email archived), and the
policies linked to any of its emails with their policy number. Its id is
opaque and never changes: it encodes the account and the thread key, since the
same thread key can exist in two accounts. Listing never marks an email as read.
Archiving threads or emails
Archiving is a broker decision only: linking an email to a policy, automatically
or manually, never writes archivedAt. PATCH /email-threads with
{ "threadIds": [...], "archived": true } archives every email of those
threads in one write (up to 500 threads); adding emailIds narrows it to those
emails, and ids outside the threads are ignored. false unarchives the same
way. Archiving an archived email keeps its first archivedAt. The response
lists the emailIds whose state changed, which is what the inbox's "Annuler"
toast sends back with the opposite value.
A broker archives in their own personal accounts; the firm's shared accounts
require the shared-account admin permission. One malformed, unknown or invisible
thread id rejects the whole request with 400. Archiving is a Korint state only: nothing is written to
the mailbox. A new email arrives unarchived, so it brings its thread back to
process.
GET /general-inbox/summary returns the sidebar badge, unreadThreadCount: the
threads to process with at least one unread, non-archived email, over the same mailboxes. Like the other endpoints of this capability, both
are internal and not part of the public partner API.
Reading an email
GET /emails/:emailId returns one stored email for the general inbox: its
addresses, bodyStatus and bodyHtml, and its attachments with their storage
status. The caller must be an authenticated broker, and the email must belong to
one of the broker's own accounts or to an account shared within the broker's
brokerage firm, with or without the shared-account admin permission; only the
inbox list reserves shared accounts to admins. Disconnected, errored and deleted
accounts keep their stored emails readable. An unknown or invisible email answers
HTTP 400, so the response does not reveal whether the email exists.
Opening an unread email marks it read in Korint and queues an
email.mark_as_seen job, five attempts with exponential backoff, that adds the
\Seen flag through EmailEngine; a message that no longer exists at the
provider counts as done. The job is queued before the local flag is written: a
failed request can leave a mailbox write without a local flip, which the next
messageUpdated event repairs, but never a local flip without a mailbox write.
There is no "mark as unread"; the mailbox remains the source of truth and later
flag changes are mirrored by the synchronization described above.
GET /email-threads/:threadId opens a whole thread: every email, oldest first,
with its archive state, whether it is still to process (same rule as
toProcessCount) and its linked policies, but no body. It uses the same
visibility as GET /emails/:emailId, answers 400 for a malformed, unknown or
invisible thread id, and marks every unread email of the thread read with one
email.mark_as_seen job each, in the same order. The frontend then reads each
body through GET /emails/:emailId as the email scrolls into view.
Each attachment carries status (pending, stored, failed, skipped) and
failureReason. A stored inline attachment also carries inlineUrl, a presigned
URL valid for five minutes that the frontend substitutes for the matching cid:
reference in the body. Regular attachments are downloaded through
GET /emails/:emailId/attachments/:emailAttachmentId/download-url. Like the
other endpoints of this capability, it is internal and not part of the public
partner API.
Policy exchanges
The policy dashboard's Échanges tab shows whole conversations as soon as one email has an active link to the policy. Every broker with policy access sees their own mailboxes and the firm's shared mailboxes, including disconnected history. Colleagues' personal mailboxes remain private.
The tab loads GET /email-threads?policyId=…, including the metadata for every
email, without marking anything read. Conversations appear newest first; emails
within each conversation appear oldest first. An icon identifies emails that are
not linked to this policy, and each row shows its mailbox connection status.
search keeps the threads with an email whose subject, participant name or address,
or body text contains the term, case-insensitively. Accents are not folded and only
common HTML entities are decoded. It requires policyId; without it the call is a 400.
All emails start collapsed. You can expand several independently: each expansion
reads that email through GET /emails/:emailId, leaving the others' read state
unchanged. Link reasons remain stored for traceability and are not displayed.
An expanded email lists its linked policies and lets the broker edit them: attach the email to a policy, detach it, or move a link to another policy. Each edit targets that single email through the manual link commands below; its other links are kept, and the conversation leaves the tab once no email remains linked to the policy.
How emails are linked to policies
Manual decisions apply to individual emails. Analysis requests cover a thread
within one connected account. When the body job reaches a
terminal bodyStatus (stored, unavailable or failed), the same
transaction records an analysis request for the message's thread in
EmailThreadAnalysis. The thread key is the provider's thread id, or the email
id when the provider gives none. Each request increments requestedVersion; a
future linking run must acknowledge only the version it read, so a message that
arrives during a run remains pending. Replaying an already stored body creates a
missing request without fetching the body again.
The internal email commands let you attach one email to several policies, detach one policy, or move a link between two policies atomically. Each command checks email visibility and access to every policy before changing links. A move requires an existing source link and a different target policy. None of these commands changes the email's archive or read state.
Two batch commands serve the inbox selection. POST /email-threads/policies
links every email of up to 500 threads still to attach (not archived, no active
link, including a manual exclusion) and returns their ids; POST /emails/policies/detach records an exclusion for each email and policy pair,
which is how the inbox undoes a batch attach. Each runs in one transaction that
locks the affected threads in a fixed order.
Decisions and their thread-analysis request commit together. A successful command confirms the individual email's manual decisions; propagation to later messages runs asynchronously. There is no undo command.
Decisions are stored in EmailEntityLink, one row per email, entity and decision:
status | Meaning |
|---|---|
candidate | Evidence for a link was observed, but the entity is not attached. |
linked | The entity is attached to the email. |
detached | The policy is manually excluded from the email. |
reasons stores the observed evidence; manual commands retain each pair's reasons.
A status change soft-deletes the active row and inserts a new one, so an email
has at most one active row per entity and its history stays queryable.
decidedByUserId and decidedAt identify a manual decision. Manual decisions
always take precedence: the linking run never overwrites them,
including when a broker corrects a link while analysis is running.
Automatic policy attachment
Analysis processes the whole conversation within one connected account, from the oldest email to the newest. Without a provider conversation identifier, each email is its own conversation. Body download completion and manual link corrections request another pass; reading an email does not.
The first matching rule supplies every eligible policy:
- Policies observed on earlier emails, including their active manual attachments.
- Current external policy references or contract numbers in the subject and full visible body, including forwarded text, quotations and signatures.
- Customer email addresses: From for received messages, To, Cc and Bcc for sent messages. The connected mailbox's own address is excluded.
Number matching ignores case but preserves punctuation, internal spaces and leading zeros. Attachment contents are not searched. Customer addresses come from configured email fields, for every customer role. Matching stays in the account owner's tenant and brokerage portfolio, including a wholesaler's retail firms. Quotes, subscribed and stopped policies are eligible; deleted policies are excluded. Quotes use their open new-business branches until subscription, then the current base policy.
A manual detachment excludes that policy from its email onward. Later observations
remain stored as candidate rows; exclusions never cause a fallback to a lower
matching rule. A later manual attachment lifts the exclusion from its own position
onward. Automatic attachment does not archive emails.
The inbox shows an analysis indicator while a requested pass is unacknowledged and has retries remaining. Exhausting the retries removes the indicator; a new analysis request displays it again. An email leaves “À traiter” once it has an active link. Analysis failures remain recorded for investigation; jobs retry up to eight attempts, and a periodic recovery sweep repairs interrupted handoffs without resetting an exhausted request's budget. An account without an owning brokerage firm records an analysis error and creates no automatic links.
Alerting the broker who connected the account
When a connected account stops syncing because its authorization has expired or
been revoked, EmailEngine reports an authenticationError and Korint marks the
account as error. On the transition into that state, the broker who connected the
account receives an email at their Korint login address inviting them to
reconnect the mailbox, with a link to the general inbox.
The alert fires once per transition: EmailEngine re-emits the error on every
retry and webhook deliveries can be replayed, but a conditional write lets only
the job that actually flips the account to error send the email. The alert is
gated on the error status alone, so a disconnected account raises none:
EmailEngine reconnects a disconnected account on its own with backoff, so it
needs no broker action.
Like the rest of this capability, the alert is configured for the demo tenant only, and it is delivered through the internal SES transport.