Report it privately by email to hello@flockstack.io, with "Security" in the subject, the affected version or commit, and the steps to reproduce it. Please don't open a public issue or pull request for a security report.
- Every customer document carries its
accountId, and a Mongoose plugin rejects any query, aggregation or bulk write on customer models without one. Cross-account lookups are explicit ({ crossAccount: true }) and limited to signed links, tracking links, public archive slugs, the API key lookup, an admin taking an address off the never-email list, which finds the contacts it stopped in every account, the campaigns and newsletters Gmail's feedback loop names, whose account it reads, and on FlockStack Cloud the daily totals of its public deliverability page, which keep only sums and counts across accounts. - Integration tests (
tests/isolation.test.mjs,tests/api.test.mjs,tests/mcp.test.mjs) sign in two accounts and check that neither can list, read, change or delete the other's newsletters, contacts, campaigns, API keys or uploads, or sign contacts up into the other's newsletter, through server actions, data functions, the REST API and MCP. - The dashboard shows one newsletter at a time, named by a cookie that is never trusted: every read checks it against the account and falls back to the account's first newsletter, and the newsletter menu and
/newsletters/<id>only set it to one of the account's newsletters. Server actions that take a newsletter (new campaigns, imports, exports) check the ID against the account too (tests/newsletters.test.mjs). - Uploads are stored under
accounts/<accountId>/, a path that comes from the account, never from the request. On S3 the bucket is private: readers get images through the CDN, and the app's IAM user can only put, get and delete objects underaccounts/(deletes only for a deleted account's purge and for an upload whose record wasn't saved) and create invalidations on the CDN's distribution, with keys of its own, never default AWS credentials.
- The dashboard, the REST API and the MCP server run the same actions (
lib/actions), so the same permission, plan, rate limit and input checks apply everywhere. A test fails if a dashboard server action does anything but run a registered action, apart from the newsletter menu's, which only sets which of the account's newsletters the dashboard shows. - Only the dashboard's Add contact skips confirmation: the contact is active at once when the signed-in user ticks that the person agreed, and the server refuses it otherwise. The REST API and MCP don't take that box, so their signups confirm when the newsletter asks.
- API keys are 32 random bytes, stored as SHA-256 hashes and shown once. They belong to one account and only reach its data. Revoking one takes effect on the next request.
- Two levels: full access, and signups only, which can only add contacts. Keys made before levels count as signups only. Only a full access key can create keys, and an account has up to 20 active ones.
- Each key gets 300 requests a minute across REST and MCP, plus an hourly limit on actions that send, import, upload, add domains, create keys or call Stripe. The account gets each hourly limit once too, shared by all its keys and connected apps, so more keys or apps don't raise it.
- The dashboard's calls count against the account's hourly limits on what costs something each time: test emails and sends, imports (a file counts once, whatever its chunks), image uploads, adding and checking domains, and Stripe. Test emails only go to the account owner's own address.
- Signups, which send the welcome or confirmation email to any address, take 600 an hour per key, and 600 an hour per account across its keys, connected apps and contacts added by hand. Sites that pass their visitor's IP get 5 per visitor in 10 minutes.
- A newsletter's slug changes 3 times a day per account, on every channel: each change moves its shared address at the email provider and in DNS, out of budgets every account shares.
- Only the signup endpoint answers browsers (CORS), so a full access key in a web page doesn't work from it.
- Errors outside the documented codes answer
internal_errorwithout detail; the detail goes to the server log. - The MCP send tools are annotated destructive and say sending can't be undone. Sending a campaign that is already sending or sent answers
conflict. - Every action has a scope class (
lib/action-scopes.js; read, write-draft, write, send, destructive, admin) and every key and connected app a level that allows some classes: read only, drafts only, send with approval, full, or signups only. A key can be limited to one newsletter, to a number of calls a day, and to a date. A test fails when an action has no class or where its newsletter comes from isn't said. - A send with approval key's send and destructive actions (and any key's, on a newsletter that asks) are stored in
ApprovalRequestwith their exact input and its SHA-256 hash and not run (lib/agent-guard.js,lib/approvals.js). Only a signed-in person approves, throughdecide_approval, which is a registry action markeddashboardOnly: it has no REST route, OpenAPI entry or MCP tool, andrunActionrefuses it to anything but the dashboard. Approving claims the request with a conditional update (one winner), checks the hash and that the key or app still exists, and runs the action once as that key or app with the stored input. Requests expire after 24 hours; their inputs go a week after the decision, the requests after 90 days. - A key below full access can't create keys, open billing, change prices, manage webhooks or export the account, so it can't widen itself. An agent can't edit a campaign once it is scheduled, or an active automation, until it is unscheduled or paused, so what the owner approved can't change before it goes out.
- The agent activity log (
AgentActivity) holds ids, names, option names and counts per call, never a subject, a body or an address, and is deleted after 90 days by the hourly cron; the per-day counts (AgentDay) go with it. - OAuth (ChatGPT, Claude): Better Auth issues access tokens signed RS256 with a key it keeps encrypted with
BETTER_AUTH_SECRET./mcpaccepts a token only with FlockStack's issuer,/mcpas its audience, theat+jwttype, an unexpiredexp, one of theflockstack:scopes, a valid DPoP proof when it's bound to a key, and the id of a consent that still exists for its user and app. Tokens last an hour; refresh tokens are stored hashed, rotate on use, and expire 90 days after the last use. - An app gets the access its user picks on the consent page, at the levels of an API key and no more than it asked for (
flockstack:read,:drafts,:send,:full), in its user's account only, after the user signs in and allows it there; the page names the app's host. Connections made before levels had none stored and keep full access. PKCE (S256) is required, redirect URIs must match the app's client metadata document or registration exactly (loopback ports aside), and responses carry the issuer (RFC 9207). Disconnecting an app on the API keys page deletes its tokens and consent; its access tokens answer 401 from the next request. - Client metadata documents are fetched over https with the address resolved once and pinned, private and reserved addresses refused, redirects refused, 5 KB at most. Better Auth's client and consent management routes are closed.
- Dynamic client registration (RFC 7591, for Grok Bot on Cursor's agents) takes public clients only (
token_endpoint_auth_methodnone) whose redirect URIs are all on the allowlist inlib/oauth-rules.js, without a back-channel logout URI, 10 an hour per IP. Registered clients are bound to/mcpand go through the same consent page. - Contacts exports are signed links (a key derived from
BETTER_AUTH_SECRET) that name the account and the filters and expire after an hour. Anyone holding a link can download the file until then. - Images uploaded by URL are downloaded over https on port 443 only, and the connection is refused when the host resolves to a loopback, private, link-local, carrier-grade NAT, documentation, multicast or reserved address, checked at connect time and after each of up to 3 redirects. Size is capped at 4 MB while reading.
- A from address is either the newsletter's shared address (
hello@<slug>.SHARED_DOMAIN) or on a domain the account verified; it's checked when a newsletter is saved, before every send and again for each email. A domain, a shared sending subdomain and an archive subdomain belong to one account, and at the provider to that account's tenant only. - Provider events are recorded only in the account they name: SES's
account_idtag; PostShiba'saccount_idunique arg, which has to match the PostShiba tenant on the event, or, without unique args, that tenant's account. An event whose tenant isn't the named account's is dropped. CSV imports and contacts added by hand record the customer's consent, who gave it and when.
- Better Auth sessions in httpOnly cookies, stored in the database, so signing out or revoking a session takes effect at once.
- Session cookies are host-only (no
Domain), and Better Auth trustsAPP_URL's origin only. When the shared domain is the app's own (FlockStack Cloud: archives on<slug>.flockstack.io), archive hosts never get the cookie, but browsers still send it with an archive page's requests to the app, the same site. Better Auth refuses those from any other origin, server actions check the origin against the host, uploads take same-origin requests only, and archive pages run no customer script and post forms only to themselves. Slugs can't takewwwor FlockStack's other subdomains (lib/reserved-slugs.js), and those hosts never open an archive. - Sign-in codes are 6 digits, expire after 5 minutes, die after 3 wrong tries and are stored hashed. Sending them is rate limited per IP and per inbox (an address's dots and +tags count as one where its provider ignores them), checking them per IP. Better Auth's own code and password routes are closed.
- A Google or Apple sign-in opens a new account, or joins an existing user, only when the provider says it verified that email: neither is a trusted provider for account linking. Apple's client secret is signed per isolate from the
.p8key (APPLE_PRIVATE_KEY), valid for a day, andhttps://appleid.apple.comis the one other origin Better Auth trusts, for Apple's form post to the callback. - FlockStack Cloud takes one account per inbox: a new user whose address reaches an existing user's inbox (Gmail ignores dots and +tags; Outlook, Hotmail, Live, iCloud, Fastmail and Proton +tags; Yahoo -tags, in
lib/email-aliases.js) is refused once the code or Google proved the address, so the refusal tells nobody else anything. Throwaway domains get no code and no new account, and an IP address gets 3 new accounts a day. Accounts from before keep signing in. - Without
RESEND_API_KEYor an email provider, development prints codes in the server console; production never does. - After sign-in, the app only goes on to a path on its own site with at most a plain query string (
/billing/checkout?plan=growth&interval=year): never another host or a protocol-relative path. /billing/checkout(the plan buttons) only opens Checkout for a signed-in account without a plan. A paying account gets the billing page, where Stripe confirms any change.- An account without a plan can set up its newsletter and write campaigns, nothing that reaches outside FlockStack: no emails (tests and signup emails included), imports, domains or API keys, nothing at the email provider or in DNS until its plan starts, and 20 image uploads and 5 contacts added by hand a day.
/adminand its actions answer 404 to anyone whose verified email isn't inADMIN_EMAILS.- The server refuses to start with a
BETTER_AUTH_SECRETunder 32 characters.
- Only its owner deletes it, on the account page: it isn't an action, so no API key or connected app can, and a test fails if one appears. Better Auth's user update route is closed too: the page changes the name, and a photo only to one of the account's uploads.
- It's refused while Stripe bills a plan (active, trialing or past due), which the billing portal cancels, and while a campaign sends or is scheduled.
- It takes a code sent for it, of a kind no sign-in uses (Better Auth's email OTP, stored hashed, 5 minutes, one use, 3 tries, 3 codes per 15 minutes), and the account's email typed out. The email says what the code is for, so an owner who didn't ask knows.
- At once, every session of the owner ends, API keys are revoked, connected apps lose their consents and tokens, and the account counts as unpaid everywhere, self-hosted too: no emails, signups or archive, and the REST API, MCP and the dashboard refuse it. Its tenant is suspended at the provider.
- The purge runs 30 days later as a signed QStash job. Signing in before then offers to restore the account; once the purge starts, it can't be. Every query it runs names the account, so it can't reach another one. It deletes the user with its sessions, sign-in methods and codes, everything with the account's id, its domains and shared subdomains at the provider with the DNS records published for them, its tenant, and its images on S3 and Vercel Blob, then invalidates the account's folder on CloudFront so the CDN stops serving them.
- What stays: Stripe's own billing records, and a deletion record with an HMAC of the email (a key derived from
BETTER_AUTH_SECRET), the account's id and the dates, nothing else. A new account with that email alerts the admins. The never-email list keeps the entries its emails added: they name no account.
Without FLOCKSTACK_CLOUD=true (only that exact value counts), a server is self-hosted:
- Only
ALLOWED_EMAILS(addresses, and@domainfor every address of exactly that domain) andADMIN_EMAILSsign in. With both empty, nobody does: it's never open by default. - The list is checked when a code is asked for and when it's checked, and Better Auth's user and session hooks refuse anyone off it whatever the route (codes, Google, Apple, the auth API), so no new user or session can exist for them. The email also has to be verified (by the code, Google or Apple).
- Someone taken off the list is signed out at their next request, and their API keys stop working on the REST API and MCP, until they're back on it.
- There's no billing: no paywall, no plan limits, and the billing actions, the billing page and the Stripe webhook answer that billing is off. The automatic blocks on spam signals stay on.
- FlockStack Cloud's marketing pages, which the open source doesn't have, answer 404. The public docs footer keeps a link to the source code, which the AGPL asks a modified server to offer its users.
- Sign-in codes and alerts go through Resend, apart from customers' emails. On FlockStack Cloud they never go any other way. A self-hosted server without
RESEND_API_KEYsends them through its email provider fromEMAIL_FROM: on SES outside every tenant, on PostShiba through a tenant of their own (mc-platform), so a customer's tenant never carries them. SENDING_ENABLEDholds readers' emails only: sign-in codes and alerts don't wait for it.- Admin alerts that need someone (
lib/alert-rules.js) also go to Telegram whenTELEGRAM_BOT_TOKENandTELEGRAM_CHAT_IDare both set: in Telegram's HTML with everything but the app's name escaped, each at most once every 6 hours, and never in the way of the request that raised them. The token is part of the Bot API's address, so no error or log line names it.
- FlockStack Cloud refuses to start without
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRETandRESEND_API_KEY, and/api/healthanswers 503 with which one is missing (names, never values), so the cloud can't run free or without sign-in emails by mistake. - Stripe webhooks are verified with
STRIPE_WEBHOOK_SECRET, and every subscription event re-reads the subscription from Stripe instead of trusting the payload. - The checkout return only syncs a session whose
client_reference_idis the signed-in account. - Checkout refuses a Stripe price whose amount or interval doesn't match
lib/plans.js, and a plan isn't offered or accepted until Stripe has both of its prices at those amounts. - Every email counts toward the plan's emails for the month: issues, last-chance, tests, welcome and confirmation emails. Campaign and welcome emails take their room in one atomic update before they go out (
claimPlanEmailsinlib/usage.js), with the plan's numbers, a free plan's or an admin's overrides, so signups (600 an hour per account) can't send welcome emails past the plan. Without room, a welcome email is skipped for good, itsMessagerecords why, and the owner is emailed once that month; welcome emails start again when the month resets or the plan grows. Confirmation emails always go out, so signups never break, and still count. A self-hosted server has no limits.
- SES only runs with FlockStack's own keys (
SES_ACCESS_KEY_ID,SES_SECRET_ACCESS_KEY), never with default AWS credentials from the machine.EMAIL_PROVIDERwith an unknown value stops sending instead of picking a provider. - Each account has its own tenant at the provider. PostShiba's SMTP password for a tenant (shown once) is stored AES-256-GCM encrypted with
CREDENTIALS_ENCRYPTION_KEY, a random IV per value and the account and cluster authenticated with it, so a value copied onto another account doesn't decrypt. It's left out of every read of the account but the PostShiba adapter's (which decrypts it for campaign sends), never sent to a browser and never logged; a separate transactional cluster's password isn't kept at all. - Bounce and complaint rates have two levels (
LIMITSinlib/config.js, judged out of emails sent inlib/rate-levels.js), each with a minimum count, so one complaint never stops anything. Over 2% hard bounces with at least 5, or 0.08% complaints with at least 2, a campaign pauses and its owner is emailed; they can resume it, and from then on only the block level stops that campaign. A first send's slice at that level holds the rest of its campaign the same way; the last 7 days at that level only warn the owner, once a day at most. Gmail's spam rate for an account at 0.08% in Google Postmaster Tools pauses its sending campaigns the same way, or warns its owner when none sends. - Clear abuse blocks the whole account (
lib/account-blocks.js): over 5% hard bounces with at least 10, or 0.3% complaints with at least 3, on a campaign, a first send's slice or the last 7 days; Gmail's spam rate for the account at 0.3% in Google Postmaster Tools; a hard bounce that names a spam trap; SES disabling the tenant or a high-impact finding; PostShiba'soperator_hold; and an admin. A blocked account sends nothing, welcome and confirmation emails included, on any plan; its tenant is suspended at the provider, its archive takes no signups, and the dashboard, REST API and MCP refuse every change withaccount_blockedexcept pausing, unscheduling, discarding, revoking keys and apps, and billing. Its emails' tracked links stop redirecting: they answer 403 with a page saying the link is disabled, without the destination,noindexandno-store, so unblocking brings them back on the next click. Its opens and clicks aren't counted, and unsubscribe links, one-click unsubscribe and pending readers' confirmation links keep working. Every block emails the admins at once. Nothing lifts a block on its own, a provider lifting its pause included: only an admin, from/admin, once the provider resumed the tenant. - Gmail reports no single complaint, only spam rates, in Google Postmaster Tools, which FlockStack watches. Every email carries a
Feedback-IDof its campaign (or, for signup emails, its newsletter), its account asmc-<accountId>, its kind andflockstack: no address, nothing unique to one email. A daily job (/api/cron/postmaster, only withCRON_SECRET) reads the domain's spam rate and the feedback IDs Gmail flags, counted by DKIM signer, maps each to its account and acts on each day of data once: from 0.08% the account's sending campaigns pause, at 0.3% it's blocked, never twice and never for the emails of the days before an admin unblocked it. The domain at 0.1%, or an ID that names no account, alerts the admins. - Postmaster Tools is connected from
/adminonly (404 for anyone else), through Google's OAuth with the read-only traffic scope (postmaster.traffic.readonly), PKCE, and a state in a__Host-cookie only the admin's browser holds, for 10 minutes. The refresh token is stored AES-256-GCM encrypted withCREDENTIALS_ENCRYPTION_KEY, left out of every read but the job's, never sent to a browser or logged. Google refusing it alerts the admins once until it works again. Without Google's client, the key or a connection, the job does nothing. - On FlockStack Cloud, how fast a send goes out is earned by clean volume, not by a number of campaigns or time alone (
WARMUPinlib/config.js,lib/send-spread.js). An account's proven volume is its largest campaign of the last 60 days, steps and last-chance emails included, whose bounces and complaints stayed under the pause level, counted only once its last email is a day old. A send, counted with what the account's other sends still have to send or sent in the last 24 hours (so a send split into small ones, or sent beside others, gets the same pace), goes at full speed up to 200 emails, or up to its proven volume times 2 on the first day of the account's 30-day warm-up (from its first campaign), rising each day to 5 on its last and after it. A bigger send goes out over 4 hours plus 4 for each time it doubles that, 72 at most, its batches spread evenly to the end. A resume keeps its send's pace, and each step of a send in steps gets its own. Pacing never refuses a send: plan limits, pauses and blocks apply as before. An admin can end the warm-up early, which leaves the 5 times rule, or start it again. - Contacts the account never emailed go in a slice of 5% (200 to 2,000), and the rest waits 3 hours for the slice's bounces and complaints (
FIRST_SENDinlib/config.js,lib/first-send.js), during the warm-up and after it. In a paced send, the held rest goes out over the rest of its hours. - Every address takes one syntax: a dot-atom, no doubled dots, a domain ending of letters or
xn--. Imports drop misspelled provider domains (lib/email-typos.js) and spam trap addresses; the archive's form,add_contactand MCP refuse them, and domains that can't receive email, with the address a misspelling was likely meant to be. - Every account sends through the same providers, so one never-email list covers all of a server's accounts (
lib/global-suppressions.js). On it: a hard bounce PostShiba classifies Invalid Address (the mailbox or its domain doesn't exist, or it moved; never a full or disabled mailbox, a refusal, a 4.x.x code or an expired message), SES's NoEmail and Suppressed hard bounces, and a hard bounce that names a spam trap, each only for the address an email of FlockStack's went to, so an account's events can't list an address it never emailed. Hard bounces leave after 180 days through a TTL index, spam traps never. Complaints, soft bounces and imported statuses stay their account's own. - The list keeps an HMAC-SHA256 of each address, keyed from
SUPPRESSION_HASH_KEYalone (neverBETTER_AUTH_SECRET), of the address as delivered: lowercased, a +tag dropped where the provider delivers it to the base address, Gmail's dots and Yahoo's -tags kept. Next to it only the reason, the provider's type and status code (never its words, which can quote the address) and the dates; nothing names an account or a newsletter. The server refuses to start without a key of 32 characters or more. - Imports leave listed addresses out as undeliverable;
add_contact, MCP, Add contact and the archive's form refuse them with "This address can't receive email", whichever account's email listed them, its own included. Each send batch checks its addresses, so a contact added before its address was listed is skipped too: its email is suppressed, and the contact becomes bounced, marked undeliverable on the Contacts page, without touching the account's own suppression list. An admin looks an address up on/adminand can take it off, which makes the contacts it stopped with that exact address active again. - Unsubscribe, confirmation, "keep sending it" and tracking links are signed with keys derived from
BETTER_AUTH_SECRET. Rotating it signs everyone out and breaks those links in emails already sent. - SES webhooks only accept messages signed by AWS SNS for the topics in
SES_SNS_TOPIC_ARNandSES_TENANT_SNS_TOPIC_ARN. The PostShiba webhook only accepts requests whoseX-Capsule-Signatureis the HMAC-SHA256 of{timestamp}.{raw body}withPOSTSHIBA_WEBHOOK_SECRET, compared in constant time, with a timestamp within 5 minutes; redelivered events are recorded once. Background jobs only run when QStash signed the request with the server's own signing keys, whateverQSTASH_DEVsays (except innext dev); any other request answers 403. The hourly check (/api/cron/hourly) only runs withAuthorization: Bearer CRON_SECRET, compared in constant time, and answers 503 without aCRON_SECRET. - The app never holds a Vercel token. On FlockStack Cloud, shared subdomains' records go through a DNS helper (
dns-broker/), a Vercel project of its own and the only place with the token. It takes requests signed withDNS_BROKER_SECRET(HMAC-SHA256 of{timestamp}.{body}, compared in constant time, 5 minutes at most), has flockstack.io, the team and the list, create and delete record endpoints fixed in its code, and only writes a shared address's records under<slug>.flockstack.iofor a slug that isn't FlockStack's: the archive's CNAME tocname.vercel-dns.com, PostShiba's DKIM CNAME to*.customers.postshiba.comand return path CNAME torp.postshiba.com. It tags each record for its slug, deletes only those, never writes in a slug that has a record it didn't tag, stops at 900 new records a day, and logs every create, delete and refusal without the token, the secret or headers. When it refuses or fails to publish a shared address's records, the app takes the address's domain back at the provider, so nothing stays there that the newsletter doesn't name; the next save or send tries again. - Public endpoints (signup form, signup API, unsubscribe, views, sign-in) are rate limited. With Upstash Redis the limits are shared across server instances; without it, each instance counts in memory.
- Issue HTML shown on the archive goes through an allowlist cleaner and
sanitize-htmlon every render; HTML issues render in a sandboxed iframe without scripts. - Every response carries a Content Security Policy (no framing, no plugins, no inline event handlers, forms and
<base>limited to the app's origin) and HSTS. Click tracking only redirects to links signed by the app, and never for a blocked account.
- The first account to add a domain holds it until it removes it, verified or not; its real owner has to ask support.
- On PostShiba, Gmail sees PostShiba's own
Feedback-ID(mc-<accountId>:…:postshiba) in place of FlockStack's, so its feedback loop reports accounts, not campaigns. - Gmail's data comes a day or two late, only for domains that send to at least 50 Gmail users a day, and only for feedback IDs with enough emails and spam reports: a small account's complaints at Gmail stay out of sight.
- A full access key can do anything the owner can, billing links included, and an approval only protects what goes through it: turn on "Ask me before an agent sends" for a newsletter to make even a full access key's sends and deletes wait. A key limited to one newsletter is checked per action (
lib/action-scopes.js); an action added later without an entry there is refused to it, and a test fails until it has one. - Invalid keys aren't rate limited by IP before their database lookup (a unique index on the hash, so each costs one indexed read).
- Tenants separate suppression lists and provider pauses, but capacity and IP reputation are still shared: SES's account quota and reputation, and on PostShiba each cluster's shared IP and hourly cap, used by every account on it.
- Vercel tokens can't be limited to DNS (a project-scoped token can't edit it): the helper's token can do anything in the team, and only the helper's code and its own project keep it to flockstack.io's records. Anyone with
DNS_BROKER_SECRETcan create or delete shared address records within those rules, up to the daily cap. - Customers' own archive subdomains aren't added to the Vercel project for them: on Vercel that's done by hand.
- When a slug changes, or a new slug's records can't be published, a sending subdomain the provider fails to remove stays in the account's tenant (an admin alert says so) and blocks the slug's next owner from its shared address until it's removed.
- The DNS helper's 900 new records a day are shared by every account: about 300 new shared addresses a day. Past them, new ones are refused until the oldest records are a day old, then set up at their newsletter's next save or send (an admin alert names each one).
- On a self-hosted server without a shared domain or an archive subdomain, the archive is served from the app's own domain, where only the sanitizer, the sandboxed frame and the Content Security Policy keep issue HTML away from the dashboard.
- Sign-in tells whether an email is on a self-hosted server's list. The per-IP and per-email limits slow down anyone trying addresses.
- Signups tell whether an address is on the never-email list, in the same words whichever account's email put it there and whether it bounced or hit a trap. The signup limits per key, account and visitor slow down anyone trying addresses.
- A hard bounce the provider misclassifies stops its address on every account for up to 180 days, until an admin takes it off; contacts it stopped under another +tag of the address stay bounced.
- Deleted contacts' hashes (
lib/contacts.js) are still keyed fromBETTER_AUTH_SECRET: moving them to a key of their own needs the addresses, which aren't kept.