## Summary
- Add email forwarding as a new message channel type, allowing users to
forward emails from addresses like `support@mycompany.com` into Twenty
- Inbound emails arrive via S3 (SES → S3 bucket), are polled by a cron
job, parsed, routed to the correct workspace/channel, and persisted as
messages
- Dedicated settings page at `/settings/accounts/new-email-forwarding`
where users provide their source email handle and receive a unique
forwarding address
- Forwarding channels bypass the IMAP/mailbox sync state machine — they
skip cron-driven sync, relaunch, and message-list-fetch lifecycle stages
- Forwarding address section shown at the top of the Emails settings
page so users can find/copy their addresses after initial setup
- Tab names for forwarding channels display the user-provided handle
(e.g. `support@mycompany.com`) instead of the internal routing address
- Shared utilities extracted from IMAP driver: `extractThreadId`,
`extractParticipants`, `extractAddresses` to avoid code duplication
- Uses the existing S3 bucket (STORAGE_S3_*) with `inbound-email/`
prefix — no separate bucket needed
- Feature gated behind `isEmailForwardingEnabled` client config
(requires `INBOUND_EMAIL_DOMAIN` + S3 storage)
## New backend modules
- `InboundEmailS3ClientProvider` — lazy-initialized S3 client using
existing storage config
- `InboundEmailStorageService` — S3 operations (get, move to
processed/unmatched/failed)
- `InboundEmailParserService` — RFC 822 parsing via `postal-mime`,
builds `MessageWithParticipants`
- `InboundEmailImportService` — orchestrates download → parse → route →
persist → archive
- `MessagingInboundEmailPollCronJob` — polls S3 `incoming/` prefix,
enqueues import jobs
- `CreateEmailForwardingChannelInput` DTO — accepts user-provided
`handle`
## New frontend components
- `SettingsAccountsNewEmailForwardingChannel` — dedicated page with
handle input form + forwarding address result
- `SettingsAccountsEmailForwardingSection` — forwarding address list on
the Emails settings page
- `useConnectedAccountHandleMap` — shared hook for account ID → handle
lookup
- `useCreateEmailForwardingChannel` — mutation hook accepting handle
parameter
## Test plan
- [x] 17 unit tests for inbound email import service (all outcomes:
imported, unmatched, loop_dropped, unconfigured, parse_failed,
persist_failed)
- [x] 16 tests for `computeSyncStatus` including EMAIL_FORWARDING cases
- [x] 11 tests for `extractEnvelopeRecipient` utility
- [x] TypeScript typechecks pass for both twenty-server and twenty-front
- [x] Lint passes for both packages
- [ ] Manual: create forwarding channel, verify forwarding address
generated
- [ ] Manual: send email to forwarding address, verify it appears in
Twenty
https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
## The bug
Claude's MCP connector fails with \"Couldn't reach the MCP server\" on
every URL (\`api.twenty.com/mcp\`, \`app.twenty.com/mcp\`,
\`<workspace>.twenty.com/mcp\`, custom domains). The failure happens
**before** any OAuth flow starts — the client never even reaches the
consent screen.
## Root cause
\`POST /mcp\` unauthenticated returns:
\`\`\`
HTTP/2 401
access-control-allow-origin: *
www-authenticate: Bearer
resource_metadata=\"https://…/.well-known/oauth-protected-resource\"
content-type: application/json; charset=utf-8
(no access-control-expose-headers)
\`\`\`
The [Fetch/CORS
spec](https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name)
defines only six response headers as safelisted — \`Cache-Control\`,
\`Content-Language\`, \`Content-Type\`, \`Expires\`, \`Last-Modified\`,
\`Pragma\`. Every other header is withheld from cross-origin JS unless
the server opts it in via \`Access-Control-Expose-Headers\`.
Result: Claude's browser-side MCP client receives the 401 but
\`response.headers.get('WWW-Authenticate')\` returns \`null\`. No
\`resource_metadata\` URL, no discovery, no OAuth — the client gives up
with the generic \"can't reach server\" error.
The [MCP authorization
spec](https://modelcontextprotocol.io/specification/draft/basic/authorization)
explicitly requires this header to be exposed.
## Fix
One config change in \`main.ts\`:
\`\`\`ts
- cors: true,
+ // Expose WWW-Authenticate so browser-based MCP clients can read the
+ // resource_metadata pointer on 401. Required by MCP authorization
spec.
+ cors: { exposedHeaders: ['WWW-Authenticate'] },
\`\`\`
NestJS's default \`cors: true\` uses the \`cors\` package defaults,
which don't set \`exposedHeaders\`. Moving to an explicit config keeps
all other defaults (origin \`*\`, standard methods) and adds the single
required expose.
## Why it's safe and generally beneficial
- \`Access-Control-Expose-Headers: WWW-Authenticate\` is sent on every
response but only has an effect when \`WWW-Authenticate\` is actually
present (i.e. 401s). It's an opt-in permission, not a header-setter.
- \`WWW-Authenticate\` itself is still only set by \`McpAuthGuard\` on
401 — this PR doesn't change where or when the header is emitted.
- Covers the entire app, not just \`/mcp\` — any future 401-returning
endpoint will behave correctly for browser clients automatically.
- No change to origin handling, methods, or credentials. All existing
API / GraphQL / REST traffic is unaffected.
## Verification
After deploy:
\`\`\`bash
curl -sI -X POST -H \"Origin: https://claude.ai\"
https://api.twenty.com/mcp \\
| grep -iE 'access-control-expose|www-authenticate'
# Expect:
# access-control-expose-headers: WWW-Authenticate
# www-authenticate: Bearer resource_metadata=\"…\"
\`\`\`
Then re-try adding the MCP connector in Claude — if this was the only
blocker, OAuth should now complete.
## Related
- #19755, #19766, #19824 — prior fixes in the MCP/OAuth discovery chain
(host-aware metadata, path-aware well-known, \`TRUST_PROXY\` for
\`request.protocol\`). This PR completes the CORS side of that work.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## The bug
Pasting `https://<workspace>.twenty.com/mcp` into an MCP client (Claude
connector, etc.) fails discovery. Curl shows why:
```bash
$ curl -si https://twentyfortwenty.twenty.com/.well-known/oauth-protected-resource
HTTP/2 200
...
{
\"resource\": \"http://{workspace}.twenty.com/mcp\",
\"authorization_servers\": [\"http://twentyfortwenty.twenty.com\"],
...
}
```
The response advertises `http://` even though the request came in on
`https://`. RFC 9728 / RFC 8707 require the client to validate that the
advertised `resource` matches the URL it connected to, so strict MCP
clients reject the mismatch and OAuth never starts.
## Why request.protocol returns \"http\"
Per [Express docs](https://expressjs.com/en/guide/behind-proxies.html),
`request.protocol` returns the socket-level protocol unless
`app.set('trust proxy', ...)` is configured. In our deployment:
```
client -- https --> Cloudflare -- https --> ingress-nginx -- http --> NestJS pod
```
TLS is terminated at the edge. The upstream TCP connection into the pod
is plain HTTP, and nginx sets `X-Forwarded-Proto: https` for the pod to
read. Without a `trust proxy` setting, Express ignores
`X-Forwarded-Proto` and `request.protocol === 'http'`.
`main.ts` currently has no `app.set('trust proxy', ...)` call anywhere.
## Why this only surfaced now
`grep -rn request.protocol` finds three pre-existing call sites —
`RestApiMetadataService`, `OpenApiService`, `RouteTriggerService`. All
three wrap it in `getServerUrl({ serverUrlEnv: SERVER_URL,
serverUrlFallback: \`${request.protocol}://${request.get('host')}\` })`,
which returns `SERVER_URL` whenever it's non-empty. In production
`SERVER_URL` is always set (e.g. \`api.twenty.com\`), so the
\`request.protocol\` branch is effectively dead code there.
#19755 introduced the first call site that uses `request.protocol`
unconditionally — the OAuth discovery controller has to echo the request
host, because the whole point is supporting multiple paste-able origins
(workspace subdomains, custom domains, etc.). That's why this is the
first \"wrong protocol\" bug anyone has seen in our app.
## The fix
One line in `main.ts`:
```ts
app.set('trust proxy', twentyConfigService.get('TRUST_PROXY'));
```
Backed by a new `TRUST_PROXY` env var with a default. `request.protocol`
then honors `X-Forwarded-Proto`, `request.ip` honors `X-Forwarded-For`,
etc. OAuth discovery URLs come out on the right scheme, and any future
`request.protocol` callers Just Work.
## Why this needs to be configurable (not hardcoded)
Twenty is open-source and deployed in at least three distinct
topologies:
1. **Kubernetes with ingress** (us, enterprise self-hosters) — TLS
terminated upstream, needs `trust proxy` **on**.
2. **Self-host behind a user-supplied reverse proxy** (Caddy, Traefik,
nginx — our [recommended
setup](https://twenty.com/developers/section/self-hosting)) — same as
above, needs `trust proxy` **on**.
3. **Self-host with NestJS exposed directly to the internet** — no
upstream proxy, needs `trust proxy` **off** (otherwise any curl with
`X-Forwarded-For: 1.2.3.4` spoofs `request.ip`, poisoning rate-limiters
and audit logs).
There is no single static value that's correct for all three. Express
makes this a setting for exactly this reason — we follow suit.
## Why the default is `'loopback, linklocal, uniquelocal'`
Shorthand for loopback (127/8, ::1), link-local (169.254/16, fe80::/10),
and unique-local (10/8, 172.16/12, 192.168/16, fc00::/7). In practical
terms: **trust peers coming from private networks; don't trust the
public internet**.
This default is correct for shapes 1 and 2 (cloud, proxied self-host)
because the ingress/proxy peer is always a private-network IP in every
sane deployment.
For shape 3 (directly exposed), the default is still safe because public
clients have public IPs, which are not in any of those ranges — so
`X-Forwarded-For` from an attacker on the internet is ignored. The only
way to be bitten is the exotic case where a public client reaches NestJS
through a private-network hop that isn't a proxy (e.g. a NAT appliance
that forwards to the pod on a private IP and blindly appends headers).
Narrow attack surface, and an operator running that kind of setup is
expected to configure `TRUST_PROXY=false` explicitly.
\"Safer than the naïve `true`, more useful than `false`\" — this matches
what Rails, Django, and many other frameworks recommend for
Kubernetes-style deployments.
## Why an env var instead of hardcoded
- Rejecting hardcoded `true`: would expose shape-3 self-hosters to IP
spoofing without a way to opt out.
- Rejecting hardcoded `false`: would leave cloud + shape-2 self-hosters
broken, same bug as today.
- Accepting string-typed env (not boolean): Express's `trust proxy`
accepts booleans, hop counts (`1`, `2`), IP ranges (`'10.0.0.0/8'`), and
named CIDRs (`'loopback'`). A boolean would hide that flexibility;
operators occasionally need the richer values. The string maps 1:1 onto
what Express accepts.
## Deployment matrix
| Deployment | Default works? | Override needed? |
|---|---|---|
| Cloud (us, K8s + nginx ingress + Cloudflare) | ✓ | — |
| Self-host behind reverse proxy (recommended) | ✓ | — |
| Self-host exposed directly on public IP | ✓ (public IPs not in private
ranges) | Optional: `TRUST_PROXY=false` for strictness |
| Local dev (direct, no proxy) | ✓ (no `X-Forwarded-*` headers arrive) |
— |
| Exotic: multi-hop through non-sanitizing private-network middlebox |
Risky | `TRUST_PROXY=false` |
## Related
- Blocks MCP connector OAuth on `<ws>.twenty.com` / custom domains.
After deploy: `curl -s
https://<ws>.twenty.com/.well-known/oauth-protected-resource | jq
.resource` should return `https://...` (not `http://...`).
- Fixes latent issue in `RestApiMetadataService`, `OpenApiService`,
`RouteTriggerService` fallback paths (pre-existing but dead in
production because `SERVER_URL` is always set — no behavior change
there).
## Test plan
- [x] `tsc --noEmit` clean
- [ ] After deploy: `curl -s
https://twentyfortwenty.twenty.com/.well-known/oauth-protected-resource`
returns `https://` URLs
- [ ] After deploy: MCP connector in Claude successfully completes OAuth
against `https://<ws>.twenty.com/mcp`
- [ ] No change in `request.ip` logging behavior on cloud (nginx-ingress
peer is already private-network, was already being trusted implicitly by
every framework layer that wasn't `request.protocol`)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Bumps
[@types/bytes](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/bytes)
from 3.1.4 to 3.1.5.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/bytes">compare
view</a></li>
</ul>
</details>
<br />
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
This PR fixes two critical bugs with the DATE type, following up the
recent refactor around dates and time zones :
https://github.com/twentyhq/twenty/pull/16544
There are a lot of related bugs in Sentry, this PR should fix all bugs
that are of the form :
`Cannot parse: 2026-02-04T00:00:00.000Z`
# `node-postgres` date type
The package `node-postgres`, used by TypeORM, returns by default a Date
object for the date OID type.
But we can change this behavior without patching anything.
See the documentation for `pg-types` :
https://github.com/brianc/node-pg-types
Our fix is to call `setTypeParser` from `pg` to have the postgres "date"
type returned as a string always.
```ts
export const setPgDateTypeParser = () => {
types.setTypeParser(PG_DATE_TYPE_OID, (val: string) => val);
};
```
Then in server's main.ts :
```ts
const bootstrap = async () => {
setPgDateTypeParser();
```
## Are we safe with this very low-level fix ?
Since TypeORM bypasses a string value returned by `pg` we are safe with
this modification at a very low-level.
```ts
else if (columnMetadata.type === "date") {
value = DateUtils.mixedDateToDateString(value)
}
```
https://github.com/typeorm/typeorm/blob/0ec4079cd3760dc49247a02c54415f16a2a51766/src/driver/postgres/PostgresDriver.ts#L838
```ts
/**
* Converts given value into date string in a "YYYY-MM-DD" format.
*/
static mixedDateToDateString(value: string | Date): string {
if (value instanceof Date) {
return (
this.formatZerolessValue(value.getFullYear(), 4) +
"-" +
this.formatZerolessValue(value.getMonth() + 1) +
"-" +
this.formatZerolessValue(value.getDate())
)
}
return value
}
```
https://github.com/typeorm/typeorm/blob/6f3788b83730463e3b787b2a98bb41695e13caf8/src/util/DateUtils.ts#L28
For reference the original type parser seems to be configured in
node-pg-types, on the 1182 OID, which is an array of date / 1082 OID,
this type parser calls another small library `postgres-date` which
creates a JS Date. I couldn't find a type parser explicitly on 1082 in
the stack TypeORM -> node-postgres -> node-pg-types -> postgres-date
```ts
register(1182, parseStringArray) // date[]
```
https://github.com/brianc/node-pg-types/blob/26bfe645a8ddc0c73830b1b8c63f2c4f8265b24f/lib/textParsers.js#L168C19-L168C45
```ts
static parse (dateString) {
return new PGDateParser(dateString).getJSDate()
}
```
https://github.com/bendrucker/postgres-date/blob/b3060560ed62c250f7800d29e4b68a9d5a0622bd/index.js#L285
# Calendar view mixing DATE and DATE_TIME
At the time of the refactor, DATE type wasn't properly tested with
calendar view, thus the drag and drop of a card with a calendar view on
a DATE field and not a DATE_TIME field, was not working, this is now
fixed.
Resolves [Dependabot Alert
74](https://github.com/twentyhq/twenty/security/dependabot/74).
Upgraded `graphql-upload` from `13.0.0` to `16.0.2`. Type exports
changed. API remains the same.
Tested the following upload flows:
- profile picture
- workspace logo
- rich text editor (image, video, file)
- record profile picture
- file associated to record.
They all work as intended, nothing breaks.
We were using a global ValidationPipe in main.ts. This is an issue as
@Controllers should return HttpExecption and @Resolvers should return
GraphqlErrors
Removing the global pipe and creating a ResolverValidationPipe able to
generate GraphqlError. We also need to handle the exception in a filter
to avoid nest to think it's unhandled and make it flow to logs
Next step:
- it would be nice to have both @UsePipes(ResolverValidationPipe) +
@UseFilters(GraphqlValidationExceptionFilter) come together. This should
be possible if we create a @GraphQLResolver annotation
Updated the environment variable name from PORT to NODE_PORT for more
clarity and alignment with node server conventions. Adjusted references
in configuration and application bootstrap accordingly.
Fix#10017
Added explicit typing for session storage options to improve type
safety. Enabled 'trust proxy' to ensure proper client IP and protocol
detection behind proxies. These changes improve security and reliability
in session handling.
Eliminated all references to `isSSOEnabled` across the frontend,
backend, and configuration files. This change simplifies the codebase by
removing unnecessary feature flag checks, associated logic, and
environment variables. The SSO feature remains available without
reliance on this flag.
Added new forbidden words and regex patterns to subdomain validation in
`update-workspace-input`. Enhanced the `ForbiddenWords` validator to
support both strings and regex matching. Updated tests to verify
regex-based forbidden subdomain validation.
Fix#9064
---------
Co-authored-by: Weiko <corentin@twenty.com>
Introduce `defaultWorkspaceId` to improve workspace redirection logic.
Updated GraphQL schema, server logic, and frontend components
accordingly to prioritize default workspaces when available.
## Summary
This PR adds a mechanism to handle and prioritize default workspace
selection for users during authentication. It updates the logic in
multiple components and services to ensure users are redirected to their
default workspaces if no specific selection is provided.
### Main changes:
- **GraphQL Schema Updates**:
- Enhanced `UserExists` GraphQL entity with a new `defaultWorkspaceId`
field to specify the user's default workspace.
- Updated queries and mutations to handle the `defaultWorkspaceId`.
- **Client-Side Updates**:
- Enhanced `useAuth` hook to include logic for managing default
workspace redirection.
- Adjusted UI logic in `SignInUpGlobalScopeForm` to utilize the
`defaultWorkspaceId`.
- **Server-Side Adjustments**:
- Modified `AuthService` to include `defaultWorkspaceId` in
`checkUserExists`.
- Default workspace logic added to the backend flow for consistent
handling.
- **Tests/Helpers**:
- Added utility and type changes to integrate the new backend response
changes (e.g., `UserExists` GraphQL).
- **Subsequent function lifecycle** was adjusted to include recheck for
workspace token states when performing sign-in flows.
## What it does
### Backend
- [x] Add a mutation to create OIDC and SAML configuration
- [x] Add a mutation to delete an SSO config
- [x] Add a feature flag to toggle SSO
- [x] Add a mutation to activate/deactivate an SSO config
- [x] Add a mutation to delete an SSO config
- [x] Add strategy to use OIDC or SAML
- [ ] Improve error management
### Frontend
- [x] Add section "security" in settings
- [x] Add page to list SSO configurations
- [x] Add page and forms to create OIDC or SAML configuration
- [x] Add field to "connect with SSO" in the signin/signup process
- [x] Trigger auth when a user switch to a workspace with SSO enable
- [x] Add an option on the security page to activate/deactivate the
global invitation link
- [ ] Add new Icons for SSO Identity Providers (okta, Auth0, Azure,
Microsoft)
---------
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
- Improve snackbar to enable displaying multi-line message (so far we
only displayed the first few words which was very frustrating)
- Followup on previous issue to enable tim@apple.dev on the demo
workspace (prefilled automatically)
- Fix sentry tracing which had been broken when migrating from v7 to v8
* feat: merge front and server dockerfiles and optimize build
* fix: update image label
* fix: bring back support for REACT_APP_SERVER_BASE_URL injection at runtime
* fix: remove old entries & add nx cache in dockerignore
* feat: generate frontend config at runtime using Nest
* fix: format and filename
* feat: use the EnvironmentService and leave default blank
* feat: add support for DB migrations
* feat: wip add user to sentry
* feat: wip interceptor
* feat: wip add user to sentry
* feat: add user into sentry errors
* fix: hide stack trace in production
* fix: properly log commands and handle exceptions
* fix: filter command exceptions
* feat: handle jobs errors