Skip to content

feat: offer to copy a sign-in code from the new-mail notification on Android #147 - #148

Open
wmcparla wants to merge 1 commit into
bulwarkmail:mainfrom
wmcparla:feat/copy-code-from-notification
Open

wmcparla wants to merge 1 commit into
bulwarkmail:mainfrom
wmcparla:feat/copy-code-from-notification

Conversation

@wmcparla

@wmcparla wmcparla commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

When a new mail's subject or preview carries a sign-in code (two-factor, verification, one-time password), its Android notification offers Copy 482913 as its first action, in place of Reply (#147). Tapping it copies the code and dismisses the notification. Mail without a code keeps Mark as read, Delete and Reply as today.

A code mail in the tray After Copy

Android 13+ shows the copied code masked in its clipboard preview, because the clip is marked sensitive.

Changes

  • Detection (OtpCode.kt, new): searches the subject and the server's preview. Offering a wrong number is treated as worse than offering none, so it errs towards none.
    • A number counts only when it's close to a word that names a code, in the app's languages ("code", "verification", "one-time", "PIN", "Bestätigungscode", "engangskode", "kodunuz", "código", "код", "验证码", "인증", "รหัส", ...). A bare English "code" also needs a sign-in word somewhere in the text ("verify", "sign in", "password", "your code", ...), so prose like "a new code formatter, 4500 commits" offers nothing.
    • The code comes after that word, or before it only where it leads the subject or a line ("482913 is your code"). So "Account 40021987 verification code: 482913" and "Issue 48213 updated: login code ..." never offer the first number.
    • Shapes:
      • 4 to 8 digits, or two groups of 3 or 4 ("123 456");
      • 5 to 8 capitals and digits mixed through, like Steam's F4K2N or Slack's Q7K-2M9.
      • Groups are copied without the space or hyphen, and full-width digits become plain ones.
    • Never counted:
      • promo, gift card, postal, error and decline codes, and barcodes;
      • the number a code is about ("card ending in 4821", account, issue, case, shipment, flight numbers);
      • phone and dial-in numbers (a longer row of digit groups);
      • prices, times, dates, decimals and #123456;
      • a word-then-number promo shape ("BF2026", "HERBST25");
      • a year, unless it comes straight after the word ("PIN 2048", "your code is 1987");
      • anything in mail that sells something ("25% off", "checkout") or invites to a meeting (a Zoom passcode isn't offered);
      • letter codes in travel mail (booking references).
  • The notification (BulwarkFcmModule.postNotification):
    • A found code adds the Copy action first and drops Reply, since the tray shows three actions.
    • The label carries the code, so a wrong pick is visible before it is pasted.
    • The search is wrapped in runCatching: a fault in it can't cost the notification.
    • Both the native lookup and the JS task end here, so both get it.
  • The tap (BulwarkNotificationActionReceiver):
    • Writes the clip with android.content.extra.IS_SENSITIVE, labelled with the app's name rather than the code.
    • Dismisses the notification through the receiver's existing path (group summary included), as Mark as read and Delete do.
    • Below Android 13, which shows no confirmation itself, a toast says "Copied to clipboard".
    • No server call is made.
  • Organisation rule: an account whose organisation hides notification content gets no subject or preview in its notification, so there is nothing to search and no action. The test for that case now also checks copyCodeLabel is absent.
  • Strings:
    • notifications.copy_code ("Copy {code}") is new in every locales/rn/<lang>.json, machine-translated. Android puts the code in place of {code}.
    • The toast reuses notifications.copied_to_clipboard.
    • Both are passed to the native side the same way as Mark as read, Delete and Reply (showNotification options and the shared strings of the native lookup), with English fallbacks in strings.xml.

Related issues

Closes #147

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactor / code quality improvement
  • Chore / dependency update / CI change

Checklist

  • I have read the Contributing Guide
  • My code follows the project's code style and conventions
  • I have run npm run typecheck and there are no errors (native has no lint script)
  • The build passes (no npm run build in native; an x86_64 release APK, assembleGithubRelease -PallowDebugSignedRelease=true, builds and runs)
  • I have tested my changes locally
  • I have added or updated documentation if needed (none needed)
  • I have updated translations (locales/) if my changes affect user-facing text (machine-translated; npm run i18n:check passes)
  • I have included screenshots or a screen recording for UI changes

Tests:

  • npx vitest run: 7103 passed, 163 skipped (the live Stalwart suites). npx tsc --noEmit and npm run i18n:check are clean.

  • ./gradlew :app:testGithubReleaseUnitTest: every suite passes, including the new OtpCodeTest (22 tests). It covers:

    • real-shaped code mails in English, French, German, Spanish, Russian, Chinese, Japanese (full-width), Korean, Thai, Norwegian, Swedish, Turkish and Romanian;
    • the Steam and Slack shapes;
    • the mails that must offer nothing.
  • Beyond the unit tests, a separate review ran 74 realistic mails through the detector:

    • 2FA mail from about 20 services;
    • newsletters, receipts, shipping, statements, Jira, GitHub, meeting invites and coupons.

    69 came out as expected. Left as they are:

    • Steam codes with fewer than two digits, which aren't offered;
    • Google's SMS-style G-482913, which isn't offered;
    • a news article about 2FA codes, which offered a number from its text.

On a device (Android 16 emulator, x86_64 release build, test accounts on a self-hosted Stalwart, mail delivered over SMTP and pushed through FCM and the hosted relay):

  • Native lookup, first version of the detector, 7 of 7 as predicted:
    • code in the preview, code in the subject only, a German code mail and a meeting invite showed Copy with the right code, then Mark as read and Delete;
    • an order-shipped mail, a "25% off" mail and a "card ending in 4821" mail showed Mark as read, Delete and Reply.
  • JS task, the same 7 of 7: run on a local build whose native lookup always falls back (not part of this PR). The log showed all seven handled by the task.
  • Final detector (this commit), native lookup, 9 of 9 as predicted:
    • code in the preview, code in the subject only, the German mail and a password-reset code showed Copy with the right code;
    • the order mail, the "25% off" mail, the meeting invite (now on purpose without Copy), "card ending in 4821" and a shipment number before "code" showed Mark as read, Delete and Reply.
  • The tap, on both builds: the notification closed, the clipboard preview showed the code masked, and pasting into another app's text field gave the code.

Not tested:

  • The toast on Android 12 and below: no such emulator here.
  • The label in a language other than English on a device: the unit tests check that the templates keep {code}.

🤖 Generated with Claude Code

…Android bulwarkmail#147

When a new mail's subject or preview carries a sign-in code (two-factor,
verification, one-time password), its notification shows "Copy 482913"
as the first action, in place of Reply: the tray shows three actions, and
nobody answers a code mail. Tapping it copies the code, marked sensitive
so the clipboard preview of Android 13+ hides it, and dismisses the
notification; below Android 13 a toast says it was copied.

A number only counts near a word that names a code (in the app's
languages), after it, or before it only where it leads a line. It does
not count where that word names something else (promo, gift card, postal,
error or decline code, barcode), where the number is what the code is for
(card ending in, account, issue, shipment, flight), for a phone or dial-in
number, or in mail that sells something or invites to a meeting. Offering
a wrong number is worse than offering none.

Both the native lookup and the JS task end in postNotification, so both
get it; a login whose organisation hides notification content shows no
content and so no code. The label is new in every locale (machine
translated); the toast reuses notifications.copied_to_clipboard.
@wmcparla
wmcparla marked this pull request as ready for review November 15, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Copy a sign-in code straight from the new-mail notification on Android

1 participant